feat(admin): finish the screens that were stopping one step short
Five places where the data existed and the screen did not use it. Booking a whole course had no button because it needs a doctor and the course does not carry one — each session can be with a different doctor. The page now asks for the doctor the same way the resource booking page does, and the button explains that it is all-or-nothing before it is pressed. A course whose package does not cover the remaining sessions is still valid — the rest is simply charged normally — but nobody was told. The course response carries package_balance and the shortfall, and the page warns. Before session six, not during it. The credit ledger already returned who recorded a row and which appointment it belonged to, and showed neither. An adjustable ledger without the name of the person who adjusted it is half an audit trail. Version history printed a JSON blob of each version's effects, which does not answer the question anyone actually has: what changed? It now diffs each version against the previous one, field by field, and says so plainly when a version changed nothing meaningful. A resource with no calendar showed "—" for utilization. Null means undefined, not zero, and the next step is always the same: set up the calendar. It is a link now. The report range also accepts a custom from/to, kept in the URL like the rest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import PolicyVersionDiff from './PolicyVersionDiff';
|
||||||
|
|
||||||
|
const version = (n: number, snapshot: Record<string, unknown>) => ({
|
||||||
|
version: n,
|
||||||
|
created_at: 1_800_000_000 + n,
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PolicyVersionDiff', () => {
|
||||||
|
/** ⭐ سؤال واقعی «چه چیزی عوض شد؟» است، نه «هر نسخه چه بود». */
|
||||||
|
it('shows only the fields that changed between versions', () => {
|
||||||
|
render(
|
||||||
|
<PolicyVersionDiff
|
||||||
|
versions={[
|
||||||
|
version(1, { name: 'تخفیف پاییز', priority: 0, effects: [{ type: 'discount_percent', value: 10 }] }),
|
||||||
|
version(2, { name: 'تخفیف پاییز', priority: 5, effects: [{ type: 'discount_percent', value: 20 }] }),
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('اولویت')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('اثرها')).toBeInTheDocument();
|
||||||
|
// نام عوض نشده، پس نباید ردیف بگیرد.
|
||||||
|
expect(screen.queryByText('نام')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the first version rather than diffing it against nothing', () => {
|
||||||
|
render(<PolicyVersionDiff versions={[version(1, { name: 'قانون' })]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('نسخهٔ نخست')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when a version changed nothing meaningful', () => {
|
||||||
|
render(
|
||||||
|
<PolicyVersionDiff
|
||||||
|
versions={[version(1, { name: 'قانون', priority: 0 }), version(2, { name: 'قانون', priority: 0 })]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('بدون تغییرِ معنادار')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { formatDate } from '../lib/utils';
|
||||||
|
|
||||||
|
interface VersionRow {
|
||||||
|
version: number;
|
||||||
|
created_at: number;
|
||||||
|
snapshot: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فقط فیلدهایی که تغییرشان معنا دارد — `updated_at` و نسخه خودشان همیشه فرق دارند. */
|
||||||
|
const WATCHED: Array<{ key: string; label: string }> = [
|
||||||
|
{ key: 'name', label: 'نام' },
|
||||||
|
{ key: 'priority', label: 'اولویت' },
|
||||||
|
{ key: 'active', label: 'فعال' },
|
||||||
|
{ key: 'condition', label: 'شرط' },
|
||||||
|
{ key: 'effects', label: 'اثرها' },
|
||||||
|
{ key: 'valid_from', label: 'شروع اعتبار' },
|
||||||
|
{ key: 'valid_to', label: 'پایان اعتبار' },
|
||||||
|
{ key: 'service_uuid', label: 'سرویس' },
|
||||||
|
{ key: 'address_uuid', label: 'شعبه' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function show(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '—';
|
||||||
|
if (typeof value === 'boolean') return value ? 'بله' : 'خیر';
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value, null, 0);
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تفاوت هر نسخه با نسخهٔ پیش از خودش.
|
||||||
|
*
|
||||||
|
* فهرست کامل اثرها روی هر نسخه، سؤال واقعی را جواب نمیدهد: «چه چیزی عوض شد؟». وقتی
|
||||||
|
* نوبتی به نسخهٔ ۳ ارجاع میدهد و کسی میپرسد چرا قیمتش فرق دارد، همین ستون جواب است.
|
||||||
|
*/
|
||||||
|
export default function PolicyVersionDiff({ versions }: { versions: VersionRow[] }) {
|
||||||
|
if (!versions || versions.length === 0) return null;
|
||||||
|
|
||||||
|
const ordered = [...versions].sort((a, b) => a.version - b.version);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-pad" style={{ marginBottom: 16 }}>
|
||||||
|
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>تاریخچهٔ نسخهها</h3>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{ordered.map((v, i) => {
|
||||||
|
const previous = i === 0 ? null : ordered[i - 1].snapshot;
|
||||||
|
|
||||||
|
const changes = previous
|
||||||
|
? WATCHED.filter((f) => show(v.snapshot[f.key]) !== show(previous[f.key]))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={v.version}
|
||||||
|
style={{ borderTop: i === 0 ? undefined : '1px solid var(--border)', paddingTop: i === 0 ? 0 : 10 }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: 10, fontSize: 12, alignItems: 'center' }}>
|
||||||
|
<span style={{ fontWeight: 600, minWidth: 60 }}>نسخهٔ {v.version}</span>
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>{formatDate(v.created_at)}</span>
|
||||||
|
{previous === null && (
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>نسخهٔ نخست</span>
|
||||||
|
)}
|
||||||
|
{previous !== null && changes.length === 0 && (
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>بدون تغییرِ معنادار</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{changes.map((f) => (
|
||||||
|
<div
|
||||||
|
key={f.key}
|
||||||
|
style={{ display: 'flex', gap: 8, fontSize: 12, marginTop: 6, flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
<span style={{ minWidth: 90, color: 'var(--text-2)' }}>{f.label}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--danger)',
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{show(previous?.[f.key])}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>←</span>
|
||||||
|
<span style={{ color: 'var(--success)', wordBreak: 'break-all' }}>
|
||||||
|
{show(v.snapshot[f.key])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import DataTable, { type Column } from '../components/ui/DataTable';
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
@@ -66,6 +66,30 @@ export default function PatientPackageLedgerPage() {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// «چه کسی» و «کدام نوبت» از قبل در پاسخ بودند و نمایش داده نمیشدند. دفترِ
|
||||||
|
// اصلاحپذیر بدون نامِ اصلاحکننده، نصف حسابرسی است.
|
||||||
|
key: 'created_by',
|
||||||
|
header: 'ثبتکننده',
|
||||||
|
render: (r) => (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{r.created_by ?? 'سیستم'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'appointment_uuid',
|
||||||
|
header: 'نوبت',
|
||||||
|
render: (r) =>
|
||||||
|
r.appointment_uuid ? (
|
||||||
|
<Link
|
||||||
|
to={`/admin/appointments/${r.appointment_uuid}`}
|
||||||
|
style={{ fontSize: 12, color: 'var(--primary)' }}
|
||||||
|
>
|
||||||
|
مشاهدهٔ نوبت
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BeakerIcon } from '@heroicons/react/24/outline';
|
|||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
import DataTable, { type Column } from '../components/ui/DataTable';
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||||
|
import PolicyVersionDiff from '../components/PolicyVersionDiff';
|
||||||
import { formatDate } from '../lib/utils';
|
import { formatDate } from '../lib/utils';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { usePolicies, usePolicy, usePolicySimulation } from '../hooks/usePolicies';
|
import { usePolicies, usePolicy, usePolicySimulation } from '../hooks/usePolicies';
|
||||||
@@ -163,20 +164,7 @@ export default function PolicySimulationPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{policy?.versions && policy.versions.length > 1 && (
|
{policy?.versions && policy.versions.length > 1 && (
|
||||||
<div className="card" style={{ marginBottom: 16 }}>
|
<PolicyVersionDiff versions={policy.versions as never} />
|
||||||
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>تاریخچهٔ نسخهها</h3>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{policy.versions.map((v) => (
|
|
||||||
<div key={v.version} style={{ display: 'flex', gap: 10, fontSize: 12 }}>
|
|
||||||
<span style={{ fontWeight: 600, minWidth: 60 }}>نسخهٔ {v.version}</span>
|
|
||||||
<span style={{ color: 'var(--text-3)' }}>{formatDate(v.created_at)}</span>
|
|
||||||
<span style={{ color: 'var(--text-2)' }}>
|
|
||||||
{JSON.stringify((v.snapshot as { effects?: unknown }).effects)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { useUrlState } from '../hooks/useUrlState';
|
|||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import DataTable, { type Column } from '../components/ui/DataTable';
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { isoToUnix, unixToIso } from '../lib/utils';
|
||||||
import { useBranches } from '../hooks/useBranches';
|
import { useBranches } from '../hooks/useBranches';
|
||||||
import { useResourceUtilization } from '../hooks/useReports';
|
import { useResourceUtilization } from '../hooks/useReports';
|
||||||
import type { UtilizationRow } from '../types';
|
import type { UtilizationRow } from '../types';
|
||||||
@@ -11,6 +14,7 @@ const RANGES = [
|
|||||||
{ value: '7', label: 'هفتهٔ گذشته' },
|
{ value: '7', label: 'هفتهٔ گذشته' },
|
||||||
{ value: '30', label: 'ماه گذشته' },
|
{ value: '30', label: 'ماه گذشته' },
|
||||||
{ value: '90', label: 'سه ماه گذشته' },
|
{ value: '90', label: 'سه ماه گذشته' },
|
||||||
|
{ value: 'custom', label: 'بازهٔ دلخواه' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function percent(value: number | null): string {
|
function percent(value: number | null): string {
|
||||||
@@ -27,17 +31,28 @@ export default function ResourceUtilizationPage() {
|
|||||||
const { branches } = useBranches();
|
const { branches } = useBranches();
|
||||||
// بازه و شعبه در URL مینشینند نه در state: بازگشت از صفحهٔ منبع باید همان گزارش را
|
// بازه و شعبه در URL مینشینند نه در state: بازگشت از صفحهٔ منبع باید همان گزارش را
|
||||||
// برگرداند، و لینکِ گزارش باید همان چیزی را نشان بدهد که فرستنده دیده.
|
// برگرداند، و لینکِ گزارش باید همان چیزی را نشان بدهد که فرستنده دیده.
|
||||||
const [urlState, setUrlState] = useUrlState({ branch: '', days: '7' });
|
const [urlState, setUrlState] = useUrlState({ branch: '', days: '7', from: '', to: '' });
|
||||||
const branchUuid = urlState.branch;
|
const branchUuid = urlState.branch;
|
||||||
const days = urlState.days;
|
const days = urlState.days;
|
||||||
|
|
||||||
const setBranchUuid = (v: string) => setUrlState({ branch: v });
|
const setBranchUuid = (v: string) => setUrlState({ branch: v });
|
||||||
const setDays = (v: string) => setUrlState({ days: v });
|
const setDays = (v: string) => setUrlState({ days: v });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بازهٔ آماده برای حالت عادی، بازهٔ دلخواه برای وقتی که کاربر دقیقاً میداند چه
|
||||||
|
* میخواهد — مثلاً مقایسهٔ دو ماه مشخص با هم.
|
||||||
|
*/
|
||||||
const range = useMemo(() => {
|
const range = useMemo(() => {
|
||||||
|
if (days === 'custom') {
|
||||||
|
return {
|
||||||
|
from: isoToUnix(urlState.from) ?? Math.floor(Date.now() / 1000) - 7 * 86400,
|
||||||
|
to: isoToUnix(urlState.to) ?? Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const to = Math.floor(Date.now() / 1000);
|
const to = Math.floor(Date.now() / 1000);
|
||||||
return { from: to - Number(days) * 86400, to };
|
return { from: to - Number(days) * 86400, to };
|
||||||
}, [days]);
|
}, [days, urlState.from, urlState.to]);
|
||||||
|
|
||||||
const { rows, loading } = useResourceUtilization(branchUuid || undefined, range.from, range.to);
|
const { rows, loading } = useResourceUtilization(branchUuid || undefined, range.from, range.to);
|
||||||
|
|
||||||
@@ -70,14 +85,20 @@ export default function ResourceUtilizationPage() {
|
|||||||
{
|
{
|
||||||
key: 'utilization',
|
key: 'utilization',
|
||||||
header: 'بهرهوری',
|
header: 'بهرهوری',
|
||||||
render: (r) => (
|
// `null` یعنی «تعریفنشده» نه «صفر»، و کار بعدی روشن است: تقویم منبع را بساز.
|
||||||
<span
|
// بدون لینک، کاربر عدد را میبیند و نمیداند کجا باید برود.
|
||||||
style={{ fontSize: 13 }}
|
render: (r) =>
|
||||||
title={r.utilization === null ? 'برای این منبع تقویمی تعریف نشده است' : undefined}
|
r.utilization === null ? (
|
||||||
>
|
<Link
|
||||||
{percent(r.utilization)}
|
to={`/admin/resources/${r.resource_uuid}/calendar`}
|
||||||
</span>
|
style={{ fontSize: 12, color: 'var(--primary)' }}
|
||||||
),
|
title="برای این منبع تقویمی تعریف نشده است"
|
||||||
|
>
|
||||||
|
تنظیم تقویم
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 13 }}>{percent(r.utilization)}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'active_ratio',
|
key: 'active_ratio',
|
||||||
@@ -126,6 +147,25 @@ export default function ResourceUtilizationPage() {
|
|||||||
<label>بازه</label>
|
<label>بازه</label>
|
||||||
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '7'))} options={RANGES} />
|
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '7'))} options={RANGES} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{days === 'custom' && (
|
||||||
|
<>
|
||||||
|
<div className="field-block" style={{ minWidth: 170, margin: 0 }}>
|
||||||
|
<label>از تاریخ</label>
|
||||||
|
<PersianDateInput
|
||||||
|
value={urlState.from || unixToIso(range.from)}
|
||||||
|
onChange={(v) => setUrlState({ from: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field-block" style={{ minWidth: 170, margin: 0 }}>
|
||||||
|
<label>تا تاریخ</label>
|
||||||
|
<PersianDateInput
|
||||||
|
value={urlState.to || unixToIso(range.to)}
|
||||||
|
onChange={(v) => setUrlState({ to: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px' }}>
|
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px' }}>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { formatDate, formatNumber } from '../lib/utils';
|
|||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { useBranches } from '../hooks/useBranches';
|
import { useBranches } from '../hooks/useBranches';
|
||||||
import { useNextSlotSuggestion, useTreatmentCourse } from '../hooks/useCourses';
|
import { useNextSlotSuggestion, useTreatmentCourse } from '../hooks/useCourses';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api, type ApiResponse } from '../lib/api';
|
||||||
import type { CourseSessionRow } from '../types';
|
import type { CourseSessionRow } from '../types';
|
||||||
|
|
||||||
const SESSION_STATUS: Record<CourseSessionRow['status'], { label: string; className: string }> = {
|
const SESSION_STATUS: Record<CourseSessionRow['status'], { label: string; className: string }> = {
|
||||||
@@ -25,17 +27,27 @@ const SESSION_STATUS: Record<CourseSessionRow['status'], { label: string; classN
|
|||||||
*/
|
*/
|
||||||
export default function TreatmentCoursePage() {
|
export default function TreatmentCoursePage() {
|
||||||
const { courseUuid } = useParams<{ courseUuid: string }>();
|
const { courseUuid } = useParams<{ courseUuid: string }>();
|
||||||
const { course, loading, abandon } = useTreatmentCourse(courseUuid);
|
const { course, loading, abandon, bookAll } = useTreatmentCourse(courseUuid);
|
||||||
const { branches } = useBranches();
|
const { branches } = useBranches();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const canManage = can('appointment_settings', 'update');
|
const canManage = can('appointment_settings', 'update');
|
||||||
|
|
||||||
const [branchUuid, setBranchUuid] = useState('');
|
const [branchUuid, setBranchUuid] = useState('');
|
||||||
|
const [doctorUuid, setDoctorUuid] = useState('');
|
||||||
const [abandoning, setAbandoning] = useState(false);
|
const [abandoning, setAbandoning] = useState(false);
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
const { suggestion } = useNextSlotSuggestion(courseUuid, branchUuid || undefined);
|
const { suggestion } = useNextSlotSuggestion(courseUuid, branchUuid || undefined);
|
||||||
|
|
||||||
|
/** همان اندپوینتی که صفحهٔ رزرو منبعمحور استفاده میکند — منشی فقط پزشکان خودش را میبیند. */
|
||||||
|
const doctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
|
||||||
|
queryKey: ['booking-doctors'],
|
||||||
|
queryFn: () => api.get('/api/v1/my/clinic-doctors'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const doctors = doctorsQuery.data?.data?.data ?? [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* دورهای که وسطش لغو شده، بیصدا کِش میآید: جلسه به «برنامهریزیشده» برمیگردد و
|
* دورهای که وسطش لغو شده، بیصدا کِش میآید: جلسه به «برنامهریزیشده» برمیگردد و
|
||||||
* هیچکس خبردار نمیشود.
|
* هیچکس خبردار نمیشود.
|
||||||
@@ -173,6 +185,25 @@ export default function TreatmentCoursePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* پکیجی که کفاف جلسات باقیمانده را نمیدهد، دوره را باطل نمیکند — بقیهاش
|
||||||
|
نقدی میشود. ولی باید قبل از جلسهٔ ششم دانسته شود، نه سرِ آن. */}
|
||||||
|
{(course.package_shortfall ?? 0) > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.8,
|
||||||
|
color: 'var(--warning)',
|
||||||
|
background: 'var(--warning-bg)',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
padding: '8px 10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
اعتبار پکیج برای {formatNumber(course.package_shortfall ?? 0)} جلسه کم میآید
|
||||||
|
(مانده: {formatNumber(course.package_balance ?? 0)}). بقیهٔ جلسات با قیمت
|
||||||
|
عادی حساب میشوند.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{overdue !== null && (
|
{overdue !== null && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -204,6 +235,36 @@ export default function TreatmentCoursePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* رزرو گروهی پزشک لازم دارد؛ خودِ دوره پزشکی ندارد چون هر جلسه میتواند
|
||||||
|
با پزشک دیگری باشد. */}
|
||||||
|
{canManage && course.status === 'active' && (
|
||||||
|
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
|
||||||
|
<label>پزشک برای رزرو گروهی</label>
|
||||||
|
<SearchableSelect
|
||||||
|
value={doctorUuid}
|
||||||
|
onChange={(v) => setDoctorUuid(String(v ?? ''))}
|
||||||
|
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
|
||||||
|
placeholder="انتخاب پزشک"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManage && course.status === 'active' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary sm"
|
||||||
|
disabled={branchUuid === '' || doctorUuid === '' || bookAll.isPending}
|
||||||
|
title={
|
||||||
|
branchUuid === '' || doctorUuid === ''
|
||||||
|
? 'شعبه و پزشک را انتخاب کنید'
|
||||||
|
: 'همهٔ جلسات برنامهریزیشده یکجا رزرو میشوند — همه یا هیچ'
|
||||||
|
}
|
||||||
|
onClick={() => bookAll.mutate({ branch_uuid: branchUuid, doctor_uuid: doctorUuid })}
|
||||||
|
>
|
||||||
|
رزرو همهٔ جلسات
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{canManage && course.status === 'active' && (
|
{canManage && course.status === 'active' && (
|
||||||
<button type="button" className="btn secondary sm" onClick={() => setAbandoning(true)}>
|
<button type="button" className="btn secondary sm" onClick={() => setAbandoning(true)}>
|
||||||
رهاکردن دوره
|
رهاکردن دوره
|
||||||
|
|||||||
@@ -1308,6 +1308,8 @@ export interface TreatmentCourse {
|
|||||||
patient_package_uuid: string | null;
|
patient_package_uuid: string | null;
|
||||||
preferred_resource_uuid: string | null;
|
preferred_resource_uuid: string | null;
|
||||||
preferred_resource_name: string | null;
|
preferred_resource_name: string | null;
|
||||||
|
package_balance?: number | null;
|
||||||
|
package_shortfall?: number | null;
|
||||||
status: 'active' | 'completed' | 'abandoned';
|
status: 'active' | 'completed' | 'abandoned';
|
||||||
abandon_reason: string | null;
|
abandon_reason: string | null;
|
||||||
started_at: number;
|
started_at: number;
|
||||||
|
|||||||
@@ -126,6 +126,8 @@
|
|||||||
"patient_package_uuid": null,
|
"patient_package_uuid": null,
|
||||||
"preferred_resource_uuid": null,
|
"preferred_resource_uuid": null,
|
||||||
"preferred_resource_name": null,
|
"preferred_resource_name": null,
|
||||||
|
"package_balance": 4,
|
||||||
|
"package_shortfall": 2,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"abandon_reason": null,
|
"abandon_reason": null,
|
||||||
"started_at": 1785484481,
|
"started_at": 1785484481,
|
||||||
@@ -306,3 +308,11 @@ ddev exec php bin/phpunit tests/Course # ۱۴ تست
|
|||||||
مهمترینها: `testChangingTheProtocolLeavesRunningCoursesAlone` (snapshot)،
|
مهمترینها: `testChangingTheProtocolLeavesRunningCoursesAlone` (snapshot)،
|
||||||
`testTheSuggestionAnchorsOnTheLastCompletedSession` (لنگر متحرک) و
|
`testTheSuggestionAnchorsOnTheLastCompletedSession` (لنگر متحرک) و
|
||||||
`testCancellingOneSessionOnlyResetsThatSession`.
|
`testCancellingOneSessionOnlyResetsThatSession`.
|
||||||
|
|
||||||
|
## اعتبار پکیج در برابر جلسات باقیمانده
|
||||||
|
|
||||||
|
`package_balance` مانده و `package_shortfall` کسری آن نسبت به جلسات **انجامنشده** است
|
||||||
|
(`null` وقتی دوره پکیجی ندارد).
|
||||||
|
|
||||||
|
کسری، دوره را باطل نمیکند و خطا هم نیست: بقیهٔ جلسات با قیمت عادی حساب میشوند. ولی
|
||||||
|
باید پیش از جلسهٔ ششم دانسته شود نه سرِ آن، پس صفحهٔ دوره آن را بهصورت هشدار نشان میدهد.
|
||||||
|
|||||||
@@ -156,3 +156,12 @@ ddev exec php bin/phpunit tests/Report # ۱۶ تست
|
|||||||
پیش از این هر منبع پنج کوئری اضافه میآورد و گزارشِ یک کلینیک چهلمنبعی دویست کوئری
|
پیش از این هر منبع پنج کوئری اضافه میآورد و گزارشِ یک کلینیک چهلمنبعی دویست کوئری
|
||||||
میشد. تست `testQueryCountDoesNotGrowWithTheNumberOfResources` همین را نگه میدارد —
|
میشد. تست `testQueryCountDoesNotGrowWithTheNumberOfResources` همین را نگه میدارد —
|
||||||
عددِ دقیق را پین نمیکند، فقط رشدِ خطی را رد میکند.
|
عددِ دقیق را پین نمیکند، فقط رشدِ خطی را رد میکند.
|
||||||
|
|
||||||
|
## `utilization = null` در UI
|
||||||
|
|
||||||
|
بهجای «۰٪»، لینک **«تنظیم تقویم»** به تقویم همان منبع نمایش داده میشود. `null` یعنی
|
||||||
|
تعریفنشده نه صفر، و کار بعدی کاربر همان ساختن تقویم است — عددِ تنها او را به آنجا
|
||||||
|
نمیرساند.
|
||||||
|
|
||||||
|
بازهٔ گزارش علاوه بر هفته/ماه/سهماه، حالت **دلخواه** هم دارد (`from`/`to` شمسی در URL)
|
||||||
|
برای وقتی که کاربر دقیقاً میداند چه بازهای میخواهد.
|
||||||
|
|||||||
@@ -171,8 +171,20 @@ class TreatmentCourseController extends BaseController
|
|||||||
usort($sessions, static fn (CourseSession $a, CourseSession $b): int
|
usort($sessions, static fn (CourseSession $a, CourseSession $b): int
|
||||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||||
|
|
||||||
|
// مانده در برابر جلسات باقیمانده: **هشدار** است نه خطا. دورهای که پکیجش کفاف
|
||||||
|
// نمیدهد هنوز کاملاً معتبر است — بقیهاش نقدی میشود — ولی کسی باید بداند،
|
||||||
|
// ترجیحاً قبل از جلسهٔ ششم نه سرِ آن.
|
||||||
|
$package = $course->getPatientPackage();
|
||||||
|
$balance = $package === null ? null : $this->credits->balance($package);
|
||||||
|
$needed = count(array_filter(
|
||||||
|
$sessions,
|
||||||
|
static fn (CourseSession $s): bool => $s->getStatus() !== CourseSession::STATUS_COMPLETED,
|
||||||
|
));
|
||||||
|
|
||||||
return $course->toArray() + [
|
return $course->toArray() + [
|
||||||
'progress' => $this->progress->progressOf($course),
|
'progress' => $this->progress->progressOf($course),
|
||||||
|
'package_balance' => $balance,
|
||||||
|
'package_shortfall' => $balance === null ? null : max(0, $needed - $balance),
|
||||||
'sessions' => array_map(
|
'sessions' => array_map(
|
||||||
static fn (CourseSession $s): array => $s->toArray(),
|
static fn (CourseSession $s): array => $s->toArray(),
|
||||||
$sessions,
|
$sessions,
|
||||||
|
|||||||
Reference in New Issue
Block a user