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>
);
}
+34
View File
@@ -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: پرداخت / تراکنش / کیف‌پول)
+35
View File
@@ -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'])]
+92
View File
@@ -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();
}
}
+126
View File
@@ -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());
}
}