feat: replace patient messages tab with pinnable notes

Port the tauri AddNoteModal notes feature into the admin patient case-file,
replacing the mislabeled «پیام‌ها» (SMS log) tab with «یادداشت‌ها».

Backend (src/Patient):
- PatientNote entity + repository (record-scoped, pinned-first ordering)
- CRUD endpoints on PatientController: GET /notes, POST /note,
  PATCH /note/{uuid} (edit body + toggle pin), DELETE /note/{uuid}
- author display name captured server-side from the current user
- migration for patient_notes; docs/api/patient.md updated

Frontend (assets/admin):
- NotesTab: compose box, newest/oldest sort, pinned-first list with
  accent rail + pin/edit/delete, edit modal, confirm-delete, empty state
- tab key/label/icon messages -> notes; onAddNote deep-links the notes tab

Tests: PatientNoteTest (create/list-order/edit/pin/delete/validation/ownership),
PatientDetailPage notes cases (render/empty/pin/create).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 12:51:33 +03:30
co-authored by Claude Opus 4.8
parent 65083854cb
commit 15c92903e1
8 changed files with 636 additions and 39 deletions
+63 -4
View File
@@ -211,12 +211,71 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => {
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/session/s1', { payment_method: 'wallet' }));
});
it('renders the messages tab with a send box', async () => {
it('renders the notes tab with a compose box and empty state', async () => {
renderDetail();
await loaded();
fireEvent.click(screen.getByText('پیام‌ها'));
expect(await screen.findByPlaceholderText('متن پیام...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ارسال' })).toBeInTheDocument();
fireEvent.click(screen.getByText('یادداشت‌ها'));
expect(await screen.findByText('افزودن یادداشت جدید')).toBeInTheDocument();
expect(screen.getByPlaceholderText('یادداشت خود را بنویسید...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /ذخیره یادداشت/ })).toBeInTheDocument();
// حالت خالی (API لیست خالی برمی‌گرداند)
expect(await screen.findByText('یادداشتی ثبت نشده است.')).toBeInTheDocument();
});
it('renders notes pinned-first with author, date and a pin control', async () => {
get.mockImplementation((url: string) => {
if (url === '/api/v1/patient/r1') return Promise.resolve({ success: true, data: {
uuid: 'r1', user_name: 'ساغر صابری', record_number: 'P-1001', created_at: 1700000000, profile: null,
} });
if (url === '/api/v1/patient/r1/notes') return Promise.resolve({ success: true, data: [
{ uuid: 'n-pin', body: 'یادداشت پین‌شده', pinned: true, author: 'دکتر احمدی', created_at: 1700000000, updated_at: null },
{ uuid: 'n-2', body: 'یادداشت عادی', pinned: false, author: 'منشی رضایی', created_at: 1700009000, updated_at: 1700010000 },
] });
return Promise.resolve({ success: true, data: [] });
});
renderDetail();
await loaded();
fireEvent.click(screen.getByText('یادداشت‌ها'));
expect(await screen.findByText('یادداشت پین‌شده')).toBeInTheDocument();
expect(screen.getByText('یادداشت عادی')).toBeInTheDocument();
expect(screen.getByText('دکتر احمدی')).toBeInTheDocument();
expect(screen.getByText('منشی رضایی')).toBeInTheDocument();
expect(screen.getByText('(ویرایش‌شده)')).toBeInTheDocument(); // یادداشت دومی updated_at دارد
expect(screen.getByText('یادداشت‌های قبلی (۲)')).toBeInTheDocument();
// pinned note is rendered before the normal one
const bodies = screen.getAllByText(/یادداشت (پین‌شده|عادی)/).map((el) => el.textContent);
expect(bodies[0]).toBe('یادداشت پین‌شده');
});
it('posts a new note from the compose box', async () => {
const post = api.post as ReturnType<typeof vi.fn>;
post.mockResolvedValue({ success: true, data: {} });
renderDetail();
await loaded();
fireEvent.click(screen.getByText('یادداشت‌ها'));
const box = await screen.findByPlaceholderText('یادداشت خود را بنویسید...');
fireEvent.change(box, { target: { value: 'حساسیت دارویی' } });
fireEvent.click(screen.getByRole('button', { name: /ذخیره یادداشت/ }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/patient/r1/note', { body: 'حساسیت دارویی' }));
});
it('toggles pin on a note via PATCH', async () => {
const patch = api.patch as ReturnType<typeof vi.fn>;
patch.mockResolvedValue({ success: true, data: {} });
get.mockImplementation((url: string) => {
if (url === '/api/v1/patient/r1') return Promise.resolve({ success: true, data: {
uuid: 'r1', user_name: 'ساغر صابری', record_number: 'P-1001', created_at: 1700000000, profile: null,
} });
if (url === '/api/v1/patient/r1/notes') return Promise.resolve({ success: true, data: [
{ uuid: 'n-2', body: 'یادداشت عادی', pinned: false, author: 'منشی رضایی', created_at: 1700009000, updated_at: null },
] });
return Promise.resolve({ success: true, data: [] });
});
renderDetail();
await loaded();
fireEvent.click(screen.getByText('یادداشت‌ها'));
fireEvent.click(await screen.findByRole('button', { name: 'پین کردن' }));
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/patient/note/n-2', { pinned: true }));
});
it('renders appointment turn cards + toolbar on the نوبت‌ها tab', async () => {
+155 -35
View File
@@ -5,7 +5,7 @@ import {
ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon,
CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon,
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
ArrowUpTrayIcon, TrashIcon, DocumentIcon,
ArrowUpTrayIcon, TrashIcon, DocumentIcon, UserIcon,
} from '@heroicons/react/24/outline';
import { PlusIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
@@ -28,7 +28,7 @@ import InvoiceSummaryModal from '../components/InvoiceSummaryModal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { TurnsFilter, AddTurn } from '../components/icons/FilesToolbarIcons';
import {
TabServices, TabInfo, TabCalendar, TabCard, TabWallet, TabSMS, TabCall, TabAttach, TabBody,
TabServices, TabInfo, TabCalendar, TabCard, TabWallet, TabCall, TabAttach, TabBody,
} from '../components/icons/FilesServiceIcons';
import PatientRecordInfoForm from '../components/PatientRecordInfoForm';
import WalletTransactionModal from '../components/WalletTransactionModal';
@@ -38,7 +38,7 @@ import {
GENDER_OPTS, MARITAL_OPTS, EDUCATION_OPTS, REFERRAL_OPTS,
} from '../lib/patientForm';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'notes' | 'callcenter' | 'attach' | 'records';
const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [
{ key: 'services', label: 'سرویس‌ها', icon: (c) => <TabServices color={c} /> },
@@ -46,7 +46,7 @@ const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }
{ key: 'appointments', label: 'نوبت‌ها', icon: (c) => <TabCalendar color={c} /> },
{ key: 'payments', label: 'پرداخت‌ها', icon: (c) => <TabCard color={c} /> },
{ key: 'wallet', label: 'کیف پول', icon: (c) => <TabWallet color={c} /> },
{ key: 'messages', label: 'پیام‌ها', icon: (c) => <TabSMS color={c} /> },
{ key: 'notes', label: 'یادداشت‌ها', icon: (c) => <DocumentTextIcon style={{ width: 18, color: c }} /> },
{ key: 'callcenter', label: 'کال سنتر', icon: (c) => <TabCall color={c} /> },
{ key: 'attach', label: 'ضمیمه', icon: (c) => <TabAttach color={c} /> },
{ key: 'records', label: 'پرونده پزشکی', icon: (c) => <TabBody color={c} /> },
@@ -168,7 +168,7 @@ export default function PatientDetailPage() {
tags={(record as any)?.tags}
nextAppointment={nextAppointment}
hasDebt={hasDebt}
onAddNote={() => setTab('messages')}
onAddNote={() => setTab('notes')}
/>
{/* tab bar */}
@@ -247,8 +247,8 @@ export default function PatientDetailPage() {
<AttachmentsTab uuid={uuid!} />
) : tab === 'records' ? (
<MedicalRecordsTab uuid={uuid!} />
) : tab === 'messages' ? (
<MessagesTab uuid={uuid!} />
) : tab === 'notes' ? (
<NotesTab uuid={uuid!} />
) : (
<Placeholder label={TABS.find((t) => t.key === tab)!.label} />
)}
@@ -466,60 +466,180 @@ function MedicalRecordsTab({ uuid }: { uuid: string }) {
);
}
interface Message { uuid: string; body: string; channel: string; created_at: number }
interface Note { uuid: string; body: string; pinned: boolean; author: string | null; created_at: number; updated_at: number | null }
const CHANNEL_LABEL: Record<string, string> = { sms: 'پیامک', note: 'یادداشت', call: 'تماس', email: 'ایمیل' };
/** آیکن سنجاق (پین) — پرشده وقتی یادداشت پین است. Heroicons سنجاق ندارد. */
function PinIcon({ filled, color, size = 15 }: { filled?: boolean; color: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill={filled ? color : 'none'} stroke={color} strokeWidth={filled ? 0 : 1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 4h6l-1 5 3 3v2H7v-2l3-3-1-5Z" />
<line x1="12" y1="14" x2="12" y2="21" stroke={color} strokeWidth={1.8} />
</svg>
);
}
/** پیام‌ها — patient message/communication log: send + list + delete. */
function MessagesTab({ uuid }: { uuid: string }) {
type NoteSort = 'newest' | 'oldest';
/**
* یادداشت‌ها — personal, pinnable staff notes on the record (replaces the old
* پیام‌ها tab). Compose box on top, then the list: pinned notes float first
* (accent rail + filled pin), each with author + Jalali date and pin/edit/delete.
*/
function NotesTab({ uuid }: { uuid: string }) {
const qc = useQueryClient();
const [body, setBody] = useState('');
const [sort, setSort] = useState<NoteSort>('newest');
const [editTarget, setEditTarget] = useState<Note | null>(null);
const [editBody, setEditBody] = useState('');
const [delTarget, setDelTarget] = useState<Note | null>(null);
const { data, isLoading } = useQuery<ApiResponse<Message[]>>({
queryKey: ['patient-messages', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/messages`),
const { data, isLoading } = useQuery<ApiResponse<Note[]>>({
queryKey: ['patient-notes', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/notes`),
});
const items = data?.data ?? [];
const invalidate = () => qc.invalidateQueries({ queryKey: ['patient-messages', uuid] });
const invalidate = () => qc.invalidateQueries({ queryKey: ['patient-notes', uuid] });
const send = useMutation({
mutationFn: () => api.post(`/api/v1/patient/${uuid}/message`, { body, channel: 'sms' }),
onSuccess: () => { invalidate(); setBody(''); toast.success('پیام ثبت شد'); },
onError: (e: any) => toast.error(e.message),
// سرور «پین‌شده‌ها اول، سپس جدیدترین» می‌دهد؛ برای «قدیمی‌ترین» ترتیبِ غیرپین را
// معکوس می‌کنیم ولی پین‌شده‌ها همیشه بالا می‌مانند.
const shown = useMemo(() => {
const pinned = items.filter((n) => n.pinned);
const rest = items.filter((n) => !n.pinned);
const dir = (a: Note, b: Note) => (sort === 'oldest' ? a.created_at - b.created_at : b.created_at - a.created_at);
return [...pinned].sort(dir).concat([...rest].sort(dir));
}, [items, sort]);
const create = useMutation({
mutationFn: () => api.post(`/api/v1/patient/${uuid}/note`, { body: body.trim() }),
onSuccess: () => { invalidate(); setBody(''); toast.success('یادداشت ثبت شد'); },
onError: (e: any) => toast.error(e?.message || 'خطا در ثبت یادداشت'),
});
const update = useMutation({
mutationFn: ({ u, patch }: { u: string; patch: { body?: string; pinned?: boolean } }) =>
api.patch(`/api/v1/patient/note/${u}`, patch),
onSuccess: () => { invalidate(); setEditTarget(null); },
onError: (e: any) => toast.error(e?.message || 'خطا در ویرایش یادداشت'),
});
const del = useMutation({
mutationFn: (u: string) => api.delete(`/api/v1/patient/message/${u}`),
onSuccess: () => { invalidate(); toast.success('پیام حذف شد'); },
onError: (e: any) => toast.error(e.message),
mutationFn: (u: string) => api.delete(`/api/v1/patient/note/${u}`),
onSuccess: () => { invalidate(); setDelTarget(null); toast.success('یادداشت حذف شد'); },
onError: (e: any) => { toast.error(e?.message || 'خطا در حذف یادداشت'); setDelTarget(null); },
});
const openEdit = (n: Note) => { setEditTarget(n); setEditBody(n.body); };
return (
<div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<div className="field" style={{ flex: 1 }}><input value={body} onChange={(e) => setBody(e.target.value)} placeholder="متن پیام..." /></div>
<button className="btn primary" disabled={!body.trim() || send.isPending} onClick={() => send.mutate()}>ارسال</button>
{/* افزودن یادداشت جدید */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 18, marginBottom: 18 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)', marginBottom: 12 }}>افزودن یادداشت جدید</div>
<div className="field" style={{ height: 'auto', marginBottom: 12 }}>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={4}
placeholder="یادداشت خود را بنویسید..."
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical', color: 'var(--text)' }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn primary" disabled={!body.trim() || create.isPending} onClick={() => create.mutate()}>
<PlusIcon style={{ width: 16 }} /> ذخیره یادداشت
</button>
</div>
</div>
{/* سرآیند + مرتب‌سازی */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14, flexWrap: 'wrap', gap: 10 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-2)' }}>
یادداشتهای قبلی ({formatNumber(items.length)})
</div>
{items.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>مرتبسازی:</span>
{([['newest', 'جدیدترین'], ['oldest', 'قدیمی‌ترین']] as [NoteSort, string][]).map(([key, label]) => {
const on = sort === key;
return (
<button key={key} onClick={() => setSort(key)} style={{
background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
padding: '2px 2px 4px', fontWeight: on ? 700 : 500,
color: on ? 'var(--accent)' : 'var(--text-3)',
borderBottom: `2px solid ${on ? 'var(--accent)' : 'transparent'}`,
}}>{label}</button>
);
})}
</div>
)}
</div>
{/* لیست */}
{isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : items.length === 0 ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>پیامی ثبت نشده است.</div>
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>یادداشتی ثبت نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((m) => (
<div key={m.uuid} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13.5, color: 'var(--text)', whiteSpace: 'pre-wrap' }}>{m.body}</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 4, display: 'flex', gap: 8 }}>
<span className="badge gray" style={{ fontSize: 10.5 }}>{CHANNEL_LABEL[m.channel] ?? m.channel}</span>
<span>{formatDate(m.created_at)}</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{shown.map((n) => (
<div key={n.uuid} style={{
background: n.pinned ? 'var(--accent-bg)' : 'var(--surface)',
border: '1px solid var(--border)',
borderInlineStart: `4px solid ${n.pinned ? 'var(--accent)' : 'transparent'}`,
borderRadius: 'var(--r-lg)', padding: '14px 16px',
}}>
<div style={{ fontSize: 13.5, lineHeight: 1.9, color: 'var(--text)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{n.body}</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: 12, color: 'var(--text-3)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<UserIcon style={{ width: 15 }} /> {n.author || 'نامشخص'}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<CalendarDaysIcon style={{ width: 15 }} /> {formatDate(n.created_at)}
</span>
{n.updated_at && <span style={{ fontSize: 11 }}>(ویرایششده)</span>}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
className="btn sm ghost"
aria-label={n.pinned ? 'برداشتن پین' : 'پین کردن'}
title={n.pinned ? 'برداشتن پین' : 'پین کردن'}
style={{ color: n.pinned ? 'var(--accent)' : 'var(--text-3)' }}
disabled={update.isPending}
onClick={() => update.mutate({ u: n.uuid, patch: { pinned: !n.pinned } })}
>
<PinIcon filled={n.pinned} color="currentColor" />
</button>
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => openEdit(n)}><PencilIcon style={{ width: 15 }} /></button>
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => setDelTarget(n)}><TrashIcon style={{ width: 15 }} /></button>
</div>
</div>
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => del.mutate(m.uuid)}><TrashIcon style={{ width: 15 }} /></button>
</div>
))}
</div>
)}
{/* ویرایش */}
<Modal open={editTarget !== null} onClose={() => setEditTarget(null)} title="ویرایش یادداشت">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="field" style={{ height: 'auto' }}>
<textarea value={editBody} onChange={(e) => setEditBody(e.target.value)} rows={4} autoFocus placeholder="متن یادداشت" style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical', color: 'var(--text)' }} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn primary" disabled={!editBody.trim() || update.isPending} onClick={() => editTarget && update.mutate({ u: editTarget.uuid, patch: { body: editBody.trim() } })}>ذخیره</button>
<button className="btn" onClick={() => setEditTarget(null)}>انصراف</button>
</div>
</div>
</Modal>
<ConfirmDialog
open={!!delTarget}
title="حذف یادداشت"
message="آیا از حذف این یادداشت مطمئن هستید؟"
confirmLabel="حذف"
onConfirm={() => delTarget && del.mutate(delTarget.uuid)}
onCancel={() => setDelTarget(null)}
loading={del.isPending}
/>
</div>
);
}