Refactor booking system: Remove unused policies, packages, and related entities
- Removed package consumption flags and related properties from PriceQuote. - Eliminated unused domain event publishing for policies and waitlist in Schedule. - Cleaned up BookingEngineSeeder by removing package and policy related logic. - Updated SeedScenariosCommand to reflect removal of policies from output. - Dropped policy, package, treatment course, cancellation, waitlist, and domain event tables in migration. - Removed domain event assertions from tests related to resource blocking.
This commit is contained in:
@@ -77,17 +77,6 @@ import InventoryPage from './pages/InventoryPage';
|
||||
import BranchesPage from './pages/BranchesPage';
|
||||
import ResourceBookingPage from './pages/ResourceBookingPage';
|
||||
import PriceListsPage from './pages/PriceListsPage';
|
||||
import ResourceUtilizationPage from './pages/ResourceUtilizationPage';
|
||||
import PlanAccuracyPage from './pages/PlanAccuracyPage';
|
||||
import CancellationPolicyPage from './pages/CancellationPolicyPage';
|
||||
import WaitlistPage from './pages/WaitlistPage';
|
||||
import CourseProtocolsPage from './pages/CourseProtocolsPage';
|
||||
import TreatmentCoursePage from './pages/TreatmentCoursePage';
|
||||
import PackagesPage from './pages/PackagesPage';
|
||||
import PatientPackageLedgerPage from './pages/PatientPackageLedgerPage';
|
||||
import PoliciesPage from './pages/PoliciesPage';
|
||||
import PolicyFormPage from './pages/PolicyFormPage';
|
||||
import PolicySimulationPage from './pages/PolicySimulationPage';
|
||||
import BranchWorkingHoursPage from './pages/BranchWorkingHoursPage';
|
||||
import BranchRoomsPage from './pages/BranchRoomsPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
@@ -300,17 +289,6 @@ export default function App() {
|
||||
<Route path="branches" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchesPage /></RoleRoute>} />
|
||||
<Route path="branches/:branchUuid/working-hours" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchWorkingHoursPage /></RoleRoute>} />
|
||||
<Route path="branches/:branchUuid/rooms" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchRoomsPage /></RoleRoute>} />
|
||||
<Route path="policies" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PoliciesPage /></RoleRoute>} />
|
||||
<Route path="policies/new" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'update']}><PolicyFormPage /></RoleRoute>} />
|
||||
<Route path="policies/:policyUuid/simulate" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PolicySimulationPage /></RoleRoute>} />
|
||||
<Route path="packages" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PackagesPage /></RoleRoute>} />
|
||||
<Route path="patient-package/:patientPackageUuid/ledger" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PatientPackageLedgerPage /></RoleRoute>} />
|
||||
<Route path="course-protocols" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><CourseProtocolsPage /></RoleRoute>} />
|
||||
<Route path="treatment-course/:courseUuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><TreatmentCoursePage /></RoleRoute>} />
|
||||
<Route path="cancellation-policy" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><CancellationPolicyPage /></RoleRoute>} />
|
||||
<Route path="waitlist" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><WaitlistPage /></RoleRoute>} />
|
||||
<Route path="reports/resource-utilization" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceUtilizationPage /></RoleRoute>} />
|
||||
<Route path="reports/plan-accuracy" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><PlanAccuracyPage /></RoleRoute>} />
|
||||
<Route path="price-lists" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><PriceListsPage /></RoleRoute>} />
|
||||
<Route path="resource-booking" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointments', 'create']}><ResourceBookingPage /></RoleRoute>} />
|
||||
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import React, { useState } from 'react';
|
||||
import ConfirmDialog from './ui/ConfirmDialog';
|
||||
import { useCancellationPreview, useCancelAppointment } from '../hooks/useCancellation';
|
||||
import { formatRial } from '../lib/utils';
|
||||
import { useCancelAppointment } from '../hooks/useCancellation';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
appointmentUuid: string;
|
||||
/** چه کسی لغو میکند — جریمه برای لغو کلینیک همیشه صفر است. */
|
||||
/** چه کسی لغو میکند — وضعیت نهایی نوبت از همین میآید. */
|
||||
by?: 'user' | 'doctor';
|
||||
onClose: () => void;
|
||||
onCancelled?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* لغو نوبت با نمایش پیامد مالی **قبل از** تأیید.
|
||||
*
|
||||
* پیشنمایش از همان محاسبهای میآید که خودِ لغو انجام میدهد، پس عددی که اپراتور
|
||||
* میبیند همان است که کسر میشود. دکمهٔ لغوِ بیپیشنمایش یعنی اپراتور جریمهٔ بیمار را
|
||||
* بعد از وقوعش کشف میکند.
|
||||
*/
|
||||
/** لغو نوبت با تأیید و دلیل اختیاری. */
|
||||
export default function CancelAppointmentDialog({
|
||||
open,
|
||||
appointmentUuid,
|
||||
@@ -27,7 +20,6 @@ export default function CancelAppointmentDialog({
|
||||
onCancelled,
|
||||
}: Props) {
|
||||
const [reason, setReason] = useState('');
|
||||
const { preview, loading } = useCancellationPreview(open ? appointmentUuid : undefined, by);
|
||||
const cancel = useCancelAppointment();
|
||||
|
||||
const close = () => {
|
||||
@@ -56,54 +48,6 @@ export default function CancelAppointmentDialog({
|
||||
}
|
||||
onCancel={close}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
padding: 12,
|
||||
borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--surface-2)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.9,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span style={{ color: 'var(--text-3)' }}>در حال محاسبهٔ پیامد لغو…</span>
|
||||
) : !preview ? (
|
||||
<span style={{ color: 'var(--text-3)' }}>
|
||||
پیامد مالی لغو در دسترس نیست؛ لغو انجام میشود ولی مبلغ را دستی بررسی کنید.
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-2)' }}>جریمهٔ لغو</span>
|
||||
<strong style={{ color: preview.penalty_rials > 0 ? 'var(--danger)' : 'var(--success)' }}>
|
||||
{preview.penalty_rials > 0 ? formatRial(preview.penalty_rials) : 'بدون جریمه'}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-2)' }}>مبلغ پرداختشده</span>
|
||||
<span>{formatRial(preview.paid_rials)}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-2)' }}>بازگشت اعتبار پکیج</span>
|
||||
<span>{preview.credit_refundable ? 'بله' : 'خیر'}</span>
|
||||
</div>
|
||||
|
||||
{preview.within_free_window && (
|
||||
<div style={{ color: 'var(--success)' }}>در بازهٔ لغو رایگان است.</div>
|
||||
)}
|
||||
|
||||
{preview.notes.map((note, i) => (
|
||||
<div key={i} style={{ color: 'var(--text-3)', fontSize: 12 }}>
|
||||
{note}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="cp-label mb-2" htmlFor="cancel-reason">
|
||||
دلیل لغو (اختیاری)
|
||||
|
||||
@@ -32,14 +32,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
|
||||
{ key: 'branches', label: 'شعبهها و اتاقها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'policies', label: 'قوانین', icon: ScaleIcon, to: '/admin/policies', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'packages', label: 'پکیجها', icon: RectangleStackIcon, to: '/admin/packages', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'course-protocols', label: 'پروتکل دوره', icon: ArrowPathRoundedSquareIcon, to: '/admin/course-protocols', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'price-lists', label: 'لیستهای قیمت', icon: BanknotesIcon, to: '/admin/price-lists', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'cancellation', label: 'سیاست لغو', icon: NoSymbolIcon, to: '/admin/cancellation-policy', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'waitlist', label: 'لیست انتظار', icon: QueueListIcon, to: '/admin/waitlist', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'utilization', label: 'بهرهوری منابع', icon: ChartBarIcon, to: '/admin/reports/resource-utilization', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'plan-accuracy', label: 'دقت برنامه', icon: ChartBarIcon, to: '/admin/reports/plan-accuracy', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
|
||||
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
|
||||
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
|
||||
|
||||
@@ -1,138 +1,27 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
import type { CancellationPolicy, CancellationPreview, CancellationResult, WaitlistEntry } from '../types';
|
||||
|
||||
/**
|
||||
* سیاست لغو و لیست انتظار.
|
||||
* لغو نوبت — فقط انتقال وضعیت.
|
||||
*
|
||||
* پیشنمایش لغو همیشه از سرور میآید — همان محاسبهای که خودِ لغو انجام میدهد، تا عددی
|
||||
* که کاربر میبیند با آنچه کسر میشود یکی باشد.
|
||||
* سیاست لغو، جریمه و لیست انتظار از محصول حذف شدهاند، پس پیشنمایش مالی هم وجود ندارد:
|
||||
* چیزی برای پیشنمایش نمانده. لغو همان `PATCH /api/v1/appointment/{uuid}/status` است که
|
||||
* تایملاین نوبت و دلیل را هم ثبت میکند.
|
||||
*/
|
||||
const POLICY_KEY = ['cancellation-policy'];
|
||||
const WAITLIST_KEY = ['waitlist'];
|
||||
|
||||
function fail(e: unknown, fallback: string) {
|
||||
toast.error(e instanceof ApiError ? e.message : fallback);
|
||||
}
|
||||
|
||||
interface PolicyResponse {
|
||||
default: CancellationPolicy | null;
|
||||
overrides: CancellationPolicy[];
|
||||
}
|
||||
|
||||
export function useCancellationPolicy() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: POLICY_KEY,
|
||||
queryFn: () => api.get<ApiResponse<PolicyResponse>>('/api/v1/cancellation-policy'),
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.put<ApiResponse<CancellationPolicy>>('/api/v1/cancellation-policy', body),
|
||||
onSuccess: () => {
|
||||
toast.success('سیاست لغو ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: POLICY_KEY });
|
||||
},
|
||||
onError: (e) => fail(e, 'ذخیرهٔ سیاست ناموفق بود'),
|
||||
});
|
||||
|
||||
return {
|
||||
policy: query.data?.data?.default ?? null,
|
||||
overrides: query.data?.data?.overrides ?? [],
|
||||
loading: query.isLoading,
|
||||
save,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCancellationPreview(appointmentUuid: string | undefined, by: 'user' | 'doctor') {
|
||||
const query = useQuery({
|
||||
queryKey: ['cancellation-preview', appointmentUuid, by],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<CancellationPreview>>(
|
||||
`/api/v1/appointment/${appointmentUuid}/cancellation-preview?by=${by}`,
|
||||
),
|
||||
enabled: !!appointmentUuid,
|
||||
});
|
||||
|
||||
return { preview: query.data?.data, loading: query.isLoading };
|
||||
}
|
||||
|
||||
export function useCancelAppointment() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ uuid, by, reason }: { uuid: string; by: 'user' | 'doctor'; reason?: string }) =>
|
||||
api.post<ApiResponse<CancellationResult>>(`/api/v1/appointment/${uuid}/cancel`, {
|
||||
by,
|
||||
api.patch<ApiResponse<unknown>>(`/api/v1/appointment/${uuid}/status`, {
|
||||
status: by === 'user' ? 'cancelled_by_user' : 'cancelled_by_doctor',
|
||||
...(reason?.trim() ? { reason: reason.trim() } : {}),
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
const notified = res.data.waitlist_notified;
|
||||
toast.success(
|
||||
notified > 0 ? `نوبت لغو شد و ${notified} نفر از لیست انتظار خبر شدند` : 'نوبت لغو شد',
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: ['appointments'] });
|
||||
qc.invalidateQueries({ queryKey: WAITLIST_KEY });
|
||||
},
|
||||
onError: (e) => fail(e, 'لغو نوبت ناموفق بود'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* درخواستهایی که با یک زمان مشخص میخوانند — ابزار لحظهٔ آزاد شدن ظرفیت.
|
||||
*
|
||||
* جدا از فهرست است چون سؤال متفاوتی میپرسد: نه «چه کسی منتظر است» بلکه «چه کسی
|
||||
* برای **این** وقت منتظر است».
|
||||
*/
|
||||
export function useWaitlistMatches(params: { serviceUuid: string; start: number; branchUuid?: string }) {
|
||||
const query = useQuery({
|
||||
queryKey: ['waitlist-matches', params],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<WaitlistEntry[]>>(
|
||||
`/api/v1/waitlist/matches?service_uuid=${params.serviceUuid}&start=${params.start}` +
|
||||
(params.branchUuid ? `&branch_uuid=${params.branchUuid}` : ''),
|
||||
),
|
||||
enabled: !!params.serviceUuid && params.start > 0,
|
||||
});
|
||||
|
||||
return { matches: Array.isArray(query.data?.data) ? query.data.data : [], loading: query.isFetching };
|
||||
}
|
||||
|
||||
export function useWaitlist(status?: string) {
|
||||
const qc = useQueryClient();
|
||||
const key = [...WAITLIST_KEY, status ?? ''];
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<WaitlistEntry[]>>(`/api/v1/waitlist${status ? `?status=${status}` : ''}`),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/waitlist/${uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('از لیست انتظار حذف شد');
|
||||
qc.invalidateQueries({ queryKey: WAITLIST_KEY });
|
||||
toast.success('نوبت لغو شد');
|
||||
qc.invalidateQueries({ queryKey: ['appointments'] });
|
||||
},
|
||||
onError: (e) => fail(e, 'حذف ناموفق بود'),
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'لغو نوبت ناموفق بود'),
|
||||
});
|
||||
|
||||
return { entries: query.data?.data ?? [], loading: query.isLoading, remove };
|
||||
}
|
||||
|
||||
/** خلاصهٔ عدم حضور یک بیمار — نشان است نه مانع؛ مسدودسازی کارِ قانون `eligibility` است. */
|
||||
export function usePatientNoShows(patientUuid: string | undefined) {
|
||||
const query = useQuery({
|
||||
queryKey: ['patient-no-shows', patientUuid],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<{ count: number; threshold: number; window_days: number; at_risk: boolean }>>(
|
||||
`/api/v1/patient/${patientUuid}/no-shows`,
|
||||
),
|
||||
enabled: !!patientUuid,
|
||||
});
|
||||
|
||||
return { noShows: query.data?.data ?? null };
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
import type {
|
||||
Policy,
|
||||
PolicyCategory,
|
||||
PolicySchema,
|
||||
PolicySimulationRun,
|
||||
PolicyTemplate,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* قوانین کلینیک.
|
||||
*
|
||||
* دو نکتهٔ قراردادی که فرم به آنها وابسته است:
|
||||
* ۱. schema و الگوها از سرور میآیند، نه از فهرستی در فرانت — یک حقیقت، نه دو تا.
|
||||
* ۲. فعالسازی بدون آزمایشِ همان نسخه ۴۲۲ میگیرد؛ UI باید کاربر را اول به آزمایش ببرد.
|
||||
*/
|
||||
const POLICIES_KEY = ['policies'];
|
||||
|
||||
function fail(e: unknown, fallback: string) {
|
||||
toast.error(e instanceof ApiError ? e.message : fallback);
|
||||
}
|
||||
|
||||
/** schema تقریباً هرگز عوض نمیشود؛ نگهداشتن طولانیاش هزینهای ندارد. */
|
||||
export function usePolicySchema() {
|
||||
const query = useQuery({
|
||||
queryKey: ['policy-schema'],
|
||||
queryFn: () => api.get<ApiResponse<PolicySchema>>('/api/v1/policy-schema'),
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
return { schema: query.data?.data, loading: query.isLoading };
|
||||
}
|
||||
|
||||
export function usePolicyTemplates() {
|
||||
const query = useQuery({
|
||||
queryKey: ['policy-templates'],
|
||||
queryFn: () => api.get<ApiResponse<PolicyTemplate[]>>('/api/v1/policy-templates'),
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
return { templates: query.data?.data ?? [], loading: query.isLoading };
|
||||
}
|
||||
|
||||
export function usePolicies(category?: PolicyCategory | '') {
|
||||
const qc = useQueryClient();
|
||||
const key = [...POLICIES_KEY, category ?? ''];
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<Policy[]>>(
|
||||
`/api/v1/policies${category ? `?category=${category}` : ''}`,
|
||||
),
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: POLICIES_KEY });
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (uuid: string) => api.post<ApiResponse<Policy>>(`/api/v1/policy/${uuid}/activate`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('قانون فعال شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'فعالسازی ناموفق بود'),
|
||||
});
|
||||
|
||||
const deactivate = useMutation({
|
||||
mutationFn: (uuid: string) => api.post<ApiResponse<Policy>>(`/api/v1/policy/${uuid}/deactivate`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('قانون غیرفعال شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'غیرفعالسازی ناموفق بود'),
|
||||
});
|
||||
|
||||
return {
|
||||
policies: query.data?.data ?? [],
|
||||
loading: query.isLoading,
|
||||
activate,
|
||||
deactivate,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePolicy(uuid: string | undefined) {
|
||||
const query = useQuery({
|
||||
queryKey: ['policy', uuid],
|
||||
queryFn: () => api.get<ApiResponse<Policy>>(`/api/v1/policy/${uuid}`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
return { policy: query.data?.data, loading: query.isLoading };
|
||||
}
|
||||
|
||||
export function usePolicyMutations() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.post<ApiResponse<Policy>>('/api/v1/policy', body),
|
||||
onSuccess: () => {
|
||||
toast.success('قانون ساخته شد — حالا آزمایشش کنید');
|
||||
qc.invalidateQueries({ queryKey: POLICIES_KEY });
|
||||
},
|
||||
onError: (e) => fail(e, 'ساخت قانون ناموفق بود'),
|
||||
});
|
||||
|
||||
const newVersion = useMutation({
|
||||
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
|
||||
api.post<ApiResponse<Policy>>(`/api/v1/policy/${uuid}/version`, body),
|
||||
onSuccess: (_d, v) => {
|
||||
toast.success('نسخهٔ تازه ثبت شد');
|
||||
qc.invalidateQueries({ queryKey: POLICIES_KEY });
|
||||
qc.invalidateQueries({ queryKey: ['policy', v.uuid] });
|
||||
},
|
||||
onError: (e) => fail(e, 'ثبت نسخه ناموفق بود'),
|
||||
});
|
||||
|
||||
return { create, newVersion };
|
||||
}
|
||||
|
||||
export function usePolicySimulation(uuid: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
const key = ['policy-simulations', uuid];
|
||||
|
||||
const history = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => api.get<ApiResponse<PolicySimulationRun[]>>(`/api/v1/policy/${uuid}/simulations`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: (sampleSize?: number) =>
|
||||
api.post<ApiResponse<PolicySimulationRun>>(`/api/v1/policy/${uuid}/simulate`, {
|
||||
sample_size: sampleSize ?? 50,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: key });
|
||||
qc.invalidateQueries({ queryKey: POLICIES_KEY });
|
||||
},
|
||||
onError: (e) => fail(e, 'اجرای آزمایشی ناموفق بود'),
|
||||
});
|
||||
|
||||
return { runs: history.data?.data ?? [], loading: history.isLoading, run };
|
||||
}
|
||||
@@ -9,9 +9,6 @@ import {
|
||||
ArrowUpTrayIcon, TrashIcon, DocumentIcon, UserIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
import { usePackages, usePatientPackages } from '../hooks/usePackages';
|
||||
import { useCourseProtocols, usePatientCourses } from '../hooks/useCourses';
|
||||
import { usePatientNoShows } from '../hooks/useCancellation';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
@@ -45,7 +42,7 @@ import {
|
||||
} from '../lib/patientForm';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'packages' | 'courses' | 'notes' | '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} /> },
|
||||
@@ -53,8 +50,6 @@ 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: 'packages', label: 'پکیجها', icon: (c) => <RectangleStackIcon style={{ width: 18, color: c }} /> },
|
||||
{ key: 'courses', label: 'دورههای درمان', icon: (c) => <ArrowPathRoundedSquareIcon style={{ width: 18, 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} /> },
|
||||
@@ -173,7 +168,6 @@ export default function PatientDetailPage() {
|
||||
const sessions = sessionsQ.data?.data ?? [];
|
||||
const hasDebt = sessions.some((s) => !s.is_paid);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const { noShows } = usePatientNoShows(uuid);
|
||||
|
||||
const nextAppointment = (appointmentsQ.data?.data ?? [])
|
||||
.filter((a: any) => (a.starts_at ?? 0) >= nowSec && !String(a.status).startsWith('cancelled'))
|
||||
@@ -191,7 +185,6 @@ export default function PatientDetailPage() {
|
||||
tags={(record as any)?.tags}
|
||||
nextAppointment={nextAppointment}
|
||||
hasDebt={hasDebt}
|
||||
noShows={noShows}
|
||||
onAddNote={() => setTab('notes')}
|
||||
/>
|
||||
|
||||
@@ -269,10 +262,6 @@ export default function PatientDetailPage() {
|
||||
<PaymentsTab q={sessionsQ} />
|
||||
) : tab === 'wallet' ? (
|
||||
<WalletTab uuid={uuid!} />
|
||||
) : tab === 'packages' ? (
|
||||
<PackagesTab uuid={uuid!} />
|
||||
) : tab === 'courses' ? (
|
||||
<CoursesTab uuid={uuid!} />
|
||||
) : tab === 'callcenter' ? (
|
||||
<CallCenterTab uuid={uuid!} />
|
||||
) : tab === 'attach' ? (
|
||||
@@ -1033,161 +1022,3 @@ function AppointmentsTab({ uuid, q }: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* پکیجهای بیمار.
|
||||
*
|
||||
* مانده از دفتر میآید نه از شمارنده؛ لینک «دفتر» همان تاریخچه را نشان میدهد تا
|
||||
* معلوم باشد عدد از کجا آمده.
|
||||
*/
|
||||
function PackagesTab({ uuid }: { uuid: string }) {
|
||||
const { patientPackages, loading, sell } = usePatientPackages(uuid);
|
||||
const { packages } = usePackages();
|
||||
const [selected, setSelected] = useState('');
|
||||
|
||||
const active = packages.filter((p) => p.active);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div className="card" style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="field" style={{ minWidth: 240, margin: 0 }}>
|
||||
<label>فروش پکیج تازه</label>
|
||||
<SearchableSelect
|
||||
value={selected}
|
||||
onChange={(v) => setSelected(String(v ?? ''))}
|
||||
options={active.map((p) => ({
|
||||
value: p.uuid,
|
||||
label: `${p.name} — ${p.session_count} جلسه`,
|
||||
}))}
|
||||
placeholder="انتخاب پکیج"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary sm"
|
||||
disabled={selected === '' || sell.isPending}
|
||||
onClick={async () => {
|
||||
await sell.mutateAsync({ package_uuid: selected });
|
||||
setSelected('');
|
||||
}}
|
||||
>
|
||||
ثبت خرید
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری…</div>
|
||||
) : patientPackages.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
|
||||
این بیمار هنوز پکیجی نخریده است
|
||||
</div>
|
||||
) : (
|
||||
patientPackages.map((p) => (
|
||||
<div key={p.uuid} className="card" style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<span style={{ fontWeight: 600 }}>{p.package_name}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
خرید {formatDate(p.purchased_at)}
|
||||
{p.valid_to !== null && ` · اعتبار تا ${formatDate(p.valid_to)}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 14 }}>
|
||||
مانده: <strong>{p.balance}</strong> از {p.session_count}
|
||||
</span>
|
||||
|
||||
{p.expired && <span className="badge red"><span className="bdot" />منقضی</span>}
|
||||
{!p.expired && p.balance === 0 && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
|
||||
اعتبار پکیج تمام شده؛ نوبت بعدی نقدی محاسبه میشود.
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Link
|
||||
className="btn secondary sm"
|
||||
style={{ marginRight: 'auto' }}
|
||||
to={`/admin/patient-package/${p.uuid}/ledger`}
|
||||
>
|
||||
دفتر اعتبار
|
||||
</Link>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* دورههای درمان بیمار.
|
||||
*
|
||||
* پیشرفت از سرور میآید («۳ از ۸»)؛ فرانت نمیشمارد، چون وضعیت جلسات آنجاست.
|
||||
*/
|
||||
function CoursesTab({ uuid }: { uuid: string }) {
|
||||
const { courses, loading, start } = usePatientCourses(uuid);
|
||||
const { protocols } = useCourseProtocols();
|
||||
const [selected, setSelected] = useState('');
|
||||
|
||||
const active = protocols.filter((p) => p.active);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div className="card" style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="field" style={{ minWidth: 240, margin: 0 }}>
|
||||
<label>شروع دورهٔ تازه</label>
|
||||
<SearchableSelect
|
||||
value={selected}
|
||||
onChange={(v) => setSelected(String(v ?? ''))}
|
||||
options={active.map((p) => ({
|
||||
value: p.uuid,
|
||||
label: `${p.service_name} — ${p.session_count} جلسه`,
|
||||
}))}
|
||||
placeholder="انتخاب پروتکل"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary sm"
|
||||
disabled={selected === '' || start.isPending}
|
||||
onClick={async () => {
|
||||
await start.mutateAsync({ protocol_uuid: selected });
|
||||
setSelected('');
|
||||
}}
|
||||
>
|
||||
شروع دوره
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری…</div>
|
||||
) : courses.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
|
||||
این بیمار دورهٔ درمانی ندارد
|
||||
</div>
|
||||
) : (
|
||||
courses.map((c) => (
|
||||
<div key={c.uuid} className="card" style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<span style={{ fontWeight: 600 }}>{c.service_name}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>شروع {formatDate(c.started_at)}</span>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 14 }}>
|
||||
جلسهٔ <strong>{c.progress.completed}</strong> از {c.progress.total}
|
||||
</span>
|
||||
|
||||
{c.status === 'active' ? (
|
||||
<span className="badge green"><span className="bdot" />در جریان</span>
|
||||
) : (
|
||||
<span className="badge"><span className="bdot" />{c.status === 'completed' ? 'تمامشده' : 'رهاشده'}</span>
|
||||
)}
|
||||
|
||||
<Link className="btn secondary sm" style={{ marginRight: 'auto' }} to={`/admin/treatment-course/${c.uuid}`}>
|
||||
جزئیات دوره
|
||||
</Link>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1106,35 +1106,6 @@ export interface PolicyClause {
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface PolicyCondition {
|
||||
match?: 'all' | 'any';
|
||||
conditions?: PolicyClause[];
|
||||
}
|
||||
|
||||
export interface PolicyEffect {
|
||||
type: string;
|
||||
value?: unknown;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface Policy {
|
||||
uuid: string;
|
||||
category: PolicyCategory;
|
||||
name: string;
|
||||
condition: PolicyCondition;
|
||||
effects: PolicyEffect[];
|
||||
priority: number;
|
||||
version: number;
|
||||
active: boolean;
|
||||
valid_from: number | null;
|
||||
valid_to: number | null;
|
||||
address_uuid: string | null;
|
||||
service_uuid: string | null;
|
||||
catalog_category_uuid: string | null;
|
||||
specificity: number;
|
||||
versions?: PolicyVersionLog[];
|
||||
}
|
||||
|
||||
export interface PolicyVersionLog {
|
||||
version: number;
|
||||
snapshot: Record<string, unknown>;
|
||||
@@ -1175,13 +1146,6 @@ export interface PolicyTemplateInput {
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface PolicyTemplate {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: PolicyCategory;
|
||||
inputs: PolicyTemplateInput[];
|
||||
}
|
||||
|
||||
export interface SimulationRow {
|
||||
appointment_uuid: string;
|
||||
@@ -1205,7 +1169,6 @@ export interface PolicySimulationRun {
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
// ── پکیج و اعتبار جلسات (تسک ۱۱) ─────────────────────────────────────────────
|
||||
|
||||
export interface PackageDefinition {
|
||||
uuid: string;
|
||||
@@ -1219,70 +1182,9 @@ export interface PackageDefinition {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface PatientPackage {
|
||||
uuid: string;
|
||||
package_uuid: string;
|
||||
package_name: string;
|
||||
patient_uuid: string;
|
||||
session_count: number;
|
||||
price_paid_rials: number;
|
||||
purchased_at: number;
|
||||
valid_to: number | null;
|
||||
expired: boolean;
|
||||
/** همیشه از جمع دفتر میآید — هیچ ستونی در دیتابیس نیست */
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface CreditLedgerRow {
|
||||
uuid: string;
|
||||
kind: 'purchase' | 'consume' | 'refund' | 'adjustment' | 'expiry';
|
||||
delta: number;
|
||||
appointment_uuid: string | null;
|
||||
service_uuid: string | null;
|
||||
service_name: string | null;
|
||||
reason: string | null;
|
||||
created_by: string | null;
|
||||
created_at: number;
|
||||
/** در UI محاسبهشده نیست — سرور همان جمع تجمعی را میدهد */
|
||||
running_balance: number;
|
||||
}
|
||||
|
||||
export interface CreditLedger {
|
||||
package: PatientPackage;
|
||||
rows: CreditLedgerRow[];
|
||||
}
|
||||
|
||||
// ── دورهٔ درمان (تسک ۱۲) ──────────────────────────────────────────────────────
|
||||
|
||||
export interface CourseProtocolStep {
|
||||
session_number: number;
|
||||
params: Record<string, string | number | boolean>;
|
||||
override_duration_minutes: number | null;
|
||||
}
|
||||
|
||||
export interface CourseProtocol {
|
||||
uuid: string;
|
||||
service_uuid: string;
|
||||
service_name: string;
|
||||
session_count: number;
|
||||
min_days: number;
|
||||
ideal_days: number;
|
||||
max_days: number;
|
||||
prefer_same_resource: boolean;
|
||||
active: boolean;
|
||||
steps: CourseProtocolStep[];
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface CourseSessionRow {
|
||||
uuid: string;
|
||||
session_number: number;
|
||||
params: Record<string, string | number | boolean>;
|
||||
appointment_uuid: string | null;
|
||||
slot_start: number | null;
|
||||
status: 'planned' | 'booked' | 'completed' | 'skipped';
|
||||
completed_at: number | null;
|
||||
}
|
||||
|
||||
export interface CourseProgress {
|
||||
completed: number;
|
||||
@@ -1295,29 +1197,6 @@ export interface CourseProgress {
|
||||
last_completed_at: number | null;
|
||||
}
|
||||
|
||||
export interface TreatmentCourse {
|
||||
uuid: string;
|
||||
patient_uuid: string;
|
||||
service_uuid: string;
|
||||
service_name: string;
|
||||
protocol_uuid: string;
|
||||
session_count: number;
|
||||
min_days: number;
|
||||
ideal_days: number;
|
||||
max_days: number;
|
||||
patient_package_uuid: string | null;
|
||||
preferred_resource_uuid: string | null;
|
||||
preferred_resource_name: string | null;
|
||||
package_balance?: number | null;
|
||||
package_shortfall?: number | null;
|
||||
status: 'active' | 'completed' | 'abandoned';
|
||||
abandon_reason: string | null;
|
||||
started_at: number;
|
||||
completed_at: number | null;
|
||||
progress: CourseProgress;
|
||||
sessions?: CourseSessionRow[];
|
||||
}
|
||||
|
||||
export interface NextSlotSuggestion {
|
||||
session_number: number | null;
|
||||
params: Record<string, string | number | boolean>;
|
||||
@@ -1327,57 +1206,7 @@ export interface NextSlotSuggestion {
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
// ── لغو و لیست انتظار (تسک ۱۳) ───────────────────────────────────────────────
|
||||
|
||||
export interface CancellationPolicy {
|
||||
uuid: string;
|
||||
service_uuid: string | null;
|
||||
service_name: string | null;
|
||||
free_window_hours: number;
|
||||
penalty_mode: 'none' | 'percent' | 'fixed';
|
||||
penalty_value: number;
|
||||
deposit_refundable: boolean;
|
||||
credit_refundable: boolean;
|
||||
no_show_threshold: number;
|
||||
risk_tag_uuid: string | null;
|
||||
active: boolean;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface CancellationPreview {
|
||||
penalty_rials: number;
|
||||
deposit_refundable: boolean;
|
||||
credit_refundable: boolean;
|
||||
within_free_window: boolean;
|
||||
notes: string[];
|
||||
paid_rials: number;
|
||||
}
|
||||
|
||||
export interface CancellationResult extends Omit<CancellationPreview, 'paid_rials'> {
|
||||
appointment_uuid: string;
|
||||
status: string;
|
||||
released_resources: number;
|
||||
waitlist_notified: number;
|
||||
penalty_charged: boolean;
|
||||
}
|
||||
|
||||
export interface WaitlistEntry {
|
||||
uuid: string;
|
||||
patient_uuid: string;
|
||||
service_uuid: string;
|
||||
service_name: string;
|
||||
branch_id: number | null;
|
||||
desired_from: number;
|
||||
desired_to: number;
|
||||
preferred_day_parts: string[];
|
||||
priority: number;
|
||||
status: 'waiting' | 'notified' | 'converted' | 'expired';
|
||||
notified_at: number | null;
|
||||
notify_count: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
// ── گزارشها (تسک ۱۴) ────────────────────────────────────────────────────────
|
||||
|
||||
export interface UtilizationRow {
|
||||
resource_uuid: string;
|
||||
|
||||
Reference in New Issue
Block a user