feat(patients): phase B4 — messages (پیام‌ها)

Add a patient message/communication log: a new PatientMessage entity
(record-scoped, CASCADE) + repository, and owner-scoped endpoints
(GET messages, POST message, DELETE message) with a validated channel
(sms/note/call/email). Wire the "پیام‌ها" tab in PatientDetailPage
(send box + list + delete). PHPUnit covers create/list/delete + ownership
+ validation; Vitest covers the tab. API docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-13 15:35:10 +03:30
co-authored by Claude Opus 4.8
parent 976c4f0c0a
commit 5fb4c52246
8 changed files with 358 additions and 1 deletions
+10 -1
View File
@@ -55,7 +55,7 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => {
it('shows a placeholder for not-yet-built tabs', async () => {
renderDetail();
await screen.findByText('ساغر صابری');
fireEvent.click(screen.getByText('پیام‌ها'));
fireEvent.click(screen.getByText('کال سنتر'));
expect(screen.getByText(/به‌زودی تکمیل می‌شود/)).toBeInTheDocument();
});
@@ -75,4 +75,13 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => {
// modal opens with a title field
expect(await screen.findByPlaceholderText('مثلاً: معاینه اولیه')).toBeInTheDocument();
});
it('renders the messages tab with a send box', async () => {
renderDetail();
await screen.findByText('ساغر صابری');
fireEvent.click(screen.getByText('پیام‌ها'));
expect(await screen.findByPlaceholderText('متن پیام...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ارسال' })).toBeInTheDocument();
expect(await screen.findByText('پیامی ثبت نشده است.')).toBeInTheDocument();
});
});
+60
View File
@@ -131,6 +131,8 @@ export default function PatientDetailPage() {
<AttachmentsTab uuid={uuid!} />
) : tab === 'records' ? (
<MedicalRecordsTab uuid={uuid!} />
) : tab === 'messages' ? (
<MessagesTab uuid={uuid!} />
) : (
<Placeholder label={TABS.find((t) => t.key === tab)!.label} />
)}
@@ -324,6 +326,64 @@ function MedicalRecordsTab({ uuid }: { uuid: string }) {
);
}
interface Message { uuid: string; body: string; channel: string; created_at: number }
const CHANNEL_LABEL: Record<string, string> = { sms: 'پیامک', note: 'یادداشت', call: 'تماس', email: 'ایمیل' };
/** پیام‌ها — patient message/communication log: send + list + delete. */
function MessagesTab({ uuid }: { uuid: string }) {
const qc = useQueryClient();
const [body, setBody] = useState('');
const { data, isLoading } = useQuery<ApiResponse<Message[]>>({
queryKey: ['patient-messages', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/messages`),
});
const items = data?.data ?? [];
const invalidate = () => qc.invalidateQueries({ queryKey: ['patient-messages', 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 del = useMutation({
mutationFn: (u: string) => api.delete(`/api/v1/patient/message/${u}`),
onSuccess: () => { invalidate(); toast.success('پیام حذف شد'); },
onError: (e: any) => toast.error(e.message),
});
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>
{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={{ 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>
</div>
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => del.mutate(m.uuid)}><TrashIcon style={{ width: 15 }} /></button>
</div>
))}
</div>
)}
</div>
);
}
function TabList({ q, emptyLabel, row }: {
q: { data?: ApiResponse<any[]>; isLoading: boolean };
emptyLabel: string;