fix(settings): every settings page renders the settings shell
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>
This commit is contained in:
@@ -12,6 +12,7 @@ import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useBranches } from '../hooks/useBranches';
|
||||
import { usePriceLists, type PriceList, type PriceListItem } from '../hooks/usePriceLists';
|
||||
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
|
||||
interface Draft {
|
||||
uuid?: string;
|
||||
@@ -120,236 +121,238 @@ export default function PriceListsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="لیستهای قیمت"
|
||||
description="قیمت هر خدمت در یک بازهٔ زمانی. لیست تا فعال نشود روی هیچ فاکتوری اثر ندارد."
|
||||
backTo="/admin/settings-menu"
|
||||
action={
|
||||
canManage ? (
|
||||
<button type="button" className="btn primary sm" onClick={() => setDraft(emptyDraft())}>
|
||||
<PlusIcon style={{ width: 15 }} /> لیست تازه
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در لیستها..."
|
||||
emptyMessage="هنوز لیست قیمتی ساخته نشده است"
|
||||
actions={(l) =>
|
||||
<SettingsLayout active="price-lists">
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="لیستهای قیمت"
|
||||
description="قیمت هر خدمت در یک بازهٔ زمانی. لیست تا فعال نشود روی هیچ فاکتوری اثر ندارد."
|
||||
backTo="/admin/settings-menu"
|
||||
action={
|
||||
canManage ? (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
uuid: l.uuid,
|
||||
name: l.name,
|
||||
address_uuid: l.address_uuid,
|
||||
valid_from: l.valid_from,
|
||||
valid_to: l.valid_to,
|
||||
items: l.items,
|
||||
})
|
||||
}
|
||||
>
|
||||
ویرایش
|
||||
</button>
|
||||
|
||||
{/* «کپی از لیست قبلی»: بیشتر لیستها نسخهٔ کمیتغییریافتهٔ قبلیاند. */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
name: `${l.name} — نسخهٔ تازه`,
|
||||
address_uuid: l.address_uuid,
|
||||
valid_from: l.valid_to + DAY,
|
||||
valid_to: l.valid_to + 90 * DAY,
|
||||
items: l.items,
|
||||
})
|
||||
}
|
||||
>
|
||||
کپی
|
||||
</button>
|
||||
|
||||
{!l.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary sm"
|
||||
disabled={activate.isPending}
|
||||
onClick={() => activate.mutate(l.uuid)}
|
||||
>
|
||||
فعالسازی
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!l.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(l.uuid)}
|
||||
aria-label="حذف لیست"
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
<button type="button" className="btn primary sm" onClick={() => setDraft(emptyDraft())}>
|
||||
<PlusIcon style={{ width: 15 }} /> لیست تازه
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={draft !== null}
|
||||
title={draft?.uuid ? 'ویرایش لیست قیمت' : 'لیست قیمت تازه'}
|
||||
size="lg"
|
||||
onClose={() => setDraft(null)}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={
|
||||
!draft ||
|
||||
draft.name.trim() === '' ||
|
||||
draft.valid_to <= draft.valid_from ||
|
||||
create.isPending ||
|
||||
update.isPending ||
|
||||
setItems.isPending
|
||||
}
|
||||
onClick={save}
|
||||
>
|
||||
ذخیره
|
||||
</button>
|
||||
<button type="button" className="btn secondary" onClick={() => setDraft(null)}>
|
||||
انصراف
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{draft && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div className="field-block">
|
||||
<label htmlFor="pl-name">نام لیست</label>
|
||||
<input
|
||||
id="pl-name"
|
||||
className="input"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
placeholder="مثلاً: تعرفهٔ نیمهٔ دوم ۱۴۰۵"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field-block">
|
||||
<label>شعبه</label>
|
||||
<SearchableSelect
|
||||
value={draft.address_uuid ?? ''}
|
||||
onChange={(v) => setDraft({ ...draft, address_uuid: v ? String(v) : null })}
|
||||
options={[
|
||||
{ value: '', label: 'همهٔ شعبهها' },
|
||||
...branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' })),
|
||||
]}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
لیستِ یک شعبه بر لیست عمومی مقدم است و با آن تداخل حساب نمیشود.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="field-block" style={{ minWidth: 180 }}>
|
||||
<label>از تاریخ</label>
|
||||
<PersianDateInput
|
||||
value={unixToIso(draft.valid_from)}
|
||||
onChange={(iso) => setDraft({ ...draft, valid_from: isoToUnix(iso) ?? draft.valid_from })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-block" style={{ minWidth: 180 }}>
|
||||
<label>تا تاریخ</label>
|
||||
<PersianDateInput
|
||||
value={unixToIso(draft.valid_to)}
|
||||
onChange={(iso) => setDraft({ ...draft, valid_to: isoToUnix(iso) ?? draft.valid_to })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.valid_to <= draft.valid_from && (
|
||||
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
|
||||
پایان بازه باید بعد از شروع آن باشد.
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>قیمتها</h3>
|
||||
|
||||
{draft.items.map((item, index) => (
|
||||
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ minWidth: 220 }}>
|
||||
<SearchableSelect
|
||||
value={item.service_uuid}
|
||||
onChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
items: draft.items.map((it, i) =>
|
||||
i === index ? { ...it, service_uuid: String(v ?? '') } : it,
|
||||
),
|
||||
})
|
||||
}
|
||||
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
|
||||
placeholder="خدمت"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 200 }}>
|
||||
<PriceInput
|
||||
value={item.price_rials}
|
||||
onChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
items: draft.items.map((it, i) => (i === index ? { ...it, price_rials: v } : it)),
|
||||
})
|
||||
}
|
||||
suffix="ریال"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در لیستها..."
|
||||
emptyMessage="هنوز لیست قیمتی ساخته نشده است"
|
||||
actions={(l) =>
|
||||
canManage ? (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() => setDraft({ ...draft, items: draft.items.filter((_, i) => i !== index) })}
|
||||
aria-label="حذف قیمت"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
uuid: l.uuid,
|
||||
name: l.name,
|
||||
address_uuid: l.address_uuid,
|
||||
valid_from: l.valid_from,
|
||||
valid_to: l.valid_to,
|
||||
items: l.items,
|
||||
})
|
||||
}
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
ویرایش
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* «کپی از لیست قبلی»: بیشتر لیستها نسخهٔ کمیتغییریافتهٔ قبلیاند. */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
name: `${l.name} — نسخهٔ تازه`,
|
||||
address_uuid: l.address_uuid,
|
||||
valid_from: l.valid_to + DAY,
|
||||
valid_to: l.valid_to + 90 * DAY,
|
||||
items: l.items,
|
||||
})
|
||||
}
|
||||
>
|
||||
کپی
|
||||
</button>
|
||||
|
||||
{!l.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary sm"
|
||||
disabled={activate.isPending}
|
||||
onClick={() => activate.mutate(l.uuid)}
|
||||
>
|
||||
فعالسازی
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!l.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(l.uuid)}
|
||||
aria-label="حذف لیست"
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={draft !== null}
|
||||
title={draft?.uuid ? 'ویرایش لیست قیمت' : 'لیست قیمت تازه'}
|
||||
size="lg"
|
||||
onClose={() => setDraft(null)}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() =>
|
||||
setDraft({ ...draft, items: [...draft.items, { service_uuid: '', price_rials: 0 }] })
|
||||
className="btn primary"
|
||||
disabled={
|
||||
!draft ||
|
||||
draft.name.trim() === '' ||
|
||||
draft.valid_to <= draft.valid_from ||
|
||||
create.isPending ||
|
||||
update.isPending ||
|
||||
setItems.isPending
|
||||
}
|
||||
onClick={save}
|
||||
>
|
||||
<PlusIcon style={{ width: 15 }} /> افزودن قیمت
|
||||
ذخیره
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="btn secondary" onClick={() => setDraft(null)}>
|
||||
انصراف
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{draft && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div className="field-block">
|
||||
<label htmlFor="pl-name">نام لیست</label>
|
||||
<input
|
||||
id="pl-name"
|
||||
className="input"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
placeholder="مثلاً: تعرفهٔ نیمهٔ دوم ۱۴۰۵"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
خدمتی که در این لیست نیاید، قیمتش از تعرفهٔ سال یا خودِ خدمت خوانده میشود —
|
||||
پس هیچوقت بیقیمت نمیماند.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
<div className="field-block">
|
||||
<label>شعبه</label>
|
||||
<SearchableSelect
|
||||
value={draft.address_uuid ?? ''}
|
||||
onChange={(v) => setDraft({ ...draft, address_uuid: v ? String(v) : null })}
|
||||
options={[
|
||||
{ value: '', label: 'همهٔ شعبهها' },
|
||||
...branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' })),
|
||||
]}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
لیستِ یک شعبه بر لیست عمومی مقدم است و با آن تداخل حساب نمیشود.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="field-block" style={{ minWidth: 180 }}>
|
||||
<label>از تاریخ</label>
|
||||
<PersianDateInput
|
||||
value={unixToIso(draft.valid_from)}
|
||||
onChange={(iso) => setDraft({ ...draft, valid_from: isoToUnix(iso) ?? draft.valid_from })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-block" style={{ minWidth: 180 }}>
|
||||
<label>تا تاریخ</label>
|
||||
<PersianDateInput
|
||||
value={unixToIso(draft.valid_to)}
|
||||
onChange={(iso) => setDraft({ ...draft, valid_to: isoToUnix(iso) ?? draft.valid_to })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.valid_to <= draft.valid_from && (
|
||||
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
|
||||
پایان بازه باید بعد از شروع آن باشد.
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>قیمتها</h3>
|
||||
|
||||
{draft.items.map((item, index) => (
|
||||
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ minWidth: 220 }}>
|
||||
<SearchableSelect
|
||||
value={item.service_uuid}
|
||||
onChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
items: draft.items.map((it, i) =>
|
||||
i === index ? { ...it, service_uuid: String(v ?? '') } : it,
|
||||
),
|
||||
})
|
||||
}
|
||||
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
|
||||
placeholder="خدمت"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 200 }}>
|
||||
<PriceInput
|
||||
value={item.price_rials}
|
||||
onChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
items: draft.items.map((it, i) => (i === index ? { ...it, price_rials: v } : it)),
|
||||
})
|
||||
}
|
||||
suffix="ریال"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() => setDraft({ ...draft, items: draft.items.filter((_, i) => i !== index) })}
|
||||
aria-label="حذف قیمت"
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() =>
|
||||
setDraft({ ...draft, items: [...draft.items, { service_uuid: '', price_rials: 0 }] })
|
||||
}
|
||||
>
|
||||
<PlusIcon style={{ width: 15 }} /> افزودن قیمت
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
خدمتی که در این لیست نیاید، قیمتش از تعرفهٔ سال یا خودِ خدمت خوانده میشود —
|
||||
پس هیچوقت بیقیمت نمیماند.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user