refactor(branch): remove the branch domain, keep the address
Branches and rooms are not part of the resource-first product: a room is a
resource like any other, and the only thing the branch pages still managed —
opening hours — duplicated the resource's own shift.
What could not go is the address. Every appointment carries address_id (75 of
75 rows), the public booking site reads /clinic-pro/doctor-address/{id}, and a
resource derives its tenant pair from the address it belongs to. So
DoctorAddress stays as an invisible anchor with no page and no menu entry, and
GET /api/v1/addresses replaces GET /api/v1/branches for the forms that still
need to say "where".
BranchResolver was likewise not a branch feature. doctor_addresses is a global
table, so TenantFilter does not cover it and eight callers across booking,
availability, pricing and the catalog went through this resolver to avoid
leaking another clinic's address. It moved to Doctor\Service\AddressResolver
rather than dying with the domain.
The availability engine loses one layer: a resource's real hours were the
branch hours intersected with its shift, and are now the shift alone. That is
the single behavioural change, and the three tests that asserted the old
contract are replaced by one that states the new one.
Rooms already had a resource row each; the migration drops only the bridge
back to `rooms`, and drops it before the table — that foreign key is ON DELETE
CASCADE and the other order would take the resources, and their appointments,
with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -74,11 +74,8 @@ import AppointmentSettingsPage from './pages/AppointmentSettingsPage';
|
||||
import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage';
|
||||
import PatientsListPage from './pages/PatientsListPage';
|
||||
import InventoryPage from './pages/InventoryPage';
|
||||
import BranchesPage from './pages/BranchesPage';
|
||||
import ResourceBookingPage from './pages/ResourceBookingPage';
|
||||
import PriceListsPage from './pages/PriceListsPage';
|
||||
import BranchWorkingHoursPage from './pages/BranchWorkingHoursPage';
|
||||
import BranchRoomsPage from './pages/BranchRoomsPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
import ResourceTypesPage from './pages/ResourceTypesPage';
|
||||
import CatalogCategoriesPage from './pages/CatalogCategoriesPage';
|
||||
@@ -294,9 +291,6 @@ export default function App() {
|
||||
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ServiceDetailPage /></RoleRoute>} />
|
||||
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['inventory', 'view']}><InventoryPage /></RoleRoute>} />
|
||||
<Route path="service-categories" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
|
||||
<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="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>} />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
import { useBranches } from '../hooks/useBranches';
|
||||
import { useAddresses } from '../hooks/useAddresses';
|
||||
|
||||
interface SegmentRequirementDraft {
|
||||
type_uuid: string;
|
||||
@@ -71,7 +71,7 @@ interface Props {
|
||||
*/
|
||||
export default function ServiceSegmentsTab({ serviceUuid, canEdit }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const { branches } = useBranches();
|
||||
const { addresses } = useAddresses();
|
||||
|
||||
const [segments, setSegments] = useState<SegmentDraft[]>([]);
|
||||
const [branchUuid, setBranchUuid] = useState('');
|
||||
@@ -387,7 +387,7 @@ export default function ServiceSegmentsTab({ serviceUuid, canEdit }: Props) {
|
||||
<SearchableSelect
|
||||
value={branchUuid}
|
||||
onChange={(v) => setBranchUuid(String(v ?? ''))}
|
||||
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
placeholder="انتخاب شعبه"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,6 @@ describe('SettingsLayout', () => {
|
||||
|
||||
expect(screen.getByText('منابع').closest('a')).toHaveAttribute('href', '/admin/resources');
|
||||
expect(screen.getByText('دستهبندیها').closest('a')).toHaveAttribute('href', '/admin/service-categories');
|
||||
expect(screen.getByText('شعبهها و اتاقها').closest('a')).toHaveAttribute('href', '/admin/branches');
|
||||
expect(screen.getByText('منابع').closest('a')).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
|
||||
BanknotesIcon, UsersIcon, ShieldCheckIcon,
|
||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon,
|
||||
MapPinIcon, CubeIcon, RectangleStackIcon,
|
||||
CubeIcon, RectangleStackIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,6 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
|
||||
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] },
|
||||
{ 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: 'service-categories', label: 'دستهبندیها', icon: RectangleStackIcon, to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
|
||||
@@ -12,11 +12,9 @@ const REASON_LABELS: Record<string, string> = {
|
||||
national_holiday: 'تعطیل رسمی',
|
||||
tenant_holiday: 'تعطیلی این محیط',
|
||||
no_shift: 'شیفتی تعریف نشده',
|
||||
branch_closed: 'شعبه این روز بسته است',
|
||||
outside_branch_hours: 'شیفت بیرون از ساعت کاری شعبه',
|
||||
exception: 'مرخصی یا سرویس',
|
||||
resource_inactive: 'منبع غیرفعال است',
|
||||
branch_inactive: 'شعبه غیرفعال است',
|
||||
address_inactive: 'محل نوبتدهی غیرفعال است',
|
||||
};
|
||||
|
||||
const EXCEPTION_TYPES = [
|
||||
|
||||
@@ -28,7 +28,7 @@ const resource = {
|
||||
const props = {
|
||||
open: true,
|
||||
resource: resource as never,
|
||||
branches: [{ uuid: 'b-1', name: 'شعبه' }] as never,
|
||||
addresses: [{ uuid: 'b-1', name: 'محل نوبتدهی' }] as never,
|
||||
types: [{ uuid: 't-1', name: 'دستگاه', code: 'device' }] as never,
|
||||
saving: false,
|
||||
onClose: () => {},
|
||||
|
||||
@@ -13,7 +13,7 @@ type AttributeRow = { key: string; value: string };
|
||||
interface Props {
|
||||
open: boolean;
|
||||
resource: ClinicResource | null;
|
||||
branches: Branch[];
|
||||
addresses: Branch[];
|
||||
types: ResourceType[];
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
@@ -21,7 +21,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function ResourceFormModal({
|
||||
open, resource, branches, types, saving, onClose, onSave,
|
||||
open, resource, addresses, types, saving, onClose, onSave,
|
||||
}: Props) {
|
||||
// شمار نوبتهای آینده فقط برای منبعِ موجود معنا دارد و فقط وقتی مودال باز است.
|
||||
const { upcomingAppointments: upcoming } = useResourceDetail(open ? resource?.uuid : undefined);
|
||||
@@ -91,7 +91,7 @@ export default function ResourceFormModal({
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
|
||||
<Field label="شعبه">
|
||||
<SearchableSelect
|
||||
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
value={addressUuid}
|
||||
onChange={(v) => setAddressUuid(v ? String(v) : null)}
|
||||
placeholder="شعبه را انتخاب کنید"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api, type ApiResponse } from '../lib/api';
|
||||
import type { Branch } from '../types';
|
||||
|
||||
/**
|
||||
* محلهای نوبتدهی محیط جاری — همان `doctor_addresses`.
|
||||
*
|
||||
* جانشین `useBranches` است. مفهوم «شعبه» از محصول حذف شد، ولی فرم منبع، لیست قیمت و
|
||||
* استخر منبع هنوز باید بگویند «کجا»، پس فهرست آدرسها فقط برای انتخاب میماند. ساخت و
|
||||
* ویرایش آدرس همانجایی است که همیشه بود (جزئیات کلینیک/پزشک).
|
||||
*/
|
||||
export function useAddresses() {
|
||||
const query = useQuery({
|
||||
queryKey: ['addresses'],
|
||||
queryFn: () => api.get<ApiResponse<Branch[]>>('/api/v1/addresses'),
|
||||
});
|
||||
|
||||
// پاسخِ غیرآرایه (خطای سرور، شکل دیگر) نباید صفحه را با «map is not a function»
|
||||
// بترکاند؛ فهرست خالی رفتار درست است.
|
||||
const addresses = Array.isArray(query.data?.data) ? query.data.data : [];
|
||||
|
||||
return { addresses, loading: query.isLoading };
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
import type { Branch, BranchWorkingHours, Room, RoomPayload, WorkingHoursPayload } from '../types';
|
||||
|
||||
/**
|
||||
* «شعبه» یک جدول تازه نیست — همان آدرس محل نوبتدهی است (`doctor_addresses`).
|
||||
* ساخت/ویرایش نام و آدرس همانجایی انجام میشود که همیشه (جزئیات کلینیک/پزشک)؛
|
||||
* این هوک فقط چیزهای شعبهای را میدهد: فعال/غیرفعال، منطقهٔ زمانی، ساعت کاری، اتاق.
|
||||
*/
|
||||
const BRANCHES_KEY = ['branches'];
|
||||
|
||||
function fail(e: unknown, fallback: string) {
|
||||
toast.error(e instanceof ApiError ? e.message : fallback);
|
||||
}
|
||||
|
||||
export function useBranches() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: BRANCHES_KEY,
|
||||
queryFn: () => api.get<ApiResponse<Branch[]>>('/api/v1/branches'),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ uuid, d }: { uuid: string; d: { active?: boolean; timezone?: string } }) =>
|
||||
api.patch<ApiResponse<Branch>>(`/api/v1/branch/${uuid}`, d),
|
||||
onSuccess: () => {
|
||||
toast.success('شعبه بهروزرسانی شد');
|
||||
qc.invalidateQueries({ queryKey: BRANCHES_KEY });
|
||||
},
|
||||
onError: (e) => fail(e, 'بهروزرسانی شعبه ناموفق بود'),
|
||||
});
|
||||
|
||||
// پاسخِ غیرآرایه (خطای سرور، شکل دیگر) نباید صفحه را با «map is not a function»
|
||||
// بترکاند؛ فهرست خالی رفتار درست است.
|
||||
const branches = Array.isArray(query.data?.data) ? query.data.data : [];
|
||||
|
||||
return { branches, loading: query.isLoading, update };
|
||||
}
|
||||
|
||||
export function useBranchWorkingHours(branchUuid: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
const key = ['branch-working-hours', branchUuid];
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => api.get<ApiResponse<BranchWorkingHours>>(`/api/v1/branch/${branchUuid}/working-hours`),
|
||||
enabled: !!branchUuid,
|
||||
});
|
||||
|
||||
/** PUT قرارداد جایگزینی کامل دارد: آرایهٔ خالی یعنی شعبه بسته، نه «تغییری نده». */
|
||||
const save = useMutation({
|
||||
mutationFn: (days: WorkingHoursPayload) =>
|
||||
api.put<ApiResponse<BranchWorkingHours>>(`/api/v1/branch/${branchUuid}/working-hours`, { days }),
|
||||
onSuccess: () => {
|
||||
toast.success('ساعت کاری ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: key });
|
||||
qc.invalidateQueries({ queryKey: BRANCHES_KEY });
|
||||
},
|
||||
onError: (e) => fail(e, 'ذخیرهٔ ساعت کاری ناموفق بود'),
|
||||
});
|
||||
|
||||
return { workingHours: query.data?.data, loading: query.isLoading, save };
|
||||
}
|
||||
|
||||
export function useBranchRooms(branchUuid: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
const key = ['branch-rooms', branchUuid];
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: key });
|
||||
qc.invalidateQueries({ queryKey: BRANCHES_KEY });
|
||||
};
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => api.get<ApiResponse<Room[]>>(`/api/v1/branch/${branchUuid}/rooms`),
|
||||
enabled: !!branchUuid,
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (d: RoomPayload) =>
|
||||
api.post<ApiResponse<Room>>('/api/v1/room', { ...d, address_uuid: branchUuid }),
|
||||
onSuccess: () => { toast.success('اتاق افزوده شد'); invalidate(); },
|
||||
onError: (e) => fail(e, 'افزودن اتاق ناموفق بود'),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ uuid, d }: { uuid: string; d: RoomPayload }) =>
|
||||
api.patch<ApiResponse<Room>>(`/api/v1/room/${uuid}`, d),
|
||||
onSuccess: () => { toast.success('اتاق بهروزرسانی شد'); invalidate(); },
|
||||
onError: (e) => fail(e, 'بهروزرسانی اتاق ناموفق بود'),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/room/${uuid}`),
|
||||
onSuccess: () => { toast.success('اتاق حذف شد'); invalidate(); },
|
||||
onError: (e) => fail(e, 'حذف اتاق ناموفق بود'),
|
||||
});
|
||||
|
||||
return { rooms: query.data?.data ?? [], loading: query.isLoading, create, update, remove };
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useBranchRooms, useBranches } from '../hooks/useBranches';
|
||||
import type { Room, RoomPayload } from '../types';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
|
||||
/** اتاقهای یک شعبه. ظرفیت = چند بیمار همزمان، نه چند اتاق. */
|
||||
export default function BranchRoomsPage() {
|
||||
const { branchUuid } = useParams<{ branchUuid: string }>();
|
||||
const { rooms, loading, create, update, remove } = useBranchRooms(branchUuid);
|
||||
const { branches } = useBranches();
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('appointment_settings', 'update');
|
||||
|
||||
const branch = branches.find((b) => b.uuid === branchUuid);
|
||||
|
||||
const [urlState, setUrlState] = useUrlState({ search: '' });
|
||||
const [editing, setEditing] = useState<{ open: boolean; room: Room | null }>({ open: false, room: null });
|
||||
const [toDelete, setToDelete] = useState<Room | null>(null);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = urlState.search.trim();
|
||||
return q === '' ? rooms : rooms.filter((r) => `${r.name} ${r.room_type ?? ''}`.includes(q));
|
||||
}, [rooms, urlState.search]);
|
||||
|
||||
const columns: Column<Room>[] = [
|
||||
{ key: 'name', header: 'نام اتاق', render: (r) => <span style={{ fontWeight: 600 }}>{r.name}</span> },
|
||||
{ key: 'room_type', header: 'نوع', render: (r) => <span style={{ fontSize: 13 }}>{r.room_type || '—'}</span> },
|
||||
{
|
||||
key: 'capacity',
|
||||
header: 'ظرفیت همزمان',
|
||||
render: (r) => <span style={{ fontSize: 13 }}>{r.capacity} نفر</span>,
|
||||
},
|
||||
{ key: 'floor', header: 'طبقه', render: (r) => <span style={{ fontSize: 13 }}>{r.floor || '—'}</span> },
|
||||
{ key: 'active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.active} /> },
|
||||
];
|
||||
|
||||
const save = (payload: RoomPayload) => {
|
||||
const opts = { onSuccess: () => setEditing({ open: false, room: null }) };
|
||||
if (editing.room) update.mutate({ uuid: editing.room.uuid, d: payload }, opts);
|
||||
else create.mutate(payload, opts);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout active="branches">
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={`اتاقهای ${branch?.name ?? 'شعبه'}`}
|
||||
description="ظرفیت هر اتاق تعداد بیمارِ همزمان است — اتاق تزریق سهتخته یک اتاق با ظرفیت ۳ است، نه سه اتاق."
|
||||
backTo="/admin/branches"
|
||||
breadcrumbs={[{ label: 'شعبهها', to: '/admin/branches' }, { label: 'اتاقها' }]}
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, room: null })}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن اتاق
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در اتاقها..."
|
||||
emptyMessage="هنوز اتاقی برای این شعبه ثبت نشده است"
|
||||
actions={
|
||||
canUpdate
|
||||
? (r) => (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, room: r })}>
|
||||
ویرایش
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setToDelete(r)}>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<RoomModal
|
||||
open={editing.open}
|
||||
room={editing.room}
|
||||
saving={create.isPending || update.isPending}
|
||||
onClose={() => setEditing({ open: false, room: null })}
|
||||
onSave={save}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!toDelete}
|
||||
title="حذف اتاق"
|
||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
|
||||
confirmLabel="حذف"
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomModal({
|
||||
open, room, saving, onClose, onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
room: Room | null;
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (payload: RoomPayload) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [roomType, setRoomType] = useState('');
|
||||
const [capacity, setCapacity] = useState('1');
|
||||
const [floor, setFloor] = useState('');
|
||||
const [active, setActive] = useState(true);
|
||||
|
||||
// فرم با هر بازشدن از روی اتاقِ هدف بازنشانی میشود؛ key در والد باعث remount
|
||||
// نمیشود چون Modal همیشه mounted است.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(room?.name ?? '');
|
||||
setRoomType(room?.room_type ?? '');
|
||||
setCapacity(String(room?.capacity ?? 1));
|
||||
setFloor(room?.floor ?? '');
|
||||
setActive(room?.active ?? true);
|
||||
}, [open, room]);
|
||||
|
||||
const parsedCapacity = Number(capacity);
|
||||
const invalid = name.trim() === '' || !Number.isFinite(parsedCapacity) || parsedCapacity < 1;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={room ? 'ویرایش اتاق' : 'افزودن اتاق'}>
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
<Field label="نام اتاق">
|
||||
<input className="field" value={name} onChange={(e) => setName(e.target.value)} placeholder="اتاق تزریق" />
|
||||
</Field>
|
||||
<Field label="نوع اتاق (اختیاری)">
|
||||
<input className="field" value={roomType} onChange={(e) => setRoomType(e.target.value)} placeholder="تزریقات" />
|
||||
</Field>
|
||||
<Field label="ظرفیت همزمان">
|
||||
<input
|
||||
className="field"
|
||||
type="number"
|
||||
min={1}
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="طبقه (اختیاری)">
|
||||
<input className="field" value={floor} onChange={(e) => setFloor(e.target.value)} placeholder="۲" />
|
||||
</Field>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
|
||||
اتاق فعال است
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
|
||||
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={saving || invalid}
|
||||
onClick={() => onSave({
|
||||
name: name.trim(),
|
||||
room_type: roomType.trim() === '' ? null : roomType.trim(),
|
||||
capacity: parsedCapacity,
|
||||
floor: floor.trim() === '' ? null : floor.trim(),
|
||||
active,
|
||||
})}
|
||||
>
|
||||
{saving ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { api } from '../lib/api';
|
||||
import BranchWorkingHoursPage from './BranchWorkingHoursPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const put = api.put as ReturnType<typeof vi.fn>;
|
||||
|
||||
const branch = {
|
||||
id: '1', uuid: 'b1', type: 'clinic', clinic_id: 3, clinic_name: 'کلینیک ما',
|
||||
name: 'شعبهٔ مرکزی', map: { latitude: null, longitude: null },
|
||||
address: 'خیابان اول', telephone: '03511111111', active: true,
|
||||
timezone: 'Asia/Tehran', city: null, province: null,
|
||||
working_hours_defined: true, rooms_count: 0,
|
||||
};
|
||||
|
||||
function emptyDays(): Record<string, unknown[]> {
|
||||
return Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), []]));
|
||||
}
|
||||
|
||||
function mockApi(days: Record<string, unknown[]>) {
|
||||
get.mockImplementation((path: string) => {
|
||||
if (path === '/api/v1/branches') return Promise.resolve({ success: true, data: [branch] });
|
||||
if (path.endsWith('/working-hours')) {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: { branch_uuid: 'b1', timezone: 'Asia/Tehran', defined: true, days },
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ success: true, data: null });
|
||||
});
|
||||
put.mockResolvedValue({ success: true, data: { branch_uuid: 'b1', timezone: 'Asia/Tehran', defined: true, days } });
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/admin/branches/:branchUuid/working-hours" element={<BranchWorkingHoursPage />} />
|
||||
</Routes>,
|
||||
{ route: '/admin/branches/b1/working-hours' },
|
||||
);
|
||||
}
|
||||
|
||||
describe('BranchWorkingHoursPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders all seven days and marks the empty ones closed', async () => {
|
||||
mockApi(emptyDays());
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
|
||||
expect(screen.getByText('جمعه')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('بسته')).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('shows stored ranges as times, converting minutes from midnight', async () => {
|
||||
const days = emptyDays();
|
||||
days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 780, start_time: '09:00', end_time: '13:00', active: true }];
|
||||
mockApi(days);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue('13:00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/**
|
||||
* `<input type="time">` سقفش ۲۳:۵۹ است، پس ۱۴۴۰ با پرچم «تا پایان روز» نمایش داده
|
||||
* میشود و همان ۱۴۴۰ برمیگردد — وگرنه اولین ذخیره بازهٔ شبانهروزی را خراب میکرد.
|
||||
*/
|
||||
it('keeps an all-day range at 1440 through a round trip', async () => {
|
||||
const days = emptyDays();
|
||||
days['3'] = [{ sequence: 0, start_minute: 0, end_minute: 1440, start_time: '00:00', end_time: '24:00', active: true }];
|
||||
mockApi(days);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('۲۴:۰۰')).toBeInTheDocument());
|
||||
expect((screen.getByLabelText('تا پایان روز') as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByText('ذخیرهٔ هفته'));
|
||||
|
||||
await waitFor(() => expect(put).toHaveBeenCalled());
|
||||
expect(put.mock.calls[0][1].days['3']).toEqual([{ start_minute: 0, end_minute: 1440 }]);
|
||||
});
|
||||
|
||||
it('turns a normal range into an all-day one when the flag is checked', async () => {
|
||||
const days = emptyDays();
|
||||
days['6'] = [{ sequence: 0, start_minute: 540, end_minute: 660, start_time: '09:00', end_time: '11:00', active: true }];
|
||||
mockApi(days);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('11:00')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByLabelText('تا پایان روز'));
|
||||
fireEvent.click(screen.getByText('ذخیرهٔ هفته'));
|
||||
|
||||
await waitFor(() => expect(put).toHaveBeenCalled());
|
||||
expect(put.mock.calls[0][1].days['6']).toEqual([{ start_minute: 540, end_minute: 1440 }]);
|
||||
});
|
||||
|
||||
it('sends minutes, not time strings, on save', async () => {
|
||||
const days = emptyDays();
|
||||
days['1'] = [{ sequence: 0, start_minute: 600, end_minute: 720, start_time: '10:00', end_time: '12:00', active: true }];
|
||||
mockApi(days);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('10:00')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('ذخیرهٔ هفته'));
|
||||
|
||||
await waitFor(() => expect(put).toHaveBeenCalled());
|
||||
const [path, body] = put.mock.calls[0];
|
||||
expect(path).toBe('/api/v1/branch/b1/working-hours');
|
||||
expect(body.days['1']).toEqual([{ start_minute: 600, end_minute: 720 }]);
|
||||
// هر هفت روز فرستاده میشود، چون PUT جایگزینی کامل است نه merge تفاضلی.
|
||||
expect(Object.keys(body.days)).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('blocks a save whose end is not after its start, without calling the API', async () => {
|
||||
const days = emptyDays();
|
||||
days['2'] = [{ sequence: 0, start_minute: 600, end_minute: 720, start_time: '10:00', end_time: '12:00', active: true }];
|
||||
mockApi(days);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('12:00')).toBeInTheDocument());
|
||||
fireEvent.change(screen.getByDisplayValue('12:00'), { target: { value: '09:00' } });
|
||||
fireEvent.click(screen.getByText('ذخیرهٔ هفته'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/پایان بازه باید بعد از شروع/)).toBeInTheDocument());
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('copies one day onto the whole week', async () => {
|
||||
const days = emptyDays();
|
||||
days['0'] = [{ sequence: 0, start_minute: 480, end_minute: 600, start_time: '08:00', end_time: '10:00', active: true }];
|
||||
mockApi(days);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('08:00')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('اعمال روی همهٔ روزها'));
|
||||
|
||||
expect(screen.getAllByDisplayValue('08:00')).toHaveLength(7);
|
||||
expect(screen.queryByText('بسته')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes a range so the day becomes closed', async () => {
|
||||
const days = emptyDays();
|
||||
days['4'] = [{ sequence: 0, start_minute: 540, end_minute: 660, start_time: '09:00', end_time: '11:00', active: true }];
|
||||
mockApi(days);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('11:00')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByLabelText('حذف بازه'));
|
||||
|
||||
expect(screen.getAllByText('بسته')).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
@@ -1,265 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useBranchWorkingHours, useBranches } from '../hooks/useBranches';
|
||||
import type { WorkingHourRange } from '../types';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
|
||||
/** ۰ = شنبه — همان قرارداد محاسبهٔ اسلات در بکاند. */
|
||||
const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
|
||||
|
||||
const MINUTES_IN_DAY = 1440;
|
||||
|
||||
/**
|
||||
* `endOfDay` وجود دارد چون `<input type="time">` سقفش ۲۳:۵۹ است و مقدار ۲۴:۰۰ را
|
||||
* نه نشان میدهد و نه میسازد. بدون این پرچم، بازهٔ شبانهروزیِ ذخیرهشده (۱۴۴۰)
|
||||
* بیصدا از فرم میافتاد و اولین ذخیره آن را خراب میکرد.
|
||||
*/
|
||||
type Draft = { start: string; end: string; endOfDay: boolean };
|
||||
|
||||
function toTime(minute: number): string {
|
||||
return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** `"24:00"` باید ۱۴۴۰ بدهد نه صفر — پایان روز است، نه آغازش. */
|
||||
function toMinutes(time: string): number | null {
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
|
||||
if (!m) return null;
|
||||
const minutes = Number(m[1]) * 60 + Number(m[2]);
|
||||
return minutes >= 0 && minutes <= MINUTES_IN_DAY ? minutes : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ساعت کاری هفتگی یک شعبه.
|
||||
*
|
||||
* ذخیره یک PUT است و کل هفته را جایگزین میکند؛ روزِ خالی یعنی شعبه آن روز بسته
|
||||
* است. اعتبارسنجی نهایی سمت سرور است — این فرم فقط جلوی ارسال ورودی واضحاً خراب
|
||||
* را میگیرد تا کاربر منتظر رفتوبرگشت نماند.
|
||||
*/
|
||||
export default function BranchWorkingHoursPage() {
|
||||
const { branchUuid } = useParams<{ branchUuid: string }>();
|
||||
const { workingHours, loading, save } = useBranchWorkingHours(branchUuid);
|
||||
const { branches } = useBranches();
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('appointment_settings', 'update');
|
||||
|
||||
const branch = branches.find((b) => b.uuid === branchUuid);
|
||||
|
||||
const [draft, setDraft] = useState<Record<number, Draft[]>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workingHours) return;
|
||||
const next: Record<number, Draft[]> = {};
|
||||
DAY_LABELS.forEach((_, day) => {
|
||||
next[day] = (workingHours.days[String(day)] ?? []).map((r: WorkingHourRange) => ({
|
||||
start: toTime(r.start_minute),
|
||||
end: r.end_minute === MINUTES_IN_DAY ? '23:59' : toTime(r.end_minute),
|
||||
endOfDay: r.end_minute === MINUTES_IN_DAY,
|
||||
}));
|
||||
});
|
||||
setDraft(next);
|
||||
}, [workingHours]);
|
||||
|
||||
const addRange = (day: number) => {
|
||||
setDraft((d) => ({ ...d, [day]: [...(d[day] ?? []), { start: '09:00', end: '13:00', endOfDay: false }] }));
|
||||
};
|
||||
|
||||
const removeRange = (day: number, index: number) => {
|
||||
setDraft((d) => ({ ...d, [day]: (d[day] ?? []).filter((_, i) => i !== index) }));
|
||||
};
|
||||
|
||||
const editRange = (day: number, index: number, patch: Partial<Draft>) => {
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
[day]: (d[day] ?? []).map((r, i) => (i === index ? { ...r, ...patch } : r)),
|
||||
}));
|
||||
};
|
||||
|
||||
const copyToWholeWeek = (day: number) => {
|
||||
const source = draft[day] ?? [];
|
||||
const next: Record<number, Draft[]> = {};
|
||||
DAY_LABELS.forEach((_, d) => { next[d] = source.map((r) => ({ ...r })); });
|
||||
setDraft(next);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const days: Record<string, { start_minute: number; end_minute: number }[]> = {};
|
||||
|
||||
for (const [dayKey, ranges] of Object.entries(draft)) {
|
||||
const parsed: { start_minute: number; end_minute: number }[] = [];
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = toMinutes(range.start);
|
||||
const end = range.endOfDay ? MINUTES_IN_DAY : toMinutes(range.end);
|
||||
|
||||
if (start === null || end === null) {
|
||||
setError(`ساعت روز ${DAY_LABELS[Number(dayKey)]} را به شکل ۰۹:۰۰ وارد کنید`);
|
||||
return;
|
||||
}
|
||||
if (end <= start) {
|
||||
setError(`در روز ${DAY_LABELS[Number(dayKey)]} پایان بازه باید بعد از شروع آن باشد`);
|
||||
return;
|
||||
}
|
||||
parsed.push({ start_minute: start, end_minute: end });
|
||||
}
|
||||
|
||||
days[dayKey] = parsed;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
save.mutate(days);
|
||||
};
|
||||
|
||||
const totalRanges = Object.values(draft).reduce((sum, ranges) => sum + ranges.length, 0);
|
||||
|
||||
return (
|
||||
<SettingsLayout active="branches">
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={`ساعت کاری ${branch?.name ?? 'شعبه'}`}
|
||||
description="روز بدون بازه یعنی شعبه آن روز بسته است. شعبهٔ بدون هیچ ساعتی «تعریفنشده» است، نه همیشهباز."
|
||||
backTo="/admin/branches"
|
||||
breadcrumbs={[
|
||||
{ label: 'شعبهها', to: '/admin/branches' },
|
||||
{ label: 'ساعت کاری' },
|
||||
]}
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" disabled={save.isPending} onClick={submit}>
|
||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ هفته'}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="card"
|
||||
style={{ padding: '12px 16px', marginBottom: 16, color: 'var(--danger)', background: 'var(--danger-bg)', fontSize: 13 }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{branch && canUpdate && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>منطقهٔ زمانی شعبه</span>
|
||||
<div style={{ minWidth: 240 }}>
|
||||
<TimezoneSelect branchUuid={branch.uuid} value={branch.timezone} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
{totalRanges === 0 ? 'هیچ بازهای تعریف نشده' : `${totalRanges} بازه در هفته`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{DAY_LABELS.map((label, day) => {
|
||||
const ranges = draft[day] ?? [];
|
||||
return (
|
||||
<div key={day} className="card" style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: ranges.length ? 12 : 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14 }}>{label}</span>
|
||||
{ranges.length === 0 && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>بسته</span>
|
||||
)}
|
||||
</div>
|
||||
{canUpdate && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{ranges.length > 0 && (
|
||||
<button type="button" className="btn secondary sm" onClick={() => copyToWholeWeek(day)}>
|
||||
اعمال روی همهٔ روزها
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn secondary sm" onClick={() => addRange(day)}>
|
||||
<PlusIcon style={{ width: 15 }} /> بازه
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{ranges.map((range, index) => (
|
||||
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-3)' }}>از</label>
|
||||
<input
|
||||
type="time"
|
||||
className="field"
|
||||
value={range.start}
|
||||
disabled={!canUpdate}
|
||||
onChange={(e) => editRange(day, index, { start: e.target.value })}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-3)' }}>تا</label>
|
||||
{range.endOfDay ? (
|
||||
<span className="field" style={{ width: 120, color: 'var(--text-2)' }}>۲۴:۰۰</span>
|
||||
) : (
|
||||
<input
|
||||
type="time"
|
||||
className="field"
|
||||
value={range.end}
|
||||
disabled={!canUpdate}
|
||||
onChange={(e) => editRange(day, index, { end: e.target.value })}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
)}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'var(--text-3)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={range.endOfDay}
|
||||
disabled={!canUpdate}
|
||||
onChange={(e) => editRange(day, index, { endOfDay: e.target.checked })}
|
||||
/>
|
||||
تا پایان روز
|
||||
</label>
|
||||
{canUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() => removeRange(day, index)}
|
||||
aria-label="حذف بازه"
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* فهرست منطقهٔ زمانی کوتاه و ثابت است — بکاند با `DateTimeZone::listIdentifiers()`
|
||||
* اعتبارسنجی میکند، پس این فهرست تنها راحتی است و منبع حقیقت نیست.
|
||||
*/
|
||||
const TIMEZONES = ['Asia/Tehran', 'Asia/Dubai', 'Asia/Baghdad', 'Europe/Istanbul', 'UTC'];
|
||||
|
||||
function TimezoneSelect({ branchUuid, value }: { branchUuid: string; value: string }) {
|
||||
const { update } = useBranches();
|
||||
const options = TIMEZONES.includes(value) ? TIMEZONES : [value, ...TIMEZONES];
|
||||
|
||||
return (
|
||||
<SearchableSelect
|
||||
options={options.map((tz) => ({ value: tz, label: tz }))}
|
||||
value={value}
|
||||
onChange={(v) => v && update.mutate({ uuid: branchUuid, d: { timezone: String(v) } })}
|
||||
placeholder="منطقهٔ زمانی"
|
||||
height={38}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ClockIcon, Squares2X2Icon } from '@heroicons/react/24/outline';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useBranches } from '../hooks/useBranches';
|
||||
import type { Branch } from '../types';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
|
||||
/**
|
||||
* شعبهها — همان محلهای نوبتدهی محیط جاری.
|
||||
*
|
||||
* این صفحه شعبه نمیسازد و نام/آدرس را ویرایش نمیکند؛ آن کار از قبل در جزئیات
|
||||
* کلینیک و پزشک هست و تکرارش دو منبع حقیقت میساخت. اینجا فقط دروازهٔ ساعت کاری و
|
||||
* اتاقهاست، بهعلاوهٔ دو ویژگی شعبهای: فعالبودن و منطقهٔ زمانی.
|
||||
*/
|
||||
export default function BranchesPage() {
|
||||
const { branches, loading, update } = useBranches();
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('appointment_settings', 'update');
|
||||
|
||||
const [urlState, setUrlState] = useUrlState({ search: '', status: '' });
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = urlState.search.trim();
|
||||
return branches.filter((b) => {
|
||||
const haystack = `${b.name ?? ''} ${b.address ?? ''} ${b.telephone ?? ''}`;
|
||||
const matchesQuery = q === '' || haystack.includes(q);
|
||||
const matchesStatus =
|
||||
urlState.status === '' ||
|
||||
(urlState.status === 'active' ? b.active : !b.active);
|
||||
return matchesQuery && matchesStatus;
|
||||
});
|
||||
}, [branches, urlState.search, urlState.status]);
|
||||
|
||||
const activeCount = branches.filter((b) => b.active).length;
|
||||
|
||||
const columns: Column<Branch>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'شعبه',
|
||||
render: (b) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span style={{ fontWeight: 600 }}>{b.name || 'بدون نام'}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
{b.type === 'clinic' ? b.clinic_name || 'کلینیک' : 'مطب شخصی'}
|
||||
{b.city ? ` · ${b.city.name}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'address',
|
||||
header: 'آدرس',
|
||||
render: (b) => (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>{b.address || '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'telephone',
|
||||
header: 'تلفن',
|
||||
render: (b) => <span style={{ fontSize: 13 }}>{b.telephone || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'working_hours',
|
||||
header: 'ساعت کاری',
|
||||
render: (b) =>
|
||||
b.working_hours_defined ? (
|
||||
<span className="badge green"><span className="bdot" />تعریفشده</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>تعریفنشده</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rooms_count',
|
||||
header: 'اتاق فعال',
|
||||
render: (b) => <span style={{ fontSize: 13 }}>{b.rooms_count ?? 0}</span>,
|
||||
},
|
||||
{
|
||||
key: 'timezone',
|
||||
header: 'منطقهٔ زمانی',
|
||||
render: (b) => <span style={{ fontSize: 12, color: 'var(--text-2)' }}>{b.timezone}</span>,
|
||||
},
|
||||
{
|
||||
key: 'active',
|
||||
header: 'وضعیت',
|
||||
render: (b) => <ActiveBadge active={b.active} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsLayout active="branches">
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="شعبهها و اتاقها"
|
||||
description="ساعت کاری هر محل نوبتدهی و اتاقهای آن. نام و آدرس شعبه در صفحهٔ همان کلینیک یا پزشک ویرایش میشود."
|
||||
backTo="/admin/settings-menu"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در شعبهها..."
|
||||
emptyMessage="هیچ شعبهای برای این محیط ثبت نشده است"
|
||||
headerExtra={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginRight: 'auto' }}>
|
||||
<StatusFilter
|
||||
value={urlState.status}
|
||||
onChange={(v) => setUrlState({ status: v })}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={(b) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Link className="btn secondary sm" to={`/admin/branches/${b.uuid}/working-hours`}>
|
||||
<ClockIcon style={{ width: 15 }} /> ساعت کاری
|
||||
</Link>
|
||||
<Link className="btn secondary sm" to={`/admin/branches/${b.uuid}/rooms`}>
|
||||
<Squares2X2Icon style={{ width: 15 }} /> اتاقها
|
||||
</Link>
|
||||
{canUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={update.isPending}
|
||||
title={
|
||||
b.active && activeCount === 1
|
||||
? 'با غیرفعال کردن این شعبه، هیچ شعبهٔ فعالی باقی نمیماند'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
b.active &&
|
||||
activeCount === 1 &&
|
||||
!window.confirm('این تنها شعبهٔ فعال است. با غیرفعال کردن آن، هیچ شعبهٔ فعالی باقی نمیماند. ادامه میدهید؟')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
update.mutate({ uuid: b.uuid, d: { active: !b.active } });
|
||||
}}
|
||||
>
|
||||
{b.active ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusFilter({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const options = [
|
||||
{ value: '', label: 'همه' },
|
||||
{ value: 'active', label: 'فعال' },
|
||||
{ value: 'inactive', label: 'غیرفعال' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={`btn sm ${value === o.value ? 'primary' : 'secondary'}`}
|
||||
onClick={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import { formatDate, isoToUnix, unixToIso } from '../lib/utils';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useBranches } from '../hooks/useBranches';
|
||||
import { useAddresses } from '../hooks/useAddresses';
|
||||
import { usePriceLists, type PriceList, type PriceListItem } from '../hooks/usePriceLists';
|
||||
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
@@ -38,7 +38,7 @@ function emptyDraft(): Draft {
|
||||
*/
|
||||
export default function PriceListsPage() {
|
||||
const { lists, loading, create, update, setItems, activate, remove } = usePriceLists();
|
||||
const { branches } = useBranches();
|
||||
const { addresses } = useAddresses();
|
||||
const { items: services } = useAllServiceItems();
|
||||
const { can } = usePermissions();
|
||||
const canManage = can('appointment_settings', 'update');
|
||||
@@ -258,7 +258,7 @@ export default function PriceListsPage() {
|
||||
onChange={(v) => setDraft({ ...draft, address_uuid: v ? String(v) : null })}
|
||||
options={[
|
||||
{ value: '', label: 'همهٔ شعبهها' },
|
||||
...branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' })),
|
||||
...addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' })),
|
||||
]}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import PageHeader from '../components/ui/PageHeader';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import HoldCountdown from '../components/HoldCountdown';
|
||||
import { formatDate, formatNumber } from '../lib/utils';
|
||||
import { useBranches } from '../hooks/useBranches';
|
||||
import { useAddresses } from '../hooks/useAddresses';
|
||||
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
||||
import {
|
||||
REASON_LABELS,
|
||||
@@ -32,7 +32,7 @@ function timeOf(ts: number): string {
|
||||
export default function ResourceBookingPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const { branches } = useBranches();
|
||||
const { addresses } = useAddresses();
|
||||
const { items: services } = useAllServiceItems();
|
||||
const { create, release, confirm, rebook } = useHold();
|
||||
|
||||
@@ -162,7 +162,7 @@ export default function ResourceBookingPage() {
|
||||
setBranchUuid(String(v ?? ''));
|
||||
setPickedSlot(null);
|
||||
}}
|
||||
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
placeholder="انتخاب شعبه"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -114,15 +114,15 @@ describe('ResourceDetailPage', () => {
|
||||
it('دلیل خالی بودن روز را فارسی میکند', async () => {
|
||||
mockApi(emptyDays(), [
|
||||
{ date: 1785529800, day_of_week: 0, intervals: [], total_minutes: 0, reasons: ['national_holiday'] },
|
||||
{ date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['outside_branch_hours'] },
|
||||
{ date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['no_shift'] },
|
||||
{ date: 1785702600, day_of_week: 2, intervals: [], total_minutes: 0, reasons: ['exception'] },
|
||||
]);
|
||||
renderPage('/admin/resources/r1?tab=exceptions');
|
||||
|
||||
await waitFor(() => expect(screen.getByText('تعطیل رسمی')).toBeInTheDocument());
|
||||
expect(screen.getByText('شیفت بیرون از ساعت کاری شعبه')).toBeInTheDocument();
|
||||
expect(screen.getByText('شیفتی تعریف نشده')).toBeInTheDocument();
|
||||
expect(screen.getByText('مرخصی یا سرویس')).toBeInTheDocument();
|
||||
expect(screen.queryByText('outside_branch_hours')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('no_shift')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** پیشنمایش نباید «وقت قابل رزرو» خوانده شود — نوبتها هنوز کسر نشدهاند. */
|
||||
|
||||
@@ -12,7 +12,7 @@ import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesP
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useBranches } from '../hooks/useBranches';
|
||||
import { useAddresses } from '../hooks/useAddresses';
|
||||
import { useResourceDetail, useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
|
||||
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
||||
import type { ClinicResource } from '../types';
|
||||
@@ -46,7 +46,7 @@ export default function ResourceDetailPage() {
|
||||
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId;
|
||||
|
||||
const { resource, loading } = useResourceDetail(resourceUuid);
|
||||
const { branches } = useBranches();
|
||||
const { addresses } = useAddresses();
|
||||
const { types } = useResourceTypes();
|
||||
const { skills } = useSkills();
|
||||
const { update, setSkills, setCategories } = useResources();
|
||||
@@ -144,7 +144,7 @@ export default function ResourceDetailPage() {
|
||||
<ResourceFormModal
|
||||
open={editOpen}
|
||||
resource={resource}
|
||||
branches={branches}
|
||||
addresses={addresses}
|
||||
types={types}
|
||||
saving={update.isPending}
|
||||
onClose={() => setEditOpen(false)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useBranches } from '../hooks/useBranches';
|
||||
import { useAddresses } from '../hooks/useAddresses';
|
||||
import { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources';
|
||||
import type { ResourcePool } from '../types';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
@@ -22,7 +22,7 @@ import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
||||
*/
|
||||
export default function ResourcePoolsPage() {
|
||||
const { pools, loading, create, update, remove, setMembers } = useResourcePools();
|
||||
const { branches } = useBranches();
|
||||
const { addresses } = useAddresses();
|
||||
const { types } = useResourceTypes();
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('appointment_settings', 'update');
|
||||
@@ -118,7 +118,7 @@ export default function ResourcePoolsPage() {
|
||||
|
||||
<CreatePoolModal
|
||||
open={creating}
|
||||
branches={branches}
|
||||
addresses={addresses}
|
||||
types={types}
|
||||
saving={create.isPending}
|
||||
onClose={() => setCreating(false)}
|
||||
@@ -150,10 +150,10 @@ export default function ResourcePoolsPage() {
|
||||
}
|
||||
|
||||
function CreatePoolModal({
|
||||
open, branches, types, saving, onClose, onSave,
|
||||
open, addresses, types, saving, onClose, onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
branches: ReturnType<typeof useBranches>['branches'];
|
||||
addresses: ReturnType<typeof useAddresses>['addresses'];
|
||||
types: ReturnType<typeof useResourceTypes>['types'];
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
@@ -183,7 +183,7 @@ function CreatePoolModal({
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>شعبه</label>
|
||||
<SearchableSelect
|
||||
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
value={addressUuid}
|
||||
onChange={(v) => setAddressUuid(v ? String(v) : null)}
|
||||
placeholder="شعبه را انتخاب کنید"
|
||||
|
||||
@@ -39,7 +39,7 @@ const resource = {
|
||||
|
||||
function mockApi() {
|
||||
get.mockImplementation((path: string) => {
|
||||
if (path.startsWith('/api/v1/branches')) return Promise.resolve({ success: true, data: [branch] });
|
||||
if (path.startsWith('/api/v1/addresses')) return Promise.resolve({ success: true, data: [branch] });
|
||||
if (path.startsWith('/api/v1/resource-types')) return Promise.resolve({ success: true, data: [laserType] });
|
||||
if (path.startsWith('/api/v1/skills')) return Promise.resolve({ success: true, data: [skill] });
|
||||
if (path.startsWith('/api/v1/resources')) return Promise.resolve({ success: true, data: [resource] });
|
||||
|
||||
@@ -9,7 +9,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useBranches } from '../hooks/useBranches';
|
||||
import { useAddresses } from '../hooks/useAddresses';
|
||||
import { useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
|
||||
import ResourceFormModal from '../components/resources/ResourceFormModal';
|
||||
import ResourceSkillsModal from '../components/resources/ResourceSkillsModal';
|
||||
@@ -38,7 +38,7 @@ export default function ResourcesPage() {
|
||||
search: '', address: '', type: '', skill: '', status: '',
|
||||
});
|
||||
|
||||
const { branches } = useBranches();
|
||||
const { addresses } = useAddresses();
|
||||
const { types } = useResourceTypes();
|
||||
const { skills } = useSkills();
|
||||
const { can } = usePermissions();
|
||||
@@ -143,7 +143,7 @@ export default function ResourcesPage() {
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginRight: 'auto' }}>
|
||||
<div style={{ minWidth: 180 }}>
|
||||
<SearchableSelect
|
||||
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
value={urlState.address || null}
|
||||
onChange={(v) => setUrlState({ address: v ? String(v) : '' })}
|
||||
placeholder="همهٔ شعبهها"
|
||||
@@ -225,7 +225,7 @@ export default function ResourcesPage() {
|
||||
<ResourceFormModal
|
||||
open={editing.open}
|
||||
resource={editing.resource}
|
||||
branches={branches}
|
||||
addresses={addresses}
|
||||
types={types}
|
||||
saving={create.isPending || update.isPending}
|
||||
onClose={() => setEditing({ open: false, resource: null })}
|
||||
|
||||
@@ -889,9 +889,9 @@ export interface Branch {
|
||||
timezone: string;
|
||||
city: { id: string; name: string } | null;
|
||||
province: { id: string; name: string } | null;
|
||||
/** فقط در `GET /api/v1/branches` — شعبهٔ بدون ساعت «تعریفنشده» است، نه همیشهباز */
|
||||
/** بازمانده از دورهٔ شعبه؛ اندپوینت آدرسها دیگر برنمیگرداند */
|
||||
working_hours_defined?: boolean;
|
||||
/** فقط در `GET /api/v1/branches` — تعداد اتاقهای فعال */
|
||||
/** بازمانده از دورهٔ شعبه؛ اندپوینت آدرسها دیگر برنمیگرداند */
|
||||
rooms_count?: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user