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:
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
const LINKS = [
|
||||
{ to: '/admin/resources', label: 'منابع' },
|
||||
{ to: '/admin/resources/types', label: 'نوع منابع' },
|
||||
{ to: '/admin/resources/skills', label: 'مهارتها' },
|
||||
{ to: '/admin/resources/pools', label: 'استخر منابع' },
|
||||
];
|
||||
|
||||
/**
|
||||
* چهار صفحهٔ خانوادهٔ منابع، یک آیتم در منوی تنظیمات دارند؛ این نوار جابهجایی بینشان
|
||||
* است. اگر هر چهار تا آیتم جدا در منو میگرفتند، منوی تنظیمات یکسوم بلندتر میشد بیآنکه
|
||||
* چیزی روشنتر شود.
|
||||
*/
|
||||
export default function ResourcesSubNav() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<div className="seg" style={{ marginBottom: 'var(--gap)', overflowX: 'auto', flexWrap: 'nowrap' }}>
|
||||
{LINKS.map((l) => (
|
||||
<Link
|
||||
key={l.to}
|
||||
to={l.to}
|
||||
className={pathname === l.to ? 'active' : ''}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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() {
|
||||
@@ -49,63 +50,65 @@ export default function BranchRoomsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<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
|
||||
}
|
||||
/>
|
||||
<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
|
||||
}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
|
||||
@@ -116,127 +117,129 @@ export default function BranchWorkingHoursPage() {
|
||||
const totalRanges = Object.values(draft).reduce((sum, ranges) => sum + ranges.length, 0);
|
||||
|
||||
return (
|
||||
<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
|
||||
}
|
||||
/>
|
||||
<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} />
|
||||
{error && (
|
||||
<div
|
||||
className="card"
|
||||
style={{ padding: '12px 16px', marginBottom: 16, color: 'var(--danger)', background: 'var(--danger-bg)', fontSize: 13 }}
|
||||
>
|
||||
{error}
|
||||
</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>
|
||||
{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>
|
||||
{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>
|
||||
) : (
|
||||
<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.end}
|
||||
value={range.start}
|
||||
disabled={!canUpdate}
|
||||
onChange={(e) => editRange(day, index, { end: e.target.value })}
|
||||
onChange={(e) => editRange(day, index, { start: 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>
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
|
||||
/**
|
||||
* شعبهها — همان محلهای نوبتدهی محیط جاری.
|
||||
@@ -91,65 +92,67 @@ export default function BranchesPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="شعبهها و اتاقها"
|
||||
description="ساعت کاری هر محل نوبتدهی و اتاقهای آن. نام و آدرس شعبه در صفحهٔ همان کلینیک یا پزشک ویرایش میشود."
|
||||
backTo="/admin/settings-menu"
|
||||
/>
|
||||
<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;
|
||||
<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
|
||||
}
|
||||
update.mutate({ uuid: b.uuid, d: { active: !b.active } });
|
||||
}}
|
||||
>
|
||||
{b.active ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
onClick={() => {
|
||||
if (
|
||||
b.active &&
|
||||
activeCount === 1 &&
|
||||
!window.confirm('این تنها شعبهٔ فعال است. با غیرفعال کردن آن، هیچ شعبهٔ فعالی باقی نمیماند. ادامه میدهید؟')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
update.mutate({ uuid: b.uuid, d: { active: !b.active } });
|
||||
}}
|
||||
>
|
||||
{b.active ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useCatalogCategories, useCategoryIncludes } from '../hooks/useCatalogCategories';
|
||||
import type { CatalogCategory } from '../types';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
|
||||
/** یک ردیف از درخت، صافشده — با عمق، تا تورفتگی نشان دهد کجای درخت است. */
|
||||
type Row = CatalogCategory & { depth: number };
|
||||
@@ -58,81 +59,83 @@ export default function CatalogCategoriesPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="دستهبندیها"
|
||||
description="دستهبندی سراسری کلینیک؛ یک بار تعریف میشود و سرویسها و منابع از همینها انتخاب میکنند."
|
||||
backTo="/admin/settings-menu"
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, category: null })}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن دستهبندی
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable<Row>
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در دستهبندیها..."
|
||||
emptyMessage="هنوز دستهبندیای تعریف نشده است"
|
||||
actions={
|
||||
canUpdate
|
||||
? (c) => (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, category: c })}>
|
||||
ویرایش
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setIncludesFor(c)}>
|
||||
شامل
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setToDelete(c)}>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<CategoryFormModal
|
||||
open={editing.open}
|
||||
category={editing.category}
|
||||
parents={all}
|
||||
saving={create.isPending || update.isPending}
|
||||
onClose={() => setEditing({ open: false, category: null })}
|
||||
onSave={(payload) => {
|
||||
const done = { onSuccess: () => setEditing({ open: false, category: null }) };
|
||||
|
||||
if (editing.category) {
|
||||
update.mutate({ uuid: editing.category.uuid, d: { name: payload.name, sort_order: payload.sort_order, active: payload.active } }, done);
|
||||
} else {
|
||||
create.mutate({ name: payload.name, parent_uuid: payload.parent_uuid }, done);
|
||||
<SettingsLayout active="service-categories">
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="دستهبندیها"
|
||||
description="دستهبندی سراسری کلینیک؛ یک بار تعریف میشود و سرویسها و منابع از همینها انتخاب میکنند."
|
||||
backTo="/admin/settings-menu"
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, category: null })}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن دستهبندی
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
|
||||
<CategoryIncludesModal
|
||||
category={includesFor}
|
||||
all={all}
|
||||
onClose={() => setIncludesFor(null)}
|
||||
/>
|
||||
<DataTable<Row>
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در دستهبندیها..."
|
||||
emptyMessage="هنوز دستهبندیای تعریف نشده است"
|
||||
actions={
|
||||
canUpdate
|
||||
? (c) => (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, category: c })}>
|
||||
ویرایش
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setIncludesFor(c)}>
|
||||
شامل
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setToDelete(c)}>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!toDelete}
|
||||
title="حذف دستهبندی"
|
||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟ سرویسها و منابعی که این دسته را دارند بدون دسته میمانند.`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
<CategoryFormModal
|
||||
open={editing.open}
|
||||
category={editing.category}
|
||||
parents={all}
|
||||
saving={create.isPending || update.isPending}
|
||||
onClose={() => setEditing({ open: false, category: null })}
|
||||
onSave={(payload) => {
|
||||
const done = { onSuccess: () => setEditing({ open: false, category: null }) };
|
||||
|
||||
if (editing.category) {
|
||||
update.mutate({ uuid: editing.category.uuid, d: { name: payload.name, sort_order: payload.sort_order, active: payload.active } }, done);
|
||||
} else {
|
||||
create.mutate({ name: payload.name, parent_uuid: payload.parent_uuid }, done);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<CategoryIncludesModal
|
||||
category={includesFor}
|
||||
all={all}
|
||||
onClose={() => setIncludesFor(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!toDelete}
|
||||
title="حذف دستهبندی"
|
||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟ سرویسها و منابعی که این دسته را دارند بدون دسته میمانند.`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useHolidays } from '../hooks/useResourceCalendar';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import type { NationalHoliday } from '../types';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
|
||||
/** سالِ شمسی جاری از همان فرمتکنندهٔ شمسیِ مرورگر گرفته میشود، نه با محاسبهٔ دستی. */
|
||||
function currentJalaliYear(): number {
|
||||
@@ -54,69 +55,71 @@ export default function HolidaysSettingsPage() {
|
||||
const years = Array.from({ length: 5 }, (_, i) => thisYear - 1 + i);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="تعطیلات رسمی"
|
||||
description="تعطیلات کشوری برای همهٔ محیطها اعمال میشود. اگر این محیط روزی را باز است، همینجا استثنا بزنید."
|
||||
backTo="/admin/settings-menu"
|
||||
/>
|
||||
<SettingsLayout active="holidays">
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="تعطیلات رسمی"
|
||||
description="تعطیلات کشوری برای همهٔ محیطها اعمال میشود. اگر این محیط روزی را باز است، همینجا استثنا بزنید."
|
||||
backTo="/admin/settings-menu"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={holidays}
|
||||
loading={loading}
|
||||
emptyMessage={`برای سال ${year} تعطیلی ثبت نشده است`}
|
||||
headerExtra={
|
||||
<div style={{ minWidth: 160, marginRight: 'auto' }}>
|
||||
<SearchableSelect
|
||||
options={years.map((y) => ({ value: String(y), label: String(y) }))}
|
||||
value={String(year)}
|
||||
onChange={(v) => setUrlState({ year: v ? String(v) : String(thisYear) })}
|
||||
placeholder="سال"
|
||||
height={36}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
canUpdate
|
||||
? (h) => {
|
||||
const override = overrideByDate.get(h.date);
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{override?.is_working ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={removeOverride.isPending}
|
||||
onClick={() => removeOverride.mutate(override.uuid)}
|
||||
>
|
||||
تعطیل کن
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={setOverride.isPending}
|
||||
onClick={() => setOverride.mutate({ date: h.date, is_working: true })}
|
||||
>
|
||||
این روز بازیم
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={holidays}
|
||||
loading={loading}
|
||||
emptyMessage={`برای سال ${year} تعطیلی ثبت نشده است`}
|
||||
headerExtra={
|
||||
<div style={{ minWidth: 160, marginRight: 'auto' }}>
|
||||
<SearchableSelect
|
||||
options={years.map((y) => ({ value: String(y), label: String(y) }))}
|
||||
value={String(year)}
|
||||
onChange={(v) => setUrlState({ year: v ? String(v) : String(thisYear) })}
|
||||
placeholder="سال"
|
||||
height={36}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
canUpdate
|
||||
? (h) => {
|
||||
const override = overrideByDate.get(h.date);
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{override?.is_working ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={removeOverride.isPending}
|
||||
onClick={() => removeOverride.mutate(override.uuid)}
|
||||
>
|
||||
تعطیل کن
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={setOverride.isPending}
|
||||
onClick={() => setOverride.mutate({ date: h.date, is_working: true })}
|
||||
>
|
||||
این روز بازیم
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ClosureCard
|
||||
overrides={overrides.filter((o) => !o.is_working)}
|
||||
canUpdate={canUpdate}
|
||||
saving={setOverride.isPending}
|
||||
onAdd={(date, note) => setOverride.mutate({ date, is_working: false, note })}
|
||||
onRemove={(uuid) => removeOverride.mutate(uuid)}
|
||||
/>
|
||||
</div>
|
||||
<ClosureCard
|
||||
overrides={overrides.filter((o) => !o.is_working)}
|
||||
canUpdate={canUpdate}
|
||||
saving={setOverride.isPending}
|
||||
onAdd={(date, note) => setOverride.mutate({ date, is_working: false, note })}
|
||||
onRemove={(uuid) => removeOverride.mutate(uuid)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import ResourceExceptionsPanel from '../components/resources/ResourceExceptionsP
|
||||
import ResourceServicesPanel from '../components/resources/ResourceServicesPanel';
|
||||
import ResourceSkillsPanel from '../components/resources/ResourceSkillsPanel';
|
||||
import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesPanel';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useBranches } from '../hooks/useBranches';
|
||||
@@ -56,10 +57,20 @@ export default function ResourceDetailPage() {
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
if (loading) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
|
||||
if (!resource) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>منبع یافت نشد.</div>;
|
||||
// حالتهای بارگذاری و نبودِ منبع هم داخل پوستهٔ تنظیمات میمانند، وگرنه منوی کناری
|
||||
// یک لحظه میپرد و دوباره برمیگردد.
|
||||
if (loading || !resource) {
|
||||
return (
|
||||
<SettingsLayout active="resources">
|
||||
<div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>
|
||||
{loading ? 'در حال بارگذاری...' : 'منبع یافت نشد.'}
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout active="resources">
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={resource.name}
|
||||
@@ -142,6 +153,7 @@ export default function ResourceDetailPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useBranches } from '../hooks/useBranches';
|
||||
import { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources';
|
||||
import type { ResourcePool } from '../types';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
||||
|
||||
/**
|
||||
* استخر منابع — گروهی از منابع که جایگزین کامل یکدیگرند.
|
||||
@@ -65,81 +67,85 @@ export default function ResourcePoolsPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="استخر منابع"
|
||||
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید در یک شعبه و از یک نوع باشند."
|
||||
backTo="/admin/resources"
|
||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'استخر منابع' }]}
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setCreating(true)}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن استخر
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<SettingsLayout active="resources">
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="استخر منابع"
|
||||
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید در یک شعبه و از یک نوع باشند."
|
||||
backTo="/admin/resources"
|
||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'استخر منابع' }]}
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setCreating(true)}>
|
||||
<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
|
||||
? (p) => (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setMembersFor(p)}>
|
||||
اعضا
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() => update.mutate({ uuid: p.uuid, d: { active: !p.active } })}
|
||||
>
|
||||
{p.active ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setToDelete(p)}>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ResourcesSubNav />
|
||||
|
||||
<CreatePoolModal
|
||||
open={creating}
|
||||
branches={branches}
|
||||
types={types}
|
||||
saving={create.isPending}
|
||||
onClose={() => setCreating(false)}
|
||||
onSave={(payload) => create.mutate(payload, { onSuccess: () => setCreating(false) })}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در استخرها..."
|
||||
emptyMessage="هنوز استخری تعریف نشده است"
|
||||
actions={
|
||||
canUpdate
|
||||
? (p) => (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setMembersFor(p)}>
|
||||
اعضا
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() => update.mutate({ uuid: p.uuid, d: { active: !p.active } })}
|
||||
>
|
||||
{p.active ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setToDelete(p)}>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<PoolMembersModal
|
||||
pool={membersFor}
|
||||
saving={setMembers.isPending}
|
||||
onClose={() => setMembersFor(null)}
|
||||
onSave={(members) =>
|
||||
membersFor &&
|
||||
setMembers.mutate({ uuid: membersFor.uuid, members }, { onSuccess: () => setMembersFor(null) })
|
||||
}
|
||||
/>
|
||||
<CreatePoolModal
|
||||
open={creating}
|
||||
branches={branches}
|
||||
types={types}
|
||||
saving={create.isPending}
|
||||
onClose={() => setCreating(false)}
|
||||
onSave={(payload) => create.mutate(payload, { onSuccess: () => setCreating(false) })}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!toDelete}
|
||||
title="حذف استخر"
|
||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟ منابع عضو حذف نمیشوند.`}
|
||||
confirmLabel="حذف"
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
<PoolMembersModal
|
||||
pool={membersFor}
|
||||
saving={setMembers.isPending}
|
||||
onClose={() => setMembersFor(null)}
|
||||
onSave={(members) =>
|
||||
membersFor &&
|
||||
setMembers.mutate({ uuid: membersFor.uuid, members }, { onSuccess: () => setMembersFor(null) })
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useResourceTypes } from '../hooks/useResources';
|
||||
import type { ResourceType } from '../types';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
||||
|
||||
/** نوع منبع — کلینیک خودش تعریفش میکند؛ سه نوع سیستمی را backfill میسازد. */
|
||||
export default function ResourceTypesPage() {
|
||||
@@ -42,73 +44,77 @@ export default function ResourceTypesPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="نوع منابع"
|
||||
description="دستهبندی منابع: پزشک، پرسنل، اتاق، دستگاه لیزر، یونیت. نوعهای سیستمی حذف نمیشوند چون پل خودکار منابع به آنها تکیه دارد."
|
||||
backTo="/admin/resources"
|
||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'نوع منابع' }]}
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, type: null })}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن نوع
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<SettingsLayout active="resources">
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="نوع منابع"
|
||||
description="دستهبندی منابع: پزشک، پرسنل، اتاق، دستگاه لیزر، یونیت. نوعهای سیستمی حذف نمیشوند چون پل خودکار منابع به آنها تکیه دارد."
|
||||
backTo="/admin/resources"
|
||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'نوع منابع' }]}
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, type: 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
|
||||
? (t) => (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, type: t })}>
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={t.is_system}
|
||||
title={t.is_system ? 'نوع سیستمی حذف نمیشود' : undefined}
|
||||
onClick={() => setToDelete(t)}
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ResourcesSubNav />
|
||||
|
||||
<TypeModal
|
||||
open={editing.open}
|
||||
type={editing.type}
|
||||
saving={create.isPending || update.isPending}
|
||||
onClose={() => setEditing({ open: false, type: null })}
|
||||
onSave={(payload) => {
|
||||
const opts = { onSuccess: () => setEditing({ open: false, type: null }) };
|
||||
if (editing.type) update.mutate({ uuid: editing.type.uuid, d: { name: payload.name, active: payload.active } }, opts);
|
||||
else create.mutate({ code: payload.code, name: payload.name }, opts);
|
||||
}}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در نوع منابع..."
|
||||
emptyMessage="هنوز نوع منبعی تعریف نشده است"
|
||||
actions={
|
||||
canUpdate
|
||||
? (t) => (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, type: t })}>
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={t.is_system}
|
||||
title={t.is_system ? 'نوع سیستمی حذف نمیشود' : undefined}
|
||||
onClick={() => setToDelete(t)}
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!toDelete}
|
||||
title="حذف نوع منبع"
|
||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
|
||||
confirmLabel="حذف"
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
<TypeModal
|
||||
open={editing.open}
|
||||
type={editing.type}
|
||||
saving={create.isPending || update.isPending}
|
||||
onClose={() => setEditing({ open: false, type: null })}
|
||||
onSave={(payload) => {
|
||||
const opts = { onSuccess: () => setEditing({ open: false, type: null }) };
|
||||
if (editing.type) update.mutate({ uuid: editing.type.uuid, d: { name: payload.name, active: payload.active } }, opts);
|
||||
else create.mutate({ code: payload.code, name: payload.name }, opts);
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import ResourceSkillsModal from '../components/resources/ResourceSkillsModal';
|
||||
import ResourceServicesModal from '../components/resources/ResourceServicesModal';
|
||||
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
||||
import type { ClinicResource } from '../types';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
||||
|
||||
/** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */
|
||||
const SUBJECT_LABEL: Record<string, string> = {
|
||||
@@ -112,160 +114,164 @@ export default function ResourcesPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="منابع"
|
||||
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار همزمان."
|
||||
backTo="/admin/settings-menu"
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, resource: null })}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن منبع
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<SettingsLayout active="resources">
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="منابع"
|
||||
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار همزمان."
|
||||
backTo="/admin/settings-menu"
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, resource: null })}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن منبع
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در منابع..."
|
||||
emptyMessage="هیچ منبعی با این فیلترها یافت نشد"
|
||||
headerExtra={
|
||||
<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 || 'بدون نام' }))}
|
||||
value={urlState.address || null}
|
||||
onChange={(v) => setUrlState({ address: v ? String(v) : '' })}
|
||||
placeholder="همهٔ شعبهها"
|
||||
isClearable
|
||||
height={36}
|
||||
/>
|
||||
<ResourcesSubNav />
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در منابع..."
|
||||
emptyMessage="هیچ منبعی با این فیلترها یافت نشد"
|
||||
headerExtra={
|
||||
<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 || 'بدون نام' }))}
|
||||
value={urlState.address || null}
|
||||
onChange={(v) => setUrlState({ address: v ? String(v) : '' })}
|
||||
placeholder="همهٔ شعبهها"
|
||||
isClearable
|
||||
height={36}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<SearchableSelect
|
||||
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
|
||||
value={urlState.type || null}
|
||||
onChange={(v) => setUrlState({ type: v ? String(v) : '' })}
|
||||
placeholder="همهٔ نوعها"
|
||||
isClearable
|
||||
height={36}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<SearchableSelect
|
||||
options={skills.map((s) => ({ value: s.uuid, label: s.name }))}
|
||||
value={urlState.skill || null}
|
||||
onChange={(v) => setUrlState({ skill: v ? String(v) : '' })}
|
||||
placeholder="همهٔ مهارتها"
|
||||
isClearable
|
||||
height={36}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<SearchableSelect
|
||||
options={[
|
||||
{ value: '1', label: 'فعال' },
|
||||
{ value: '0', label: 'غیرفعال' },
|
||||
]}
|
||||
value={urlState.status || null}
|
||||
onChange={(v) => setUrlState({ status: v ? String(v) : '' })}
|
||||
placeholder="همهٔ وضعیتها"
|
||||
isClearable
|
||||
height={36}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<SearchableSelect
|
||||
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
|
||||
value={urlState.type || null}
|
||||
onChange={(v) => setUrlState({ type: v ? String(v) : '' })}
|
||||
placeholder="همهٔ نوعها"
|
||||
isClearable
|
||||
height={36}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<SearchableSelect
|
||||
options={skills.map((s) => ({ value: s.uuid, label: s.name }))}
|
||||
value={urlState.skill || null}
|
||||
onChange={(v) => setUrlState({ skill: v ? String(v) : '' })}
|
||||
placeholder="همهٔ مهارتها"
|
||||
isClearable
|
||||
height={36}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<SearchableSelect
|
||||
options={[
|
||||
{ value: '1', label: 'فعال' },
|
||||
{ value: '0', label: 'غیرفعال' },
|
||||
]}
|
||||
value={urlState.status || null}
|
||||
onChange={(v) => setUrlState({ status: v ? String(v) : '' })}
|
||||
placeholder="همهٔ وضعیتها"
|
||||
isClearable
|
||||
height={36}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
canUpdate
|
||||
? (r) => (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, resource: r })}>
|
||||
ویرایش
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setSkillsFor(r)}>
|
||||
مهارتها
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setServicesFor(r)}>
|
||||
سرویسها
|
||||
</button>
|
||||
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}`}>
|
||||
تنظیمات
|
||||
</Link>
|
||||
{/* مسدودسازی موردی از تقویم جداست: آن الگوی کاری را عوض میکند،
|
||||
این یک بازهٔ مشخص را میبندد. */}
|
||||
<button type="button" className="btn secondary sm" onClick={() => setBlocksFor(r)}>
|
||||
مسدودسازی
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setToDelete(r)}>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
canUpdate
|
||||
? (r) => (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, resource: r })}>
|
||||
ویرایش
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setSkillsFor(r)}>
|
||||
مهارتها
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setServicesFor(r)}>
|
||||
سرویسها
|
||||
</button>
|
||||
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}`}>
|
||||
تنظیمات
|
||||
</Link>
|
||||
{/* مسدودسازی موردی از تقویم جداست: آن الگوی کاری را عوض میکند،
|
||||
این یک بازهٔ مشخص را میبندد. */}
|
||||
<button type="button" className="btn secondary sm" onClick={() => setBlocksFor(r)}>
|
||||
مسدودسازی
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setToDelete(r)}>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourceBlocksModal
|
||||
resourceUuid={blocksFor?.uuid ?? null}
|
||||
resourceName={blocksFor?.name ?? ''}
|
||||
onClose={() => setBlocksFor(null)}
|
||||
/>
|
||||
<ResourceBlocksModal
|
||||
resourceUuid={blocksFor?.uuid ?? null}
|
||||
resourceName={blocksFor?.name ?? ''}
|
||||
onClose={() => setBlocksFor(null)}
|
||||
/>
|
||||
|
||||
<ResourceFormModal
|
||||
open={editing.open}
|
||||
resource={editing.resource}
|
||||
branches={branches}
|
||||
types={types}
|
||||
saving={create.isPending || update.isPending}
|
||||
onClose={() => setEditing({ open: false, resource: null })}
|
||||
onSave={(payload) => {
|
||||
const opts = { onSuccess: () => setEditing({ open: false, resource: null }) };
|
||||
if (editing.resource) update.mutate({ uuid: editing.resource.uuid, d: payload }, opts);
|
||||
else create.mutate(payload, opts);
|
||||
}}
|
||||
/>
|
||||
<ResourceFormModal
|
||||
open={editing.open}
|
||||
resource={editing.resource}
|
||||
branches={branches}
|
||||
types={types}
|
||||
saving={create.isPending || update.isPending}
|
||||
onClose={() => setEditing({ open: false, resource: null })}
|
||||
onSave={(payload) => {
|
||||
const opts = { onSuccess: () => setEditing({ open: false, resource: null }) };
|
||||
if (editing.resource) update.mutate({ uuid: editing.resource.uuid, d: payload }, opts);
|
||||
else create.mutate(payload, opts);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ResourceSkillsModal
|
||||
resource={skillsFor}
|
||||
skills={skills}
|
||||
saving={setSkills.isPending}
|
||||
onClose={() => setSkillsFor(null)}
|
||||
onSave={(lines) =>
|
||||
skillsFor &&
|
||||
setSkills.mutate({ uuid: skillsFor.uuid, skills: lines }, { onSuccess: () => setSkillsFor(null) })
|
||||
}
|
||||
/>
|
||||
<ResourceSkillsModal
|
||||
resource={skillsFor}
|
||||
skills={skills}
|
||||
saving={setSkills.isPending}
|
||||
onClose={() => setSkillsFor(null)}
|
||||
onSave={(lines) =>
|
||||
skillsFor &&
|
||||
setSkills.mutate({ uuid: skillsFor.uuid, skills: lines }, { onSuccess: () => setSkillsFor(null) })
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourceServicesModal
|
||||
resource={servicesFor}
|
||||
offerings={offerings}
|
||||
services={serviceOptions}
|
||||
saving={saveServices.isPending}
|
||||
onClose={() => setServicesFor(null)}
|
||||
onSave={(lines) =>
|
||||
servicesFor &&
|
||||
saveServices.mutate(
|
||||
{ uuid: servicesFor.uuid, services: lines },
|
||||
{ onSuccess: () => setServicesFor(null) },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ResourceServicesModal
|
||||
resource={servicesFor}
|
||||
offerings={offerings}
|
||||
services={serviceOptions}
|
||||
saving={saveServices.isPending}
|
||||
onClose={() => setServicesFor(null)}
|
||||
onSave={(lines) =>
|
||||
servicesFor &&
|
||||
saveServices.mutate(
|
||||
{ uuid: servicesFor.uuid, services: lines },
|
||||
{ onSuccess: () => setServicesFor(null) },
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!toDelete}
|
||||
title="حذف منبع"
|
||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
|
||||
confirmLabel="حذف"
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useSkills } from '../hooks/useResources';
|
||||
import type { Skill } from '../types';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
||||
|
||||
/**
|
||||
* مهارتها — «کدام اپراتور مجاز است با کدام دستگاه کار کند» یک اطلاعات است، نه یک
|
||||
@@ -39,73 +41,77 @@ export default function SkillsPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="مهارتها"
|
||||
description="مهارت روی منابع مینشیند و در انتخاب منبع مناسب استفاده میشود. مهارتی که به منبعی داده شده، تا برداشته نشود حذف نمیشود."
|
||||
backTo="/admin/resources"
|
||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'مهارتها' }]}
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, skill: null })}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن مهارت
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<SettingsLayout active="resources">
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="مهارتها"
|
||||
description="مهارت روی منابع مینشیند و در انتخاب منبع مناسب استفاده میشود. مهارتی که به منبعی داده شده، تا برداشته نشود حذف نمیشود."
|
||||
backTo="/admin/resources"
|
||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'مهارتها' }]}
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, skill: 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
|
||||
? (s) => (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, skill: s })}>
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={(s.resources_count ?? 0) > 0}
|
||||
title={(s.resources_count ?? 0) > 0 ? 'اول از منابع برداشته شود' : undefined}
|
||||
onClick={() => setToDelete(s)}
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ResourcesSubNav />
|
||||
|
||||
<SkillModal
|
||||
open={editing.open}
|
||||
skill={editing.skill}
|
||||
saving={create.isPending || update.isPending}
|
||||
onClose={() => setEditing({ open: false, skill: null })}
|
||||
onSave={({ name, active }) => {
|
||||
const opts = { onSuccess: () => setEditing({ open: false, skill: null }) };
|
||||
if (editing.skill) update.mutate({ uuid: editing.skill.uuid, d: { name, active } }, opts);
|
||||
else create.mutate({ name }, opts);
|
||||
}}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در مهارتها..."
|
||||
emptyMessage="هنوز مهارتی تعریف نشده است"
|
||||
actions={
|
||||
canUpdate
|
||||
? (s) => (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, skill: s })}>
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={(s.resources_count ?? 0) > 0}
|
||||
title={(s.resources_count ?? 0) > 0 ? 'اول از منابع برداشته شود' : undefined}
|
||||
onClick={() => setToDelete(s)}
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!toDelete}
|
||||
title="حذف مهارت"
|
||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
|
||||
confirmLabel="حذف"
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
<SkillModal
|
||||
open={editing.open}
|
||||
skill={editing.skill}
|
||||
saving={create.isPending || update.isPending}
|
||||
onClose={() => setEditing({ open: false, skill: null })}
|
||||
onSave={({ name, active }) => {
|
||||
const opts = { onSuccess: () => setEditing({ open: false, skill: null }) };
|
||||
if (editing.skill) update.mutate({ uuid: editing.skill.uuid, d: { name, active } }, opts);
|
||||
else create.mutate({ name }, opts);
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -651,11 +651,15 @@ table.t tbody tr:last-child td { border-bottom: none; }
|
||||
.field:focus-within { border-color: var(--primary); box-shadow: 0 0 0 4px var(--ring); }
|
||||
.field input, .field select, .field textarea { border: none; outline: none; background: none; flex: 1; min-width: 60px; color: var(--text); font-family: inherit; font-size: 14px; }
|
||||
.seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px; gap: 2px; }
|
||||
.seg button { padding: 7px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; color: var(--text-2); transition: .14s; cursor: pointer; font-family: inherit; }
|
||||
.seg button.on { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
|
||||
/* `a` هم پذیرفته میشود: نوار تبهایی که بین صفحهها جابهجا میکنند باید لینک واقعی باشند
|
||||
(باز کردن در تب جدید، کلیک وسط)، نه دکمهای که navigate صدا میزند. */
|
||||
.seg button, .seg a { padding: 7px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; color: var(--text-2); transition: .14s; cursor: pointer; font-family: inherit; text-decoration: none; display: inline-flex; align-items: center; }
|
||||
/* `active` هممعنی `on` است — هر دو در کد استفاده شدهاند و نبودِ این alias یعنی تب فعال
|
||||
در چند صفحه هیچ نشانهای نداشت. */
|
||||
.seg button.on, .seg button.active, .seg a.on, .seg a.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
|
||||
/* همراستا با .mini-btn: ارتفاع لمسیِ حداقلی روی موبایل (WCAG 2.5.5) */
|
||||
@media (max-width: 640px), (pointer: coarse) {
|
||||
.seg button { min-height: 44px; padding: 7px 16px; }
|
||||
.seg button, .seg a { min-height: 44px; padding: 7px 16px; }
|
||||
}
|
||||
|
||||
/* ── Switch toggle ───────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user