diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index ee56e539..9b1acb28 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -14,6 +14,7 @@ import ClinicsPage from './pages/ClinicsPage'; import ClinicDetailPage from './pages/ClinicDetailPage'; import AppointmentsPage from './pages/AppointmentsPage'; import AppointmentDetailPage from './pages/AppointmentDetailPage'; +import AppointmentEditPage from './pages/AppointmentEditPage'; import PaymentsPage from './pages/PaymentsPage'; import PaymentDetailPage from './pages/PaymentDetailPage'; import SettlementsPage from './pages/SettlementsPage'; @@ -152,6 +153,7 @@ export default function App() { {/* نوبت‌ها — همه نقش‌ها به‌جز نماینده */} } /> } /> + } /> {/* فقط ادمین */} } /> diff --git a/assets/admin/components/AppointmentFiltersModal.test.tsx b/assets/admin/components/AppointmentFiltersModal.test.tsx new file mode 100644 index 00000000..26db02f7 --- /dev/null +++ b/assets/admin/components/AppointmentFiltersModal.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent } 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 AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from './AppointmentFiltersModal'; +import type { Appointment } from '../types'; + +const get = api.get as ReturnType; + +beforeEach(() => { + get.mockReset(); + get.mockResolvedValue({ success: true, data: [] }); +}); + +const mk = (over: Partial): Appointment => ({ + uuid: Math.random().toString(36), patient_name: 'x', patient_mobile: '0912', + doctor_uuid: 'd', doctor_name: 'دکتر', slot_start: 0, slot_end: 0, + appointment_date: '', appointment_time: '', end_time: '', + status: 'pending', version: 1, created_at: '', ...over, +}); + +describe('applyAppointmentFilters', () => { + const items = [ + mk({ patient_name: 'مریم اسکندری', patient_national_code: '001', status: 'completed', patient_gender: 'female', service_section: { uuid: 's1', name: 'زیبایی' } }), + mk({ patient_name: 'مازیار عزیزی', patient_national_code: '002', status: 'cancelled_by_user', patient_gender: 'male' }), + mk({ patient_name: 'پریسا همتی', status: 'salon', patient_gender: 'female' }), + ]; + + it('filters by name, national code, section, status group and gender', () => { + expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, name: 'مریم' })).toHaveLength(1); + expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, nationalCode: '002' })).toHaveLength(1); + expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, sectionUuid: 's1' })).toHaveLength(1); + // «لغو شده» covers both cancelled_by_* statuses + expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, statuses: ['cancelled'] })).toHaveLength(1); + expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, statuses: ['salon', 'completed'] })).toHaveLength(2); + expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, gender: 'female' })).toHaveLength(2); + expect(applyAppointmentFilters(items, EMPTY_FILTERS)).toHaveLength(3); + }); +}); + +describe('AppointmentFiltersModal (فیلترها)', () => { + it('renders the design controls and applies the chosen filters', () => { + const onApply = vi.fn(); + renderWithProviders( {}} />); + + expect(screen.getByText('حذف همه')).toBeInTheDocument(); + expect(screen.getByText('وضعیت نوبت')).toBeInTheDocument(); + expect(screen.getByText('جنسیت')).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('نام مراجعه کننده را وارد کنید...'), { target: { value: 'مریم' } }); + fireEvent.click(screen.getByLabelText('ویزیت شده')); + fireEvent.click(screen.getByLabelText('خانم')); + fireEvent.click(screen.getByText('اعمال تغییرات')); + + expect(onApply).toHaveBeenCalledWith(expect.objectContaining({ + name: 'مریم', statuses: ['completed'], gender: 'female', + })); + }); + + it('«حذف همه» resets to the empty filter set', () => { + const onApply = vi.fn(); + renderWithProviders( {}} />); + fireEvent.click(screen.getByText('حذف همه')); + fireEvent.click(screen.getByText('اعمال تغییرات')); + expect(onApply).toHaveBeenCalledWith(EMPTY_FILTERS); + }); +}); diff --git a/assets/admin/components/AppointmentFiltersModal.tsx b/assets/admin/components/AppointmentFiltersModal.tsx new file mode 100644 index 00000000..d3973240 --- /dev/null +++ b/assets/admin/components/AppointmentFiltersModal.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import type { Appointment } from '../types'; +import Modal from './ui/Modal'; + +interface Option { uuid: string; name?: string } + +export interface AppointmentFilters { + name: string; + nationalCode: string; + sectionUuid: string; + itemUuid: string; + statuses: string[]; + gender: 'female' | 'male' | 'both'; +} + +export const EMPTY_FILTERS: AppointmentFilters = { + name: '', nationalCode: '', sectionUuid: '', itemUuid: '', statuses: [], gender: 'both', +}; + +// design's 6 checkboxes; لغو شده covers both cancel reasons +const STATUS_OPTIONS: [string, string][] = [ + ['pending', 'ثبت شده'], + ['confirmed', 'قطعی شده'], + ['following_up', 'در حال پیگیری'], + ['salon', 'سالن'], + ['completed', 'ویزیت شده'], + ['cancelled', 'لغو شده'], +]; + +/** Pure client-side filter of the loaded day's appointments (Figma فیلترها). */ +export function applyAppointmentFilters(items: Appointment[], f: AppointmentFilters): Appointment[] { + return items.filter(a => { + if (f.name && !(a.patient_name ?? '').includes(f.name)) return false; + if (f.nationalCode && !(a.patient_national_code ?? '').includes(f.nationalCode)) return false; + if (f.sectionUuid && a.service_section?.uuid !== f.sectionUuid) return false; + if (f.itemUuid && a.service_item?.uuid !== f.itemUuid) return false; + if (f.statuses.length) { + const matches = f.statuses.some(s => + s === 'cancelled' ? a.status.startsWith('cancelled') : a.status === s); + if (!matches) return false; + } + if (f.gender !== 'both' && (a.patient_gender ?? '') !== f.gender) return false; + return true; + }); +} + +/** فیلترها (filter-desktop.pdf) — name/national-code search, بخش/سرویس, status checkboxes, gender. */ +export default function AppointmentFiltersModal({ value, onApply, onClose }: { + value: AppointmentFilters; onApply: (f: AppointmentFilters) => void; onClose: () => void; +}) { + const [f, setF] = useState(value); + + const sectionsQ = useQuery>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') }); + const itemsQ = useQuery>({ + queryKey: ['service-items', f.sectionUuid], + queryFn: () => api.get(`/api/v1/service-items/${f.sectionUuid}`), + enabled: !!f.sectionUuid, + }); + + const toggleStatus = (s: string) => setF(v => ({ + ...v, + statuses: v.statuses.includes(s) ? v.statuses.filter(x => x !== s) : [...v.statuses, s], + })); + + const label = { fontSize: 12.5, color: 'var(--text-3)' } as const; + const sel = { width: '100%', height: 38, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 10px' } as const; + + return ( + +
+
+ +
+ + +
+ setF(v => ({ ...v, name: e.target.value }))} placeholder="نام مراجعه کننده را وارد کنید..." /> +
+ +
+ setF(v => ({ ...v, nationalCode: e.target.value }))} placeholder="کد ملی مراجعه کننده را وارد کنید..." dir="ltr" /> +
+ + + + + + +
وضعیت نوبت
+
+ {STATUS_OPTIONS.map(([v, l]) => ( + + ))} +
+ +
+
جنسیت
+ {([['female', 'خانم'], ['male', 'آقا'], ['both', 'هر دو']] as const).map(([v, l]) => ( + + ))} +
+ + +
+
+ ); +} diff --git a/assets/admin/pages/AppointmentEditPage.test.tsx b/assets/admin/pages/AppointmentEditPage.test.tsx new file mode 100644 index 00000000..5f382841 --- /dev/null +++ b/assets/admin/pages/AppointmentEditPage.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { Routes, Route } from 'react-router-dom'; +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 AppointmentEditPage from './AppointmentEditPage'; + +const get = api.get as ReturnType; +const patch = api.patch as ReturnType; + +const slotStart = Math.floor(new Date('2026-08-01T15:00').getTime() / 1000); +const slotEnd = Math.floor(new Date('2026-08-01T16:00').getTime() / 1000); + +beforeEach(() => { + get.mockReset(); patch.mockReset(); + get.mockImplementation((url: string) => { + if (url === '/api/v1/appointment/ap1') return Promise.resolve({ success: true, data: { data: { + uuid: 'ap1', slot_start: slotStart, slot_end: slotEnd, status: 'confirmed', version: 4, + note: 'یادداشت', deposit_required: true, deposit_amount_rials: 5000000, + service_section: { uuid: 'sec1', name: 'زیبایی' }, service_item: { uuid: 'it1', name: 'لیزر' }, + staff: { uuid: 'st1', full_name: 'سحر ایمانی' }, + } } }); + if (url === '/api/v1/service-sections') return Promise.resolve({ success: true, data: [{ uuid: 'sec1', name: 'زیبایی' }] }); + if (url.startsWith('/api/v1/service-items/')) return Promise.resolve({ success: true, data: [{ uuid: 'it1', name: 'لیزر' }] }); + if (url === '/api/v1/staff') return Promise.resolve({ success: true, data: [{ uuid: 'st1', full_name: 'سحر ایمانی' }] }); + return Promise.resolve({ success: true, data: [] }); + }); + patch.mockResolvedValue({ success: true, data: {} }); +}); + +function renderEdit() { + return renderWithProviders( + } />, + { route: '/admin/appointments/ap1/edit' }, + ); +} + +describe('AppointmentEditPage (ویرایش نوبت)', () => { + it('hydrates the form from the appointment', async () => { + renderEdit(); + expect(await screen.findByText('مشخصات سرویس:')).toBeInTheDocument(); + expect((screen.getByLabelText('ساعت شروع') as HTMLInputElement).value).toBe('15:00'); + expect((screen.getByLabelText('وضعیت') as HTMLSelectElement).value).toBe('confirmed'); + expect(screen.getByDisplayValue('یادداشت')).toBeInTheDocument(); + }); + + it('patches the general update endpoint with the edited values', async () => { + renderEdit(); + await screen.findByText('مشخصات سرویس:'); + fireEvent.change(screen.getByLabelText('ساعت پایان'), { target: { value: '16:30' } }); + fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' })); + await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({ + slot_start: slotStart, + slot_end: Math.floor(new Date('2026-08-01T16:30').getTime() / 1000), + service_section_uuid: 'sec1', + staff_uuid: 'st1', + deposit_required: true, + version: 4, + }))); + }); +}); diff --git a/assets/admin/pages/AppointmentEditPage.tsx b/assets/admin/pages/AppointmentEditPage.tsx new file mode 100644 index 00000000..0ee5d600 --- /dev/null +++ b/assets/admin/pages/AppointmentEditPage.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate, useParams, Link } from 'react-router-dom'; +import { ChevronRightIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import PersianDateInput from '../components/ui/PersianDateInput'; +import PriceInput from '../components/ui/PriceInput'; + +interface Option { uuid: string; name?: string; full_name?: string } + +interface AppointmentDetail { + uuid: string; slot_start: number; slot_end: number; status: string; version: number; + note?: string | null; + deposit_required?: boolean; deposit_amount_rials?: number | null; + service_section?: Option | null; service_item?: Option | null; staff?: Option | null; +} + +const isoDate = (ts: number) => { + const d = new Date(ts * 1000); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +}; +const isoTime = (ts: number) => { + const d = new Date(ts * 1000); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +}; +const toEpoch = (date: string, time: string) => Math.floor(new Date(`${date}T${time || '00:00'}`).getTime() / 1000); + +/** ویرایش نوبت (Figma edit.pdf) — full-page edit of service specs, timing, deposit, status and notes. */ +export default function AppointmentEditPage() { + const { uuid = '' } = useParams(); + const navigate = useNavigate(); + const qc = useQueryClient(); + + const { data, isLoading } = useQuery>({ + queryKey: ['appointment-edit', uuid], + queryFn: () => api.get(`/api/v1/appointment/${uuid}`), + enabled: !!uuid, + }); + const a = data?.data?.data; + + const [sectionUuid, setSectionUuid] = useState(''); + const [itemUuid, setItemUuid] = useState(''); + const [staffUuid, setStaffUuid] = useState(''); + const [date, setDate] = useState(''); + const [start, setStart] = useState(''); + const [end, setEnd] = useState(''); + const [depositRequired, setDepositRequired] = useState(false); + const [depositRials, setDepositRials] = useState(0); + const [status, setStatus] = useState(''); + const [note, setNote] = useState(''); + + // hydrate once the appointment arrives + useEffect(() => { + if (!a) return; + setSectionUuid(a.service_section?.uuid ?? ''); + setItemUuid(a.service_item?.uuid ?? ''); + setStaffUuid(a.staff?.uuid ?? ''); + setDate(isoDate(a.slot_start)); + setStart(isoTime(a.slot_start)); + setEnd(isoTime(a.slot_end)); + setDepositRequired(!!a.deposit_required); + setDepositRials(a.deposit_amount_rials ?? 0); + setStatus(a.status); + setNote(a.note ?? ''); + }, [a]); + + const sectionsQ = useQuery>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') }); + const itemsQ = useQuery>({ + queryKey: ['service-items', sectionUuid], + queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`), + enabled: !!sectionUuid, + }); + const staffQ = useQuery>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') }); + + const save = useMutation({ + mutationFn: () => api.patch(`/api/v1/appointment/${uuid}`, { + slot_start: toEpoch(date, start), + slot_end: toEpoch(date, end), + service_section_uuid: sectionUuid, + service_item_uuid: itemUuid, + staff_uuid: staffUuid, + deposit_required: depositRequired, + deposit_amount_rials: depositRequired ? depositRials : null, + note, + ...(status !== a?.status ? { status } : {}), + version: a?.version, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['appointments'] }); + toast.success('نوبت به‌روزرسانی شد'); + navigate('/admin/appointments'); + }, + onError: (e: any) => toast.error(e.message || 'خطا در ذخیره اطلاعات'), + }); + + const label = { fontSize: 12.5, color: 'var(--text-3)' } as const; + const sel = { width: '100%', height: 38, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 10px' } as const; + + if (isLoading || !a) return
در حال بارگذاری...
; + + const statusOptions: [string, string][] = [ + ['pending', 'ثبت شده'], ['confirmed', 'قطعی شده'], ['following_up', 'در حال پیگیری'], + ['salon', 'سالن'], ['completed', 'ویزیت شده'], ['cancelled_by_doctor', 'لغو شده'], + ]; + + return ( +
+
+ + بازگشت + +
+ +
+
مشخصات سرویس:
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
زمان نوبت:
+
+
+ +
+
+
+ +
setStart(e.target.value)} dir="ltr" />
+
+
+ +
setEnd(e.target.value)} dir="ltr" />
+
+
+ +
بیعانه:
+
+ + {depositRequired && ( +
+ +
+
+ )} +
+ +
+ + +
+ + +
+