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 () => {