feat(appointments): edit page + filters modal — phase C2/C3
- AppointmentEditPage (edit.pdf): full-page edit of بخش/سرویس/پرسنل, Jalali
date + start/end time, deposit, status and notes; hydrates from
GET /appointment/{uuid} and saves through the general PATCH with the
optimistic-lock version. Routed at /admin/appointments/:uuid/edit (the
actions-menu ویرایش target).
- AppointmentFiltersModal (filter-desktop.pdf): name/national-code search,
بخش/سرویس selects, six status checkboxes (لغو شده covers both cancel
reasons), gender radios, حذف همه reset. Filtering is client-side over the
loaded day via the pure applyAppointmentFilters; toolbar gains the filter
button with an active indicator.
- /my/appointments rows now include patient_national_code and patient_gender
so the filters have data to match on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
{/* نوبتها — همه نقشها بهجز نماینده */}
|
||||
<Route path="appointments" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentsPage /></RoleRoute>} />
|
||||
<Route path="appointments/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentDetailPage /></RoleRoute>} />
|
||||
<Route path="appointments/:uuid/edit" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentEditPage /></RoleRoute>} />
|
||||
|
||||
{/* فقط ادمین */}
|
||||
<Route path="users" element={<RoleRoute roles={['admin']}><UsersPage /></RoleRoute>} />
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
get.mockResolvedValue({ success: true, data: [] });
|
||||
});
|
||||
|
||||
const mk = (over: Partial<Appointment>): 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(<AppointmentFiltersModal value={EMPTY_FILTERS} onApply={onApply} onClose={() => {}} />);
|
||||
|
||||
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(<AppointmentFiltersModal
|
||||
value={{ ...EMPTY_FILTERS, name: 'x', statuses: ['salon'] }} onApply={onApply} onClose={() => {}} />);
|
||||
fireEvent.click(screen.getByText('حذف همه'));
|
||||
fireEvent.click(screen.getByText('اعمال تغییرات'));
|
||||
expect(onApply).toHaveBeenCalledWith(EMPTY_FILTERS);
|
||||
});
|
||||
});
|
||||
@@ -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<AppointmentFilters>(value);
|
||||
|
||||
const sectionsQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
|
||||
const itemsQ = useQuery<ApiResponse<Option[]>>({
|
||||
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 (
|
||||
<Modal open title="فیلترها" onClose={onClose}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 8 }}>
|
||||
<button className="btn sm ghost" style={{ color: 'var(--accent)' }} onClick={() => setF(EMPTY_FILTERS)}>
|
||||
<XMarkIcon style={{ width: 14 }} /> حذف همه
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label style={label}>جستجو براساس نام</label>
|
||||
<div className="field" style={{ margin: '6px 0 12px' }}>
|
||||
<input value={f.name} onChange={e => setF(v => ({ ...v, name: e.target.value }))} placeholder="نام مراجعه کننده را وارد کنید..." />
|
||||
</div>
|
||||
<label style={label}>جستجو براساس کد ملی</label>
|
||||
<div className="field" style={{ margin: '6px 0 12px' }}>
|
||||
<input value={f.nationalCode} onChange={e => setF(v => ({ ...v, nationalCode: e.target.value }))} placeholder="کد ملی مراجعه کننده را وارد کنید..." dir="ltr" />
|
||||
</div>
|
||||
|
||||
<label style={label}>بخش</label>
|
||||
<select aria-label="بخش" style={{ ...sel, margin: '6px 0 12px' }} value={f.sectionUuid}
|
||||
onChange={e => setF(v => ({ ...v, sectionUuid: e.target.value, itemUuid: '' }))}>
|
||||
<option value="">انتخاب بخش</option>
|
||||
{(sectionsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
<label style={label}>سرویس</label>
|
||||
<select aria-label="سرویس" style={{ ...sel, margin: '6px 0 14px' }} value={f.itemUuid} disabled={!f.sectionUuid}
|
||||
onChange={e => setF(v => ({ ...v, itemUuid: e.target.value }))}>
|
||||
<option value="">انتخاب سرویس</option>
|
||||
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
|
||||
<div style={{ fontSize: 13.5, fontWeight: 700, marginBottom: 8 }}>وضعیت نوبت</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
|
||||
{STATUS_OPTIONS.map(([v, l]) => (
|
||||
<label key={v} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={f.statuses.includes(v)} onChange={() => toggleStatus(v)} /> {l}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12, marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 700, marginBottom: 8 }}>جنسیت</div>
|
||||
{([['female', 'خانم'], ['male', 'آقا'], ['both', 'هر دو']] as const).map(([v, l]) => (
|
||||
<label key={v} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', marginBottom: 6 }}>
|
||||
<input type="radio" name="gender" checked={f.gender === v} onChange={() => setF(x => ({ ...x, gender: v }))} /> {l}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="btn primary" style={{ width: '100%' }} onClick={() => { onApply(f); onClose(); }}>
|
||||
اعمال تغییرات
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
|
||||
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(
|
||||
<Routes><Route path="/admin/appointments/:uuid/edit" element={<AppointmentEditPage />} /></Routes>,
|
||||
{ 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,
|
||||
})));
|
||||
});
|
||||
});
|
||||
@@ -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<ApiResponse<{ data: AppointmentDetail }>>({
|
||||
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<ApiResponse<Option[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
|
||||
const itemsQ = useQuery<ApiResponse<Option[]>>({
|
||||
queryKey: ['service-items', sectionUuid],
|
||||
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
|
||||
enabled: !!sectionUuid,
|
||||
});
|
||||
const staffQ = useQuery<ApiResponse<Option[]>>({ 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 <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
|
||||
|
||||
const statusOptions: [string, string][] = [
|
||||
['pending', 'ثبت شده'], ['confirmed', 'قطعی شده'], ['following_up', 'در حال پیگیری'],
|
||||
['salon', 'سالن'], ['completed', 'ویزیت شده'], ['cancelled_by_doctor', 'لغو شده'],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in" style={{ maxWidth: 860, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 14 }}>
|
||||
<Link to="/admin/appointments" className="btn sm ghost" style={{ color: 'var(--text-2)' }}>
|
||||
<ChevronRightIcon style={{ width: 16 }} /> بازگشت
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 22 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>مشخصات سرویس:</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}>
|
||||
<div>
|
||||
<label style={label}>بخش</label>
|
||||
<select aria-label="بخش" style={{ ...sel, marginTop: 6 }} value={sectionUuid} onChange={e => { setSectionUuid(e.target.value); setItemUuid(''); }}>
|
||||
<option value="">انتخاب بخش</option>
|
||||
{(sectionsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>سرویس</label>
|
||||
<select aria-label="سرویس" style={{ ...sel, marginTop: 6 }} value={itemUuid} onChange={e => setItemUuid(e.target.value)} disabled={!sectionUuid}>
|
||||
<option value="">انتخاب سرویس</option>
|
||||
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>پرسنل</label>
|
||||
<select aria-label="پرسنل" style={{ ...sel, marginTop: 6 }} value={staffUuid} onChange={e => setStaffUuid(e.target.value)}>
|
||||
<option value="">انتخاب پرسنل</option>
|
||||
{(staffQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.full_name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>زمان نوبت:</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 18 }}>
|
||||
<div>
|
||||
<label style={label}>انتخاب تاریخ</label>
|
||||
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>ساعت شروع</label>
|
||||
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>ساعت پایان</label>
|
||||
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>بیعانه:</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)} />
|
||||
بیعانه مورد نیاز است.
|
||||
</label>
|
||||
{depositRequired && (
|
||||
<div style={{ minWidth: 220 }}>
|
||||
<label style={label}>مبلغ بیعانه (تومان)</label>
|
||||
<div style={{ marginTop: 6 }}><PriceInput value={depositRials} onChange={setDepositRials} /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 320, marginBottom: 18 }}>
|
||||
<label style={label}>انتخاب وضعیت</label>
|
||||
<select aria-label="وضعیت" style={{ ...sel, marginTop: 6 }} value={status} onChange={e => setStatus(e.target.value)}>
|
||||
{statusOptions.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label style={label}>توضیحات</label>
|
||||
<div className="field" style={{ height: 'auto', margin: '6px 0 18px' }}>
|
||||
<textarea value={note} onChange={e => setNote(e.target.value)} rows={4} placeholder="توضیحات"
|
||||
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
|
||||
</div>
|
||||
|
||||
<button className="btn primary" disabled={!date || !start || !end || save.isPending} onClick={() => save.mutate()}>
|
||||
ثبت اطلاعات
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import { useAuthStore } from '../stores/authStore';
|
||||
import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown';
|
||||
import AppointmentActionsMenu from '../components/AppointmentActions';
|
||||
import NewAppointmentDrawer from '../components/NewAppointmentDrawer';
|
||||
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
|
||||
import type { AppointmentFilters } from '../components/AppointmentFiltersModal';
|
||||
import { AdjustmentsHorizontalIcon } from '@heroicons/react/24/outline';
|
||||
import PersianCalendar from '../components/ui/PersianCalendar';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
|
||||
@@ -549,6 +552,8 @@ export default function AppointmentsPage() {
|
||||
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
|
||||
const [bookingHint, setBookingHint] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<AppointmentFilters>(EMPTY_FILTERS);
|
||||
const qc = useQueryClient();
|
||||
|
||||
// ── Appointments query
|
||||
@@ -566,6 +571,8 @@ export default function AppointmentsPage() {
|
||||
queryFn: () => api.get(`${apptEndpoint}?${apptParams}`),
|
||||
});
|
||||
const appointments: Appointment[] = apptQuery.data?.data ?? EMPTY_ARR;
|
||||
const filteredAppointments = applyAppointmentFilters(appointments, filters);
|
||||
const filtersActive = filters !== EMPTY_FILTERS && JSON.stringify(filters) !== JSON.stringify(EMPTY_FILTERS);
|
||||
|
||||
// ── Clinic: load doctors from clinic profile (not derived from appointments)
|
||||
const clinicDoctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
|
||||
@@ -705,6 +712,20 @@ export default function AppointmentsPage() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Filters (Figma فیلترها) */}
|
||||
<button
|
||||
aria-label="فیلترها"
|
||||
className="btn sm"
|
||||
onClick={() => setFiltersOpen(true)}
|
||||
style={{
|
||||
border: `1px solid ${filtersActive ? 'var(--primary)' : 'var(--border)'}`,
|
||||
color: filtersActive ? 'var(--primary)' : 'var(--text-2)',
|
||||
background: 'var(--surface)',
|
||||
}}
|
||||
>
|
||||
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
|
||||
</button>
|
||||
|
||||
{/* View toggle */}
|
||||
<div style={{
|
||||
display: 'flex', background: 'var(--surface-2)', borderRadius: 'var(--r-sm)',
|
||||
@@ -759,7 +780,7 @@ export default function AppointmentsPage() {
|
||||
<div style={{ padding: 16 }}>
|
||||
{viewMode === 'table' ? (
|
||||
<TableView
|
||||
items={appointments}
|
||||
items={filteredAppointments}
|
||||
loading={apptQuery.isLoading}
|
||||
queryKey={apptQueryKey}
|
||||
showDoctor={showDoctorCol}
|
||||
@@ -807,6 +828,11 @@ export default function AppointmentsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Filters modal */}
|
||||
{filtersOpen && (
|
||||
<AppointmentFiltersModal value={filters} onApply={setFilters} onClose={() => setFiltersOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Rich create drawer (اضافه کردن نوبت جدید) */}
|
||||
{drawerOpen && (
|
||||
<NewAppointmentDrawer
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface Appointment {
|
||||
version: number;
|
||||
created_at: string;
|
||||
patient_uuid?: string;
|
||||
patient_national_code?: string | null;
|
||||
patient_gender?: string | null;
|
||||
is_reserve?: boolean;
|
||||
deposit_required?: boolean;
|
||||
deposit_amount_rials?: number | null;
|
||||
|
||||
@@ -154,6 +154,7 @@ class MyAppointmentsController extends BaseController
|
||||
->select(
|
||||
'DISTINCT a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt, a.version',
|
||||
'a.isReserve, a.depositRequired, a.depositAmountRials, a.note, a.patientName as override_name',
|
||||
'a.patientNationalCode as national_code, a.patientGender as gender',
|
||||
'd.uuid as doctor_uuid, d.name as doctor_name',
|
||||
'u.uuid as patient_uuid, u.mobileNumber as patient_mobile, u.realName as patient_name',
|
||||
'ss.uuid as section_uuid, ss.name as section_name',
|
||||
@@ -251,6 +252,8 @@ class MyAppointmentsController extends BaseController
|
||||
'version' => (int) $a['version'],
|
||||
'created_at' => date('c', (int) $a['createdAt']),
|
||||
'is_reserve' => (bool) $a['isReserve'],
|
||||
'patient_national_code' => $a['national_code'],
|
||||
'patient_gender' => $a['gender'],
|
||||
'deposit_required' => (bool) $a['depositRequired'],
|
||||
'deposit_amount_rials' => $a['depositAmountRials'] !== null ? (int) $a['depositAmountRials'] : null,
|
||||
'note' => $a['note'],
|
||||
|
||||
Reference in New Issue
Block a user