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:
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -577,6 +577,40 @@ When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointme
|
||||
| 422 | `ERR_VALIDATION_001` | متن خالی (`field: body`) |
|
||||
| 404 | `ERR_PATIENT_001` / `ERR_NOT_FOUND_001` | رکورد/پیام یافت نشد یا tenant دیگر |
|
||||
|
||||
> توجه: پنل ادمین دیگر تب «پیامها» را نشان نمیدهد؛ جای آن «یادداشتها» آمده است. این اندپوینتها باقی میمانند ولی توسط پنل مصرف نمیشوند.
|
||||
|
||||
---
|
||||
|
||||
## یادداشتهای بیمار (Notes)
|
||||
|
||||
یادداشتهای شخصیِ پرسنل روی پرونده، **پینشدنی**. مشترک بین همهی کارکنانِ صاحبِ پرونده؛ نام سازنده هنگام ثبت ذخیره میشود (پس از حذف کاربر هم باقی میماند). scope به رکورد و tenant.
|
||||
|
||||
**Permission:** `IS_AUTHENTICATED_FULLY` (مالک رکورد)
|
||||
|
||||
### GET `/api/v1/patient/{uuid}/notes`
|
||||
لیست، **پینشدهها اول، سپس جدیدترین**. Response: `{ success, data: [{ uuid, body, pinned, author, created_at, updated_at }] }`
|
||||
|
||||
### POST `/api/v1/patient/{uuid}/note`
|
||||
```json
|
||||
{ "body": "متن یادداشت", "pinned": false }
|
||||
```
|
||||
`body` الزامی (trim)؛ `pinned` اختیاری (پیشفرض `false`). `author`/سازنده سمت سرور از کاربر جاری (`real_name` یا موبایل) پر میشود. Response `201`.
|
||||
|
||||
### PATCH `/api/v1/patient/note/{uuid}`
|
||||
```json
|
||||
{ "body": "متن جدید", "pinned": true }
|
||||
```
|
||||
هر دو فیلد اختیاری (partial). با ارسال `pinned` تنها → toggle پین بدون تغییر متن. `body` خالی → `422`. `updated_at` ست میشود. فقط مالک.
|
||||
|
||||
### DELETE `/api/v1/patient/note/{uuid}`
|
||||
حذف. فقط مالک؛ در غیر این صورت `404`.
|
||||
|
||||
### Errors
|
||||
| HTTP | Code | Description |
|
||||
|------|------|-------------|
|
||||
| 422 | `ERR_VALIDATION_001` | متن خالی (`field: body`) |
|
||||
| 404 | `ERR_PATIENT_001` / `ERR_NOT_FOUND_001` | رکورد/یادداشت یافت نشد یا tenant دیگر |
|
||||
|
||||
---
|
||||
|
||||
## مالی بیمار (Financials: پرداخت / تراکنش / کیفپول)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260716091352 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add patient_notes table (pinnable staff notes on patient records)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE patient_notes (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, body LONGTEXT NOT NULL, pinned TINYINT NOT NULL, author_name VARCHAR(120) DEFAULT NULL, created_at INT NOT NULL, updated_at INT DEFAULT NULL, record_id INT NOT NULL, created_by INT DEFAULT NULL, UNIQUE INDEX UNIQ_942D7D43D17F50A6 (uuid), INDEX IDX_942D7D43DE12AB56 (created_by), INDEX idx_patient_notes_record (record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE patient_notes ADD CONSTRAINT FK_942D7D434DFD750C FOREIGN KEY (record_id) REFERENCES patient_records (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE patient_notes ADD CONSTRAINT FK_942D7D43DE12AB56 FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE patient_notes DROP FOREIGN KEY FK_942D7D434DFD750C');
|
||||
$this->addSql('ALTER TABLE patient_notes DROP FOREIGN KEY FK_942D7D43DE12AB56');
|
||||
$this->addSql('DROP TABLE patient_notes');
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ class PatientController extends BaseController
|
||||
private readonly \App\Patient\Repository\PatientAttachmentRepository $attachmentRepo,
|
||||
private readonly \App\Patient\Repository\PatientMedicalRecordRepository $medicalRepo,
|
||||
private readonly \App\Patient\Repository\PatientMessageRepository $messageRepo,
|
||||
private readonly \App\Patient\Repository\PatientNoteRepository $noteRepo,
|
||||
private readonly \App\Patient\Repository\PatientCallRepository $callRepo,
|
||||
private readonly \App\Shared\Service\FileUploadService $fileUpload,
|
||||
private readonly \App\Payment\Repository\PaymentRepository $paymentRepo,
|
||||
@@ -340,6 +341,87 @@ class PatientController extends BaseController
|
||||
return $this->success(['message' => 'پیام حذف شد']);
|
||||
}
|
||||
|
||||
// ── Notes (یادداشتها) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Personal staff memos on a record; shared with everyone who owns the record.
|
||||
// The author's display name is captured at write time (survives user deletion).
|
||||
|
||||
#[Route('/api/v1/patient/{uuid}/notes', methods: ['GET'])]
|
||||
public function listNotes(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
return $this->success(array_map(
|
||||
fn(\App\Patient\Entity\PatientNote $n) => $n->toArray(),
|
||||
$this->noteRepo->findByRecord($record)
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/patient/{uuid}/note', methods: ['POST'])]
|
||||
public function createNote(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$body = trim((string) ($data['body'] ?? ''));
|
||||
if ($body === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'متن یادداشت الزامی است', 422, 'body');
|
||||
}
|
||||
|
||||
$note = new \App\Patient\Entity\PatientNote($record, $body, (bool) ($data['pinned'] ?? false));
|
||||
$note->setAuthor($user, $user->getRealName() ?? $user->getMobileNumber());
|
||||
$this->noteRepo->save($note);
|
||||
|
||||
return $this->success($note->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/patient/note/{uuid}', methods: ['PATCH'])]
|
||||
public function updateNote(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$note = $this->noteRepo->findByUuid($uuid);
|
||||
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'یادداشت یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (array_key_exists('body', $data)) {
|
||||
$body = trim((string) $data['body']);
|
||||
if ($body === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'متن یادداشت الزامی است', 422, 'body');
|
||||
}
|
||||
$note->setBody($body);
|
||||
}
|
||||
if (array_key_exists('pinned', $data)) {
|
||||
$note->setPinned((bool) $data['pinned']);
|
||||
}
|
||||
$this->noteRepo->save($note);
|
||||
|
||||
return $this->success($note->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/patient/note/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteNote(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$note = $this->noteRepo->findByUuid($uuid);
|
||||
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'یادداشت یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->noteRepo->remove($note);
|
||||
|
||||
return $this->success(['message' => 'یادداشت حذف شد']);
|
||||
}
|
||||
|
||||
// ── Medical records (پرونده پزشکی) ────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/patient/{uuid}/medical-records', methods: ['GET'])]
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Patient\Repository\PatientNoteRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* A personal, pinnable staff note attached to a patient record (the «یادداشتها» tab).
|
||||
* Shared across the staff who own the record; the author's display name is denormalised
|
||||
* so the note survives the author being deleted.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: PatientNoteRepository::class)]
|
||||
#[ORM\Table(name: 'patient_notes')]
|
||||
#[ORM\Index(columns: ['record_id'], name: 'idx_patient_notes_record')]
|
||||
class PatientNote
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
|
||||
#[ORM\JoinColumn(name: 'record_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private PatientRecord $record;
|
||||
|
||||
/** متن یادداشت — free-text body. */
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $body;
|
||||
|
||||
/** یادداشتهای پینشده بالای لیست نشان داده میشوند. */
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $pinned = false;
|
||||
|
||||
/** The staff user who wrote the note; kept for possible audit, nulled if they are removed. */
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'created_by', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $createdBy = null;
|
||||
|
||||
/** Denormalised author display name (survives user deletion). */
|
||||
#[ORM\Column(name: 'author_name', type: 'string', length: 120, nullable: true)]
|
||||
private ?string $authorName = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer', nullable: true)]
|
||||
private ?int $updatedAt = null;
|
||||
|
||||
public function __construct(PatientRecord $record, string $body, bool $pinned = false)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->record = $record;
|
||||
$this->body = $body;
|
||||
$this->pinned = $pinned;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getRecord(): PatientRecord { return $this->record; }
|
||||
public function isPinned(): bool { return $this->pinned; }
|
||||
|
||||
public function setBody(string $body): self { $this->body = $body; $this->touch(); return $this; }
|
||||
public function setPinned(bool $pinned): self { $this->pinned = $pinned; $this->touch(); return $this; }
|
||||
|
||||
public function setAuthor(?User $user, ?string $name): self
|
||||
{
|
||||
$this->createdBy = $user;
|
||||
$this->authorName = $name;
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'body' => $this->body,
|
||||
'pinned' => $this->pinned,
|
||||
'author' => $this->authorName,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\PatientNote;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class PatientNoteRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PatientNote::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?PatientNote
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes for a record, pinned first then newest first.
|
||||
*
|
||||
* @return PatientNote[]
|
||||
*/
|
||||
public function findByRecord(PatientRecord $record): array
|
||||
{
|
||||
return $this->createQueryBuilder('n')
|
||||
->where('n.record = :record')
|
||||
->setParameter('record', $record)
|
||||
->orderBy('n.pinned', 'DESC')
|
||||
->addOrderBy('n.id', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(PatientNote $n): void
|
||||
{
|
||||
$this->getEntityManager()->persist($n);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(PatientNote $n): void
|
||||
{
|
||||
$this->getEntityManager()->remove($n);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Patient notes: create, list (pinned-first order), edit, pin toggle, delete,
|
||||
* validation and tenant ownership scoping.
|
||||
*/
|
||||
class PatientNoteTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$owner->setRealName('دکتر احمدی');
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $record];
|
||||
}
|
||||
|
||||
public function testCreateCapturesAuthorAndDefaults(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, [
|
||||
'body' => 'بیمار به دارو حساسیت دارد',
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('بیمار به دارو حساسیت دارد', $created['data']['body']);
|
||||
self::assertFalse($created['data']['pinned']);
|
||||
self::assertSame('دکتر احمدی', $created['data']['author']);
|
||||
self::assertNull($created['data']['updated_at']);
|
||||
}
|
||||
|
||||
public function testListReturnsPinnedFirst(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$base = '/api/v1/patient/' . $record->getUuid();
|
||||
|
||||
// Three notes; the oldest is pinned so it must float above the two newer ones.
|
||||
$pinned = $this->authJson('POST', $base . '/note', $owner, ['body' => 'قدیمی پینشده', 'pinned' => true]);
|
||||
$this->authJson('POST', $base . '/note', $owner, ['body' => 'دوم']);
|
||||
$this->authJson('POST', $base . '/note', $owner, ['body' => 'سوم (جدیدترین)']);
|
||||
|
||||
$list = $this->authJson('GET', $base . '/notes', $owner);
|
||||
self::assertCount(3, $list['data']);
|
||||
self::assertSame($pinned['data']['uuid'], $list['data'][0]['uuid']);
|
||||
self::assertTrue($list['data'][0]['pinned']);
|
||||
// remaining two are newest-first among the unpinned
|
||||
self::assertSame('سوم (جدیدترین)', $list['data'][1]['body']);
|
||||
self::assertSame('دوم', $list['data'][2]['body']);
|
||||
}
|
||||
|
||||
public function testEditBodyAndTogglePin(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => 'اولیه']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$edited = $this->authJson('PATCH', '/api/v1/patient/note/' . $uuid, $owner, [
|
||||
'body' => 'ویرایش شد', 'pinned' => true,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('ویرایش شد', $edited['data']['body']);
|
||||
self::assertTrue($edited['data']['pinned']);
|
||||
self::assertNotNull($edited['data']['updated_at']);
|
||||
|
||||
// pin-only toggle keeps body intact
|
||||
$unpinned = $this->authJson('PATCH', '/api/v1/patient/note/' . $uuid, $owner, ['pinned' => false]);
|
||||
self::assertFalse($unpinned['data']['pinned']);
|
||||
self::assertSame('ویرایش شد', $unpinned['data']['body']);
|
||||
}
|
||||
|
||||
public function testDelete(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => 'برای حذف']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/patient/note/' . $uuid, $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$after = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/notes', $owner);
|
||||
self::assertCount(0, $after['data']);
|
||||
}
|
||||
|
||||
public function testRequiresBodyOnCreate(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => ' ']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testRejectsEmptyBodyOnEdit(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => 'اولیه']);
|
||||
$this->authJson('PATCH', '/api/v1/patient/note/' . $created['data']['uuid'], $owner, ['body' => '']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testOwnershipScoped(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => 'x']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
[$other] = $this->recordFor();
|
||||
$this->authJson('PATCH', '/api/v1/patient/note/' . $uuid, $other, ['pinned' => true]);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
$this->authJson('DELETE', '/api/v1/patient/note/' . $uuid, $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user