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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user