feat(treatment): search and edit for treatment cases
The list had no way to tell two cases apart. TreatmentCase::toArray() carried
no patient, so four cases of the same service rendered as four identical
cards — same service, same supervisor, same date, same areas. Search would have
been meaningless without fixing that first, so the payload now carries the
patient (name, mobile, record number) and the card leads with the name.
Search: `?q=` on the list endpoint, matching patient name, mobile, national
code, record number and service name — the same keys a secretary already types
into the booking form. It lives in the URL via useUrlState, debounced, so back
and refresh keep the view.
Edit: PATCH /api/v1/treatment-case/{uuid} covering status, supervising doctor,
areas and session count, driven from a modal on the list. Rules live in
TreatmentCaseEditor, not the controller, around one boundary: no edit may
overwrite work already done. An area with session records cannot be removed, and
the session count cannot drop below the sessions that are booked or finished —
both 409, both tested. Reopening a closed case clears closed_at.
`areas[]` now also exposes `category_uuid`; the edit form selects catalog
categories, while `uuid` identifies the snapshot row.
Page fixes from the redesign checklist: the status filter was a hand-rolled
primary/secondary button pair, now `.seg` with `.on`; the raw `<progress>` bar
took the browser's own appearance and ignored the theme tokens, now a token-
styled bar with an explicit progressbar role; session counts go through
formatNumber; a failed request rendered as "no cases found", which reads as an
empty clinic rather than a broken one, and an empty search now says so in its
own words.
Adds the test files neither the page nor the case editor had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import Modal from './ui/Modal';
|
||||
import Input from './ui/Input';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import { formatNumber } from '../lib/utils';
|
||||
import type { TreatmentCaseDetail, TreatmentCaseStatus } from '../types';
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: TreatmentCaseStatus; label: string }> = [
|
||||
{ value: 'active', label: 'در جریان' },
|
||||
{ value: 'completed', label: 'تمام شده' },
|
||||
{ value: 'abandoned', label: 'رها شده' },
|
||||
];
|
||||
|
||||
interface DoctorRow { uuid: string; name?: string | null; full_name?: string | null }
|
||||
|
||||
/**
|
||||
* ویرایش پروندهٔ درمان.
|
||||
*
|
||||
* پرونده بعد از باز شدن سند است نه فرم، پس فقط چیزهایی اینجا هستند که واقعاً وسط دوره
|
||||
* عوض میشوند. سرور جلوی ویرایشی را که سابقه را بازنویسی کند میگیرد؛ فرم آن خطا را
|
||||
* نشان میدهد، تکرارش نمیکند.
|
||||
*/
|
||||
export default function TreatmentCaseEditModal({ caseUuid, onClose }: {
|
||||
caseUuid: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['treatment-case', caseUuid],
|
||||
queryFn: () => api.get<ApiResponse<TreatmentCaseDetail>>(`/api/v1/treatment-case/${caseUuid}`),
|
||||
});
|
||||
|
||||
// همان اندپوینتی که صفحهٔ نوبتها میخواند: فقط پزشکانِ مجازِ همین محیط.
|
||||
// پاسخش دو لایه تو در تو است (`data.data`) — الگوی شناختهشدهٔ همین اندپوینت.
|
||||
const doctorsQ = useQuery<ApiResponse<{ data: DoctorRow[] }>>({
|
||||
queryKey: ['clinic-doctors-lite'],
|
||||
queryFn: () => api.get('/api/v1/my/clinic-doctors'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const detail = data?.data;
|
||||
|
||||
return (
|
||||
<Modal open title="ویرایش پروندهٔ درمان" size="sm" onClose={onClose} footer={null}>
|
||||
{isLoading ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
||||
) : isError || !detail ? (
|
||||
<div style={{ display: 'grid', gap: 10, justifyItems: 'start' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--danger)' }}>خواندن پرونده ناموفق بود.</span>
|
||||
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
) : (
|
||||
<EditForm
|
||||
detail={detail}
|
||||
doctors={doctorsQ.data?.data?.data ?? []}
|
||||
doctorsLoading={doctorsQ.isLoading}
|
||||
onSaved={() => {
|
||||
qc.invalidateQueries({ queryKey: ['treatment-cases'] });
|
||||
qc.invalidateQueries({ queryKey: ['treatment-case', caseUuid] });
|
||||
onClose();
|
||||
}}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: {
|
||||
detail: TreatmentCaseDetail;
|
||||
doctors: DoctorRow[];
|
||||
doctorsLoading: boolean;
|
||||
onSaved: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [status, setStatus] = useState<TreatmentCaseStatus>(detail.status);
|
||||
const [supervisor, setSupervisor] = useState<string | null>(detail.supervisor?.uuid ?? null);
|
||||
const [total, setTotal] = useState(String(detail.total_sessions));
|
||||
const [areas, setAreas] = useState<string[]>(
|
||||
detail.areas.map((a) => a.category_uuid).filter((u): u is string => u !== null),
|
||||
);
|
||||
|
||||
// ناحیهای که دستهاش حذف شده در سابقه هست ولی دیگر قابل انتخاب نیست — باید دیده
|
||||
// شود، وگرنه کاربر فکر میکند فرم آن را انداخته است.
|
||||
const orphanAreas = useMemo(
|
||||
() => detail.areas.filter((a) => a.category_uuid === null).map((a) => a.name),
|
||||
[detail.areas],
|
||||
);
|
||||
|
||||
const minTotal = detail.completed_sessions;
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.patch<ApiResponse<TreatmentCaseDetail>>(`/api/v1/treatment-case/${detail.uuid}`, {
|
||||
status,
|
||||
supervisor_doctor_uuid: supervisor,
|
||||
area_uuids: areas,
|
||||
total_sessions: Number(total) || 0,
|
||||
}),
|
||||
onSuccess: () => { toast.success('پرونده بهروزرسانی شد'); onSaved(); },
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'ویرایش پرونده ناموفق بود'),
|
||||
});
|
||||
|
||||
const toggleArea = (uuid: string) =>
|
||||
setAreas((prev) => prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]);
|
||||
|
||||
const totalValid = Number(total) >= 2 && Number(total) <= 60;
|
||||
const valid = areas.length > 0 && totalValid;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<div style={{
|
||||
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
|
||||
display: 'flex', gap: 14, flexWrap: 'wrap', fontSize: 13,
|
||||
}}>
|
||||
<span>
|
||||
<span style={{ color: 'var(--text-3)' }}>بیمار: </span>
|
||||
<b>{detail.patient.name || 'بدون نام'}</b>
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: 'var(--text-3)' }}>سرویس: </span>
|
||||
<b>{detail.service.name}</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="field-block">
|
||||
<label>وضعیت پرونده</label>
|
||||
<div className="seg" style={{ display: 'flex' }}>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={status === o.value ? 'on' : ''}
|
||||
aria-pressed={status === o.value}
|
||||
onClick={() => setStatus(o.value)}
|
||||
style={{ flex: 1, justifyContent: 'center' }}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field-block">
|
||||
<label htmlFor="case-supervisor">پزشک ناظر</label>
|
||||
<SearchableSelect
|
||||
inputId="case-supervisor"
|
||||
options={doctors.map((d) => ({ value: d.uuid, label: d.name ?? d.full_name ?? '' }))}
|
||||
value={supervisor}
|
||||
onChange={(v) => setSupervisor(v === null ? null : String(v))}
|
||||
placeholder="بدون پزشک ناظر"
|
||||
isLoading={doctorsLoading}
|
||||
isClearable
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field-block">
|
||||
<label>نواحی درمان</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{detail.available_areas.map((a) => {
|
||||
const on = areas.includes(a.uuid);
|
||||
return (
|
||||
<button
|
||||
key={a.uuid}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
onClick={() => toggleArea(a.uuid)}
|
||||
style={{
|
||||
minHeight: 36, padding: '6px 12px', borderRadius: 'var(--r-sm)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
|
||||
border: on ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: on ? 'var(--primary-soft)' : 'var(--surface)',
|
||||
color: on ? 'var(--primary-700)' : 'var(--text-2)',
|
||||
fontWeight: on ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{a.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{areas.length === 0 && <span className="field-err">حداقل یک ناحیه لازم است</span>}
|
||||
{orphanAreas.length > 0 && (
|
||||
<span className="field-hint">
|
||||
نواحیِ «{orphanAreas.join('، ')}» در سابقه هستند ولی دستهبندیشان حذف شده و قابل انتخاب نیستند.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field-block">
|
||||
<label htmlFor="case-total">تعداد جلسات</label>
|
||||
<div className="field" style={{ maxWidth: 140 }}>
|
||||
<Input
|
||||
id="case-total"
|
||||
numeric
|
||||
className=""
|
||||
value={total}
|
||||
onChange={(e) => setTotal(e.target.value.replace(/\D/g, '').slice(0, 2))}
|
||||
/>
|
||||
</div>
|
||||
<span className={totalValid ? 'field-hint' : 'field-err'}>
|
||||
{totalValid
|
||||
? `${formatNumber(minTotal)} جلسه انجام شده. جلسهای که نوبت دارد یا انجام شده حذف نمیشود.`
|
||||
: 'تعداد جلسات باید بین ۲ و ۶۰ باشد'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-start' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!valid || save.isPending}
|
||||
>
|
||||
{save.isPending ? 'در حال ذخیره…' : 'ذخیره'}
|
||||
</button>
|
||||
<button type="button" className="btn ghost" onClick={onClose}>انصراف</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import TreatmentCasesPage from './TreatmentCasesPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
function caseRow(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
uuid: 'case-1',
|
||||
status: 'active',
|
||||
total_sessions: 3,
|
||||
completed_sessions: 1,
|
||||
opened_at: 1_786_000_000,
|
||||
closed_at: null,
|
||||
service: { uuid: 'svc-1', name: 'لیزر توتال' },
|
||||
supervisor: { uuid: 'doc-1', name: 'پزشک مدیسا' },
|
||||
patient: { record_uuid: 'rec-1', name: 'محمد رسولی', mobile: '09120001111', record_number: '۱۲' },
|
||||
areas: [{ uuid: 'ca-1', name: 'دست', category_uuid: 'cat-1' }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** فقط اندپوینت فهرست را جواب میدهد؛ بقیه خالی. */
|
||||
function mockList(rows: unknown[]) {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.startsWith('/api/v1/treatment-cases')) return Promise.resolve({ success: true, data: rows });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
});
|
||||
|
||||
describe('صفحهٔ پروندههای درمان', () => {
|
||||
it('نام بیمار سرتیتر کارت است، نه نام سرویس', async () => {
|
||||
mockList([caseRow()]);
|
||||
|
||||
renderWithProviders(<TreatmentCasesPage />);
|
||||
|
||||
const name = await screen.findByText('محمد رسولی');
|
||||
expect(name.tagName).toBe('STRONG');
|
||||
expect(screen.getByText('لیزر توتال')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** جستجو باید به سرور برود، نه اینکه فهرست را در مرورگر فیلتر کند. */
|
||||
it('عبارت جستجو را بهصورت پارامتر q میفرستد', async () => {
|
||||
mockList([caseRow()]);
|
||||
|
||||
renderWithProviders(<TreatmentCasesPage />);
|
||||
await screen.findByText('محمد رسولی');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('جستجوی پرونده'), { target: { value: 'رسولی' } });
|
||||
|
||||
await waitFor(
|
||||
() => expect(get.mock.calls.some(
|
||||
(c: unknown[]) => typeof c[0] === 'string' && c[0].includes('q=%D8%B1%D8%B3%D9%88%D9%84%DB%8C'),
|
||||
)).toBe(true),
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('فیلتر وضعیت بهصورت status میرود', async () => {
|
||||
mockList([caseRow()]);
|
||||
|
||||
renderWithProviders(<TreatmentCasesPage />);
|
||||
await screen.findByText('محمد رسولی');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'رها شده' }));
|
||||
|
||||
await waitFor(() => expect(get.mock.calls.some(
|
||||
(c: unknown[]) => typeof c[0] === 'string' && c[0].includes('status=abandoned'),
|
||||
)).toBe(true));
|
||||
});
|
||||
|
||||
/** نتیجهٔ خالیِ جستجو با «هنوز پروندهای ساخته نشده» یکی نیست. */
|
||||
it('خالیِ جستجو پیام خودش را دارد', async () => {
|
||||
mockList([]);
|
||||
|
||||
renderWithProviders(<TreatmentCasesPage />);
|
||||
await screen.findByText(/پروندهای یافت نشد/);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('جستجوی پرونده'), { target: { value: 'هیچ' } });
|
||||
|
||||
expect(await screen.findByText(/برای «هیچ» پروندهای پیدا نشد/, {}, { timeout: 2000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('خطا را از فهرست خالی جدا میکند', async () => {
|
||||
get.mockRejectedValue(new Error('boom'));
|
||||
|
||||
renderWithProviders(<TreatmentCasesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/خواندن پروندهها ناموفق بود/)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/پروندهای یافت نشد/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('دکمهٔ ویرایش مودال را باز میکند', async () => {
|
||||
mockList([caseRow()]);
|
||||
|
||||
renderWithProviders(<TreatmentCasesPage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /ویرایش/ }));
|
||||
|
||||
expect(await screen.findByText('ویرایش پروندهٔ درمان')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MagnifyingGlassIcon, PencilSquareIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import { formatDate, formatDateTime } from '../lib/utils';
|
||||
import { formatDate, formatDateTime, formatNumber } from '../lib/utils';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import TreatmentCaseEditModal from '../components/TreatmentCaseEditModal';
|
||||
import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types';
|
||||
|
||||
const TABS = [
|
||||
@@ -29,7 +31,7 @@ const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
|
||||
* را جواب میدهند — «کدام بیمار در چه مرحلهای است و چه کاری مانده».
|
||||
*/
|
||||
export default function TreatmentCasesPage() {
|
||||
const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '' });
|
||||
const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '', q: '' });
|
||||
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'cases') as TabId;
|
||||
|
||||
return (
|
||||
@@ -50,18 +52,53 @@ export default function TreatmentCasesPage() {
|
||||
</div>
|
||||
|
||||
{tab === 'cases'
|
||||
? <CasesTab status={urlState.status} onStatus={(s) => setUrlState({ status: s })} />
|
||||
? <CasesTab
|
||||
status={urlState.status}
|
||||
onStatus={(s) => setUrlState({ status: s })}
|
||||
search={urlState.q}
|
||||
onSearch={(q) => setUrlState({ q })}
|
||||
/>
|
||||
: <UnbookedTab />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CasesTab({ status, onStatus }: { status: string; onStatus: (s: string) => void }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['treatment-cases', status],
|
||||
queryFn: () => api.get<ApiResponse<TreatmentCaseSummary[]>>(
|
||||
`/api/v1/treatment-cases${status ? `?status=${status}` : ''}`,
|
||||
),
|
||||
const STATUS_FILTERS = [
|
||||
['', 'همه'],
|
||||
['active', 'در جریان'],
|
||||
['completed', 'تمام شده'],
|
||||
['abandoned', 'رها شده'],
|
||||
] as const;
|
||||
|
||||
function CasesTab({ status, onStatus, search, onSearch }: {
|
||||
status: string;
|
||||
onStatus: (s: string) => void;
|
||||
search: string;
|
||||
onSearch: (s: string) => void;
|
||||
}) {
|
||||
// فیلد جستجو محلی میماند و فقط مقدار نهایی به URL میرود؛ وگرنه هر حرف یک ورودی
|
||||
// تاریخچه میسازد و «بازگشت» بیمعنی میشود.
|
||||
const [term, setTerm] = useState(search);
|
||||
useEffect(() => setTerm(search), [search]);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { if (term !== search) onSearch(term); }, 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [term]);
|
||||
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['treatment-cases', status, search],
|
||||
queryFn: () => {
|
||||
const qs = new URLSearchParams();
|
||||
if (status) qs.set('status', status);
|
||||
if (search) qs.set('q', search);
|
||||
const suffix = qs.toString();
|
||||
|
||||
return api.get<ApiResponse<TreatmentCaseSummary[]>>(
|
||||
`/api/v1/treatment-cases${suffix ? `?${suffix}` : ''}`,
|
||||
);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -69,59 +106,115 @@ function CasesTab({ status, onStatus }: { status: string; onStatus: (s: string)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar" style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{[['', 'همه'], ['active', 'در جریان'], ['completed', 'تمام شده'], ['abandoned', 'رها شده']].map(([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={`btn ${status === v ? 'primary' : 'secondary'} sm`}
|
||||
onClick={() => onStatus(v)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginBottom: 14 }}>
|
||||
<div className="field" style={{ flex: '1 1 260px', maxWidth: 380 }}>
|
||||
<MagnifyingGlassIcon style={{ width: 16, height: 16, flexShrink: 0, color: 'var(--text-3)' }} />
|
||||
<input
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
placeholder="نام بیمار، موبایل، کد ملی، شمارهٔ پرونده یا سرویس"
|
||||
aria-label="جستجوی پرونده"
|
||||
/>
|
||||
{term !== '' && (
|
||||
<button type="button" className="mini-btn" aria-label="پاک کردن جستجو" onClick={() => setTerm('')}>
|
||||
<XMarkIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="seg">
|
||||
{STATUS_FILTERS.map(([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={status === v ? 'on' : ''}
|
||||
aria-pressed={status === v}
|
||||
onClick={() => onStatus(v)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{[0, 1].map((i) => <div key={i} className="card" style={{ height: 96 }} />)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
/* خطای سرور نباید «پروندهای یافت نشد» خوانده شود — آن یعنی جستجو نتیجه نداشت. */
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 10, justifyItems: 'start' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--danger)' }}>خواندن پروندهها ناموفق بود.</span>
|
||||
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
) : cases.length === 0 ? (
|
||||
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
پروندهای یافت نشد. پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.
|
||||
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)', lineHeight: 1.9 }}>
|
||||
{search
|
||||
? `برای «${search}» پروندهای پیدا نشد.`
|
||||
: 'پروندهای یافت نشد. پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{cases.map((c) => (
|
||||
<div key={c.uuid} className="card card-pad" style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 14 }}>{c.service.name}</strong>
|
||||
<span className={`badge ${c.status === 'active' ? 'blue' : c.status === 'completed' ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{CASE_STATUS_LABEL[c.status]}
|
||||
</span>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
{c.completed_sessions} از {c.total_sessions} جلسه
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
|
||||
<span>شروع: {formatDate(c.opened_at)}</span>
|
||||
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
|
||||
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
|
||||
</div>
|
||||
|
||||
<progress
|
||||
value={c.completed_sessions}
|
||||
max={c.total_sessions}
|
||||
aria-label={`پیشرفت دوره: ${c.completed_sessions} از ${c.total_sessions}`}
|
||||
style={{ width: '100%', height: 6 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{cases.map((c) => <CaseCard key={c.uuid} item={c} onEdit={() => setEditing(c.uuid)} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing !== null && (
|
||||
<TreatmentCaseEditModal caseUuid={editing} onClose={() => setEditing(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: () => void }) {
|
||||
const percent = c.total_sessions > 0
|
||||
? Math.round((c.completed_sessions / c.total_sessions) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{/* بیمار سرتیتر است نه سرویس: دو پروندهٔ یک سرویس فقط با نام بیمار از هم جدا میشوند. */}
|
||||
<strong style={{ fontSize: 14 }}>{c.patient.name || 'بیمار بدون نام'}</strong>
|
||||
<span className={`badge ${c.status === 'active' ? 'blue' : c.status === 'completed' ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{CASE_STATUS_LABEL[c.status]}
|
||||
</span>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
{formatNumber(c.completed_sessions)} از {formatNumber(c.total_sessions)} جلسه
|
||||
</span>
|
||||
<button type="button" className="btn secondary sm" style={{ marginInlineStart: 'auto' }} onClick={onEdit}>
|
||||
<PencilSquareIcon style={{ width: 15, height: 15 }} />
|
||||
ویرایش
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
|
||||
<span>{c.service.name}</span>
|
||||
<span style={{ direction: 'ltr' }}>{c.patient.mobile}</span>
|
||||
<span>شروع: {formatDate(c.opened_at)}</span>
|
||||
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
|
||||
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
|
||||
</div>
|
||||
|
||||
{/* `<progress>` نیتیو ظاهر مرورگر را میگیرد و با توکنهای تم نمیخواند. */}
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={c.completed_sessions}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={c.total_sessions}
|
||||
aria-label={`پیشرفت دوره: ${c.completed_sessions} از ${c.total_sessions}`}
|
||||
style={{ height: 6, borderRadius: 999, background: 'var(--surface-3)', overflow: 'hidden' }}
|
||||
>
|
||||
<div style={{
|
||||
width: `${percent}%`, height: '100%', borderRadius: 999,
|
||||
background: c.status === 'completed' ? 'var(--success)' : 'var(--primary)',
|
||||
transition: 'width .3s var(--ease)',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnbookedTab() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['treatment-sessions-unbooked'],
|
||||
|
||||
@@ -1322,19 +1322,33 @@ export interface StaffTreatmentSession extends TreatmentSessionSummary {
|
||||
resource_name?: string | null;
|
||||
}
|
||||
|
||||
export type TreatmentCaseStatus = 'active' | 'completed' | 'abandoned';
|
||||
|
||||
export interface TreatmentCaseSummary {
|
||||
uuid: string;
|
||||
status: 'active' | 'completed' | 'abandoned';
|
||||
status: TreatmentCaseStatus;
|
||||
total_sessions: number;
|
||||
completed_sessions: number;
|
||||
opened_at: number;
|
||||
closed_at: number | null;
|
||||
service: { uuid: string; name: string };
|
||||
supervisor: { uuid: string; name: string } | null;
|
||||
areas: Array<{ uuid: string; name: string }>;
|
||||
/** بدون بیمار، دو پروندهٔ یک سرویس در فهرست از هم قابل تشخیص نیستند. */
|
||||
patient: {
|
||||
record_uuid: string;
|
||||
name: string | null;
|
||||
mobile: string;
|
||||
record_number: string | null;
|
||||
};
|
||||
areas: Array<{ uuid: string; name: string; category_uuid: string | null }>;
|
||||
sessions?: TreatmentSessionSummary[];
|
||||
}
|
||||
|
||||
/** پاسخ `GET /api/v1/treatment-case/{uuid}` — پرونده بهعلاوهٔ نواحیِ قابل انتخاب. */
|
||||
export interface TreatmentCaseDetail extends TreatmentCaseSummary {
|
||||
available_areas: Array<{ uuid: string; name: string }>;
|
||||
}
|
||||
|
||||
/** تعریف یک فیلد فرم ثبت درمان، از `ResourceType.field_schema`. */
|
||||
export interface TreatmentFormField {
|
||||
key: string;
|
||||
|
||||
Reference in New Issue
Block a user