Resources, branches, price lists, holidays and the new categories page sat in the settings menu but rendered bare, so clicking one made the settings sidebar disappear — the subscription page was the only one that kept it. Eleven pages now wrap in SettingsLayout with the key of the menu entry they belong to, and the four resource pages (list, types, skills, pools) share one menu entry plus a sub-nav between them, rather than four entries that would make the menu a third longer without making anything clearer. .seg accepts `a` as well as `button`, and treats `active` as an alias of `on`. Both were needed: cross-page tabs must be real links, and the pages already using `active` (service detail, clinic appointment settings) had no visible highlight at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
6.7 KiB
TypeScript
181 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|