From 15c92903e14818355b0dd41d09081254648cbb7f Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 16 Jul 2026 12:51:33 +0330 Subject: [PATCH] feat: replace patient messages tab with pinnable notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- assets/admin/pages/PatientDetailPage.test.tsx | 67 +++++- assets/admin/pages/PatientDetailPage.tsx | 190 ++++++++++++++---- docs/api/patient.md | 34 ++++ migrations/Version20260716091352.php | 35 ++++ src/Patient/Controller/PatientController.php | 82 ++++++++ src/Patient/Entity/PatientNote.php | 92 +++++++++ .../Repository/PatientNoteRepository.php | 49 +++++ tests/Patient/PatientNoteTest.php | 126 ++++++++++++ 8 files changed, 636 insertions(+), 39 deletions(-) create mode 100644 migrations/Version20260716091352.php create mode 100644 src/Patient/Entity/PatientNote.php create mode 100644 src/Patient/Repository/PatientNoteRepository.php create mode 100644 tests/Patient/PatientNoteTest.php diff --git a/assets/admin/pages/PatientDetailPage.test.tsx b/assets/admin/pages/PatientDetailPage.test.tsx index 1715e283..66a3b94e 100644 --- a/assets/admin/pages/PatientDetailPage.test.tsx +++ b/assets/admin/pages/PatientDetailPage.test.tsx @@ -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; + 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; + 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 () => { diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index 7e41d163..3088a1e2 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -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) => }, @@ -46,7 +46,7 @@ const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode } { key: 'appointments', label: 'نوبت‌ها', icon: (c) => }, { key: 'payments', label: 'پرداخت‌ها', icon: (c) => }, { key: 'wallet', label: 'کیف پول', icon: (c) => }, - { key: 'messages', label: 'پیام‌ها', icon: (c) => }, + { key: 'notes', label: 'یادداشت‌ها', icon: (c) => }, { key: 'callcenter', label: 'کال سنتر', icon: (c) => }, { key: 'attach', label: 'ضمیمه', icon: (c) => }, { key: 'records', label: 'پرونده پزشکی', icon: (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() { ) : tab === 'records' ? ( - ) : tab === 'messages' ? ( - + ) : tab === 'notes' ? ( + ) : ( 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 = { sms: 'پیامک', note: 'یادداشت', call: 'تماس', email: 'ایمیل' }; +/** آیکن سنجاق (پین) — پرشده وقتی یادداشت پین است. Heroicons سنجاق ندارد. */ +function PinIcon({ filled, color, size = 15 }: { filled?: boolean; color: string; size?: number }) { + return ( + + ); +} -/** پیام‌ها — 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('newest'); + const [editTarget, setEditTarget] = useState(null); + const [editBody, setEditBody] = useState(''); + const [delTarget, setDelTarget] = useState(null); - const { data, isLoading } = useQuery>({ - queryKey: ['patient-messages', uuid], - queryFn: () => api.get(`/api/v1/patient/${uuid}/messages`), + const { data, isLoading } = useQuery>({ + 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 (
-
-
setBody(e.target.value)} placeholder="متن پیام..." />
- + {/* افزودن یادداشت جدید */} +
+
افزودن یادداشت جدید
+
+