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 { usePermissions } from '../hooks/usePermissions';
|
||||||
import { useBranchRooms, useBranches } from '../hooks/useBranches';
|
import { useBranchRooms, useBranches } from '../hooks/useBranches';
|
||||||
import type { Room, RoomPayload } from '../types';
|
import type { Room, RoomPayload } from '../types';
|
||||||
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
|
|
||||||
/** اتاقهای یک شعبه. ظرفیت = چند بیمار همزمان، نه چند اتاق. */
|
/** اتاقهای یک شعبه. ظرفیت = چند بیمار همزمان، نه چند اتاق. */
|
||||||
export default function BranchRoomsPage() {
|
export default function BranchRoomsPage() {
|
||||||
@@ -49,63 +50,65 @@ export default function BranchRoomsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<SettingsLayout active="branches">
|
||||||
<PageHeader
|
<div className="fade-in">
|
||||||
title={`اتاقهای ${branch?.name ?? 'شعبه'}`}
|
<PageHeader
|
||||||
description="ظرفیت هر اتاق تعداد بیمارِ همزمان است — اتاق تزریق سهتخته یک اتاق با ظرفیت ۳ است، نه سه اتاق."
|
title={`اتاقهای ${branch?.name ?? 'شعبه'}`}
|
||||||
backTo="/admin/branches"
|
description="ظرفیت هر اتاق تعداد بیمارِ همزمان است — اتاق تزریق سهتخته یک اتاق با ظرفیت ۳ است، نه سه اتاق."
|
||||||
breadcrumbs={[{ label: 'شعبهها', to: '/admin/branches' }, { label: 'اتاقها' }]}
|
backTo="/admin/branches"
|
||||||
action={
|
breadcrumbs={[{ label: 'شعبهها', to: '/admin/branches' }, { label: 'اتاقها' }]}
|
||||||
canUpdate ? (
|
action={
|
||||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, room: null })}>
|
canUpdate ? (
|
||||||
<PlusIcon style={{ width: 16 }} /> افزودن اتاق
|
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, room: null })}>
|
||||||
</button>
|
<PlusIcon style={{ width: 16 }} /> افزودن اتاق
|
||||||
) : undefined
|
</button>
|
||||||
}
|
) : undefined
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
data={rows}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
searchValue={urlState.search}
|
searchValue={urlState.search}
|
||||||
onSearchChange={(v) => setUrlState({ search: v })}
|
onSearchChange={(v) => setUrlState({ search: v })}
|
||||||
searchPlaceholder="جستجو در اتاقها..."
|
searchPlaceholder="جستجو در اتاقها..."
|
||||||
emptyMessage="هنوز اتاقی برای این شعبه ثبت نشده است"
|
emptyMessage="هنوز اتاقی برای این شعبه ثبت نشده است"
|
||||||
actions={
|
actions={
|
||||||
canUpdate
|
canUpdate
|
||||||
? (r) => (
|
? (r) => (
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, room: r })}>
|
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, room: r })}>
|
||||||
ویرایش
|
ویرایش
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn secondary sm" onClick={() => setToDelete(r)}>
|
<button type="button" className="btn secondary sm" onClick={() => setToDelete(r)}>
|
||||||
حذف
|
حذف
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RoomModal
|
<RoomModal
|
||||||
open={editing.open}
|
open={editing.open}
|
||||||
room={editing.room}
|
room={editing.room}
|
||||||
saving={create.isPending || update.isPending}
|
saving={create.isPending || update.isPending}
|
||||||
onClose={() => setEditing({ open: false, room: null })}
|
onClose={() => setEditing({ open: false, room: null })}
|
||||||
onSave={save}
|
onSave={save}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!toDelete}
|
open={!!toDelete}
|
||||||
title="حذف اتاق"
|
title="حذف اتاق"
|
||||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
|
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
|
||||||
confirmLabel="حذف"
|
confirmLabel="حذف"
|
||||||
loading={remove.isPending}
|
loading={remove.isPending}
|
||||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
||||||
onCancel={() => setToDelete(null)}
|
onCancel={() => setToDelete(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
|||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { useBranchWorkingHours, useBranches } from '../hooks/useBranches';
|
import { useBranchWorkingHours, useBranches } from '../hooks/useBranches';
|
||||||
import type { WorkingHourRange } from '../types';
|
import type { WorkingHourRange } from '../types';
|
||||||
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
|
|
||||||
/** ۰ = شنبه — همان قرارداد محاسبهٔ اسلات در بکاند. */
|
/** ۰ = شنبه — همان قرارداد محاسبهٔ اسلات در بکاند. */
|
||||||
const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
|
const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
|
||||||
@@ -116,127 +117,129 @@ export default function BranchWorkingHoursPage() {
|
|||||||
const totalRanges = Object.values(draft).reduce((sum, ranges) => sum + ranges.length, 0);
|
const totalRanges = Object.values(draft).reduce((sum, ranges) => sum + ranges.length, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<SettingsLayout active="branches">
|
||||||
<PageHeader
|
<div className="fade-in">
|
||||||
title={`ساعت کاری ${branch?.name ?? 'شعبه'}`}
|
<PageHeader
|
||||||
description="روز بدون بازه یعنی شعبه آن روز بسته است. شعبهٔ بدون هیچ ساعتی «تعریفنشده» است، نه همیشهباز."
|
title={`ساعت کاری ${branch?.name ?? 'شعبه'}`}
|
||||||
backTo="/admin/branches"
|
description="روز بدون بازه یعنی شعبه آن روز بسته است. شعبهٔ بدون هیچ ساعتی «تعریفنشده» است، نه همیشهباز."
|
||||||
breadcrumbs={[
|
backTo="/admin/branches"
|
||||||
{ label: 'شعبهها', to: '/admin/branches' },
|
breadcrumbs={[
|
||||||
{ label: 'ساعت کاری' },
|
{ label: 'شعبهها', to: '/admin/branches' },
|
||||||
]}
|
{ label: 'ساعت کاری' },
|
||||||
action={
|
]}
|
||||||
canUpdate ? (
|
action={
|
||||||
<button type="button" className="btn primary" disabled={save.isPending} onClick={submit}>
|
canUpdate ? (
|
||||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ هفته'}
|
<button type="button" className="btn primary" disabled={save.isPending} onClick={submit}>
|
||||||
</button>
|
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ هفته'}
|
||||||
) : undefined
|
</button>
|
||||||
}
|
) : undefined
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
className="card"
|
className="card"
|
||||||
style={{ padding: '12px 16px', marginBottom: 16, color: 'var(--danger)', background: 'var(--danger-bg)', fontSize: 13 }}
|
style={{ padding: '12px 16px', marginBottom: 16, color: 'var(--danger)', background: 'var(--danger-bg)', fontSize: 13 }}
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{branch && canUpdate && (
|
|
||||||
<div className="card" style={{ padding: 16, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>منطقهٔ زمانی شعبه</span>
|
|
||||||
<div style={{ minWidth: 240 }}>
|
|
||||||
<TimezoneSelect branchUuid={branch.uuid} value={branch.timezone} />
|
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
)}
|
||||||
{totalRanges === 0 ? 'هیچ بازهای تعریف نشده' : `${totalRanges} بازه در هفته`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
{branch && canUpdate && (
|
||||||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
<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={{ display: 'grid', gap: 12 }}>
|
<div style={{ minWidth: 240 }}>
|
||||||
{DAY_LABELS.map((label, day) => {
|
<TimezoneSelect branchUuid={branch.uuid} value={branch.timezone} />
|
||||||
const ranges = draft[day] ?? [];
|
</div>
|
||||||
return (
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
<div key={day} className="card" style={{ padding: 16 }}>
|
{totalRanges === 0 ? 'هیچ بازهای تعریف نشده' : `${totalRanges} بازه در هفته`}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: ranges.length ? 12 : 0 }}>
|
</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
</div>
|
||||||
<span style={{ fontWeight: 700, fontSize: 14 }}>{label}</span>
|
)}
|
||||||
{ranges.length === 0 && (
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>بسته</span>
|
{loading ? (
|
||||||
|
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
|
{DAY_LABELS.map((label, day) => {
|
||||||
|
const ranges = draft[day] ?? [];
|
||||||
|
return (
|
||||||
|
<div key={day} className="card" style={{ padding: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: ranges.length ? 12 : 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 14 }}>{label}</span>
|
||||||
|
{ranges.length === 0 && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>بسته</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canUpdate && (
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{ranges.length > 0 && (
|
||||||
|
<button type="button" className="btn secondary sm" onClick={() => copyToWholeWeek(day)}>
|
||||||
|
اعمال روی همهٔ روزها
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn secondary sm" onClick={() => addRange(day)}>
|
||||||
|
<PlusIcon style={{ width: 15 }} /> بازه
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{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 }}>
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
{ranges.map((range, index) => (
|
{ranges.map((range, index) => (
|
||||||
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||||
<label style={{ fontSize: 12, color: 'var(--text-3)' }}>از</label>
|
<label style={{ fontSize: 12, color: 'var(--text-3)' }}>از</label>
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
className="field"
|
|
||||||
value={range.start}
|
|
||||||
disabled={!canUpdate}
|
|
||||||
onChange={(e) => editRange(day, index, { start: e.target.value })}
|
|
||||||
style={{ width: 120 }}
|
|
||||||
/>
|
|
||||||
<label style={{ fontSize: 12, color: 'var(--text-3)' }}>تا</label>
|
|
||||||
{range.endOfDay ? (
|
|
||||||
<span className="field" style={{ width: 120, color: 'var(--text-2)' }}>۲۴:۰۰</span>
|
|
||||||
) : (
|
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
className="field"
|
className="field"
|
||||||
value={range.end}
|
value={range.start}
|
||||||
disabled={!canUpdate}
|
disabled={!canUpdate}
|
||||||
onChange={(e) => editRange(day, index, { end: e.target.value })}
|
onChange={(e) => editRange(day, index, { start: e.target.value })}
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
/>
|
/>
|
||||||
)}
|
<label style={{ fontSize: 12, color: 'var(--text-3)' }}>تا</label>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'var(--text-3)' }}>
|
{range.endOfDay ? (
|
||||||
<input
|
<span className="field" style={{ width: 120, color: 'var(--text-2)' }}>۲۴:۰۰</span>
|
||||||
type="checkbox"
|
) : (
|
||||||
checked={range.endOfDay}
|
<input
|
||||||
disabled={!canUpdate}
|
type="time"
|
||||||
onChange={(e) => editRange(day, index, { endOfDay: e.target.checked })}
|
className="field"
|
||||||
/>
|
value={range.end}
|
||||||
تا پایان روز
|
disabled={!canUpdate}
|
||||||
</label>
|
onChange={(e) => editRange(day, index, { end: e.target.value })}
|
||||||
{canUpdate && (
|
style={{ width: 120 }}
|
||||||
<button
|
/>
|
||||||
type="button"
|
)}
|
||||||
className="btn secondary sm"
|
<label style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
onClick={() => removeRange(day, index)}
|
<input
|
||||||
aria-label="حذف بازه"
|
type="checkbox"
|
||||||
>
|
checked={range.endOfDay}
|
||||||
<TrashIcon style={{ width: 15 }} />
|
disabled={!canUpdate}
|
||||||
</button>
|
onChange={(e) => editRange(day, index, { endOfDay: e.target.checked })}
|
||||||
)}
|
/>
|
||||||
</div>
|
تا پایان روز
|
||||||
))}
|
</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>
|
||||||
</div>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useUrlState } from '../hooks/useUrlState';
|
|||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { useBranches } from '../hooks/useBranches';
|
import { useBranches } from '../hooks/useBranches';
|
||||||
import type { Branch } from '../types';
|
import type { Branch } from '../types';
|
||||||
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* شعبهها — همان محلهای نوبتدهی محیط جاری.
|
* شعبهها — همان محلهای نوبتدهی محیط جاری.
|
||||||
@@ -91,65 +92,67 @@ export default function BranchesPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<SettingsLayout active="branches">
|
||||||
<PageHeader
|
<div className="fade-in">
|
||||||
title="شعبهها و اتاقها"
|
<PageHeader
|
||||||
description="ساعت کاری هر محل نوبتدهی و اتاقهای آن. نام و آدرس شعبه در صفحهٔ همان کلینیک یا پزشک ویرایش میشود."
|
title="شعبهها و اتاقها"
|
||||||
backTo="/admin/settings-menu"
|
description="ساعت کاری هر محل نوبتدهی و اتاقهای آن. نام و آدرس شعبه در صفحهٔ همان کلینیک یا پزشک ویرایش میشود."
|
||||||
/>
|
backTo="/admin/settings-menu"
|
||||||
|
/>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
data={rows}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
searchValue={urlState.search}
|
searchValue={urlState.search}
|
||||||
onSearchChange={(v) => setUrlState({ search: v })}
|
onSearchChange={(v) => setUrlState({ search: v })}
|
||||||
searchPlaceholder="جستجو در شعبهها..."
|
searchPlaceholder="جستجو در شعبهها..."
|
||||||
emptyMessage="هیچ شعبهای برای این محیط ثبت نشده است"
|
emptyMessage="هیچ شعبهای برای این محیط ثبت نشده است"
|
||||||
headerExtra={
|
headerExtra={
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginRight: 'auto' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginRight: 'auto' }}>
|
||||||
<StatusFilter
|
<StatusFilter
|
||||||
value={urlState.status}
|
value={urlState.status}
|
||||||
onChange={(v) => setUrlState({ status: v })}
|
onChange={(v) => setUrlState({ status: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
actions={(b) => (
|
actions={(b) => (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<Link className="btn secondary sm" to={`/admin/branches/${b.uuid}/working-hours`}>
|
<Link className="btn secondary sm" to={`/admin/branches/${b.uuid}/working-hours`}>
|
||||||
<ClockIcon style={{ width: 15 }} /> ساعت کاری
|
<ClockIcon style={{ width: 15 }} /> ساعت کاری
|
||||||
</Link>
|
</Link>
|
||||||
<Link className="btn secondary sm" to={`/admin/branches/${b.uuid}/rooms`}>
|
<Link className="btn secondary sm" to={`/admin/branches/${b.uuid}/rooms`}>
|
||||||
<Squares2X2Icon style={{ width: 15 }} /> اتاقها
|
<Squares2X2Icon style={{ width: 15 }} /> اتاقها
|
||||||
</Link>
|
</Link>
|
||||||
{canUpdate && (
|
{canUpdate && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn secondary sm"
|
className="btn secondary sm"
|
||||||
disabled={update.isPending}
|
disabled={update.isPending}
|
||||||
title={
|
title={
|
||||||
b.active && activeCount === 1
|
b.active && activeCount === 1
|
||||||
? 'با غیرفعال کردن این شعبه، هیچ شعبهٔ فعالی باقی نمیماند'
|
? 'با غیرفعال کردن این شعبه، هیچ شعبهٔ فعالی باقی نمیماند'
|
||||||
: undefined
|
: undefined
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
if (
|
|
||||||
b.active &&
|
|
||||||
activeCount === 1 &&
|
|
||||||
!window.confirm('این تنها شعبهٔ فعال است. با غیرفعال کردن آن، هیچ شعبهٔ فعالی باقی نمیماند. ادامه میدهید؟')
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
update.mutate({ uuid: b.uuid, d: { active: !b.active } });
|
onClick={() => {
|
||||||
}}
|
if (
|
||||||
>
|
b.active &&
|
||||||
{b.active ? 'غیرفعال کردن' : 'فعال کردن'}
|
activeCount === 1 &&
|
||||||
</button>
|
!window.confirm('این تنها شعبهٔ فعال است. با غیرفعال کردن آن، هیچ شعبهٔ فعالی باقی نمیماند. ادامه میدهید؟')
|
||||||
)}
|
) {
|
||||||
</div>
|
return;
|
||||||
)}
|
}
|
||||||
/>
|
update.mutate({ uuid: b.uuid, d: { active: !b.active } });
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
{b.active ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useUrlState } from '../hooks/useUrlState';
|
|||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { useCatalogCategories, useCategoryIncludes } from '../hooks/useCatalogCategories';
|
import { useCatalogCategories, useCategoryIncludes } from '../hooks/useCatalogCategories';
|
||||||
import type { CatalogCategory } from '../types';
|
import type { CatalogCategory } from '../types';
|
||||||
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
|
|
||||||
/** یک ردیف از درخت، صافشده — با عمق، تا تورفتگی نشان دهد کجای درخت است. */
|
/** یک ردیف از درخت، صافشده — با عمق، تا تورفتگی نشان دهد کجای درخت است. */
|
||||||
type Row = CatalogCategory & { depth: number };
|
type Row = CatalogCategory & { depth: number };
|
||||||
@@ -58,81 +59,83 @@ export default function CatalogCategoriesPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<SettingsLayout active="service-categories">
|
||||||
<PageHeader
|
<div className="fade-in">
|
||||||
title="دستهبندیها"
|
<PageHeader
|
||||||
description="دستهبندی سراسری کلینیک؛ یک بار تعریف میشود و سرویسها و منابع از همینها انتخاب میکنند."
|
title="دستهبندیها"
|
||||||
backTo="/admin/settings-menu"
|
description="دستهبندی سراسری کلینیک؛ یک بار تعریف میشود و سرویسها و منابع از همینها انتخاب میکنند."
|
||||||
action={
|
backTo="/admin/settings-menu"
|
||||||
canUpdate ? (
|
action={
|
||||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, category: null })}>
|
canUpdate ? (
|
||||||
<PlusIcon style={{ width: 16 }} /> افزودن دستهبندی
|
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, category: null })}>
|
||||||
</button>
|
<PlusIcon style={{ width: 16 }} /> افزودن دستهبندی
|
||||||
) : undefined
|
</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);
|
|
||||||
}
|
}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<CategoryIncludesModal
|
<DataTable<Row>
|
||||||
category={includesFor}
|
columns={columns}
|
||||||
all={all}
|
data={rows}
|
||||||
onClose={() => setIncludesFor(null)}
|
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
|
<CategoryFormModal
|
||||||
open={!!toDelete}
|
open={editing.open}
|
||||||
title="حذف دستهبندی"
|
category={editing.category}
|
||||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟ سرویسها و منابعی که این دسته را دارند بدون دسته میمانند.`}
|
parents={all}
|
||||||
confirmLabel="حذف"
|
saving={create.isPending || update.isPending}
|
||||||
danger
|
onClose={() => setEditing({ open: false, category: null })}
|
||||||
loading={remove.isPending}
|
onSave={(payload) => {
|
||||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
const done = { onSuccess: () => setEditing({ open: false, category: null }) };
|
||||||
onCancel={() => setToDelete(null)}
|
|
||||||
/>
|
if (editing.category) {
|
||||||
</div>
|
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 { useHolidays } from '../hooks/useResourceCalendar';
|
||||||
import { formatDate } from '../lib/utils';
|
import { formatDate } from '../lib/utils';
|
||||||
import type { NationalHoliday } from '../types';
|
import type { NationalHoliday } from '../types';
|
||||||
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
|
|
||||||
/** سالِ شمسی جاری از همان فرمتکنندهٔ شمسیِ مرورگر گرفته میشود، نه با محاسبهٔ دستی. */
|
/** سالِ شمسی جاری از همان فرمتکنندهٔ شمسیِ مرورگر گرفته میشود، نه با محاسبهٔ دستی. */
|
||||||
function currentJalaliYear(): number {
|
function currentJalaliYear(): number {
|
||||||
@@ -54,69 +55,71 @@ export default function HolidaysSettingsPage() {
|
|||||||
const years = Array.from({ length: 5 }, (_, i) => thisYear - 1 + i);
|
const years = Array.from({ length: 5 }, (_, i) => thisYear - 1 + i);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<SettingsLayout active="holidays">
|
||||||
<PageHeader
|
<div className="fade-in">
|
||||||
title="تعطیلات رسمی"
|
<PageHeader
|
||||||
description="تعطیلات کشوری برای همهٔ محیطها اعمال میشود. اگر این محیط روزی را باز است، همینجا استثنا بزنید."
|
title="تعطیلات رسمی"
|
||||||
backTo="/admin/settings-menu"
|
description="تعطیلات کشوری برای همهٔ محیطها اعمال میشود. اگر این محیط روزی را باز است، همینجا استثنا بزنید."
|
||||||
/>
|
backTo="/admin/settings-menu"
|
||||||
|
/>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={holidays}
|
data={holidays}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage={`برای سال ${year} تعطیلی ثبت نشده است`}
|
emptyMessage={`برای سال ${year} تعطیلی ثبت نشده است`}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
<div style={{ minWidth: 160, marginRight: 'auto' }}>
|
<div style={{ minWidth: 160, marginRight: 'auto' }}>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={years.map((y) => ({ value: String(y), label: String(y) }))}
|
options={years.map((y) => ({ value: String(y), label: String(y) }))}
|
||||||
value={String(year)}
|
value={String(year)}
|
||||||
onChange={(v) => setUrlState({ year: v ? String(v) : String(thisYear) })}
|
onChange={(v) => setUrlState({ year: v ? String(v) : String(thisYear) })}
|
||||||
placeholder="سال"
|
placeholder="سال"
|
||||||
height={36}
|
height={36}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
canUpdate
|
canUpdate
|
||||||
? (h) => {
|
? (h) => {
|
||||||
const override = overrideByDate.get(h.date);
|
const override = overrideByDate.get(h.date);
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
{override?.is_working ? (
|
{override?.is_working ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn secondary sm"
|
className="btn secondary sm"
|
||||||
disabled={removeOverride.isPending}
|
disabled={removeOverride.isPending}
|
||||||
onClick={() => removeOverride.mutate(override.uuid)}
|
onClick={() => removeOverride.mutate(override.uuid)}
|
||||||
>
|
>
|
||||||
تعطیل کن
|
تعطیل کن
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn secondary sm"
|
className="btn secondary sm"
|
||||||
disabled={setOverride.isPending}
|
disabled={setOverride.isPending}
|
||||||
onClick={() => setOverride.mutate({ date: h.date, is_working: true })}
|
onClick={() => setOverride.mutate({ date: h.date, is_working: true })}
|
||||||
>
|
>
|
||||||
این روز بازیم
|
این روز بازیم
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ClosureCard
|
<ClosureCard
|
||||||
overrides={overrides.filter((o) => !o.is_working)}
|
overrides={overrides.filter((o) => !o.is_working)}
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
saving={setOverride.isPending}
|
saving={setOverride.isPending}
|
||||||
onAdd={(date, note) => setOverride.mutate({ date, is_working: false, note })}
|
onAdd={(date, note) => setOverride.mutate({ date, is_working: false, note })}
|
||||||
onRemove={(uuid) => removeOverride.mutate(uuid)}
|
onRemove={(uuid) => removeOverride.mutate(uuid)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { usePermissions } from '../hooks/usePermissions';
|
|||||||
import { useBranches } from '../hooks/useBranches';
|
import { useBranches } from '../hooks/useBranches';
|
||||||
import { usePriceLists, type PriceList, type PriceListItem } from '../hooks/usePriceLists';
|
import { usePriceLists, type PriceList, type PriceListItem } from '../hooks/usePriceLists';
|
||||||
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
||||||
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
uuid?: string;
|
uuid?: string;
|
||||||
@@ -120,236 +121,238 @@ export default function PriceListsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<SettingsLayout active="price-lists">
|
||||||
<PageHeader
|
<div className="fade-in">
|
||||||
title="لیستهای قیمت"
|
<PageHeader
|
||||||
description="قیمت هر خدمت در یک بازهٔ زمانی. لیست تا فعال نشود روی هیچ فاکتوری اثر ندارد."
|
title="لیستهای قیمت"
|
||||||
backTo="/admin/settings-menu"
|
description="قیمت هر خدمت در یک بازهٔ زمانی. لیست تا فعال نشود روی هیچ فاکتوری اثر ندارد."
|
||||||
action={
|
backTo="/admin/settings-menu"
|
||||||
canManage ? (
|
action={
|
||||||
<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) =>
|
|
||||||
canManage ? (
|
canManage ? (
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<button type="button" className="btn primary sm" onClick={() => setDraft(emptyDraft())}>
|
||||||
<button
|
<PlusIcon style={{ width: 15 }} /> لیست تازه
|
||||||
type="button"
|
</button>
|
||||||
className="btn secondary sm"
|
) : undefined
|
||||||
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
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn secondary sm"
|
className="btn secondary sm"
|
||||||
onClick={() => setDraft({ ...draft, items: draft.items.filter((_, i) => i !== index) })}
|
onClick={() =>
|
||||||
aria-label="حذف قیمت"
|
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>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn secondary sm"
|
className="btn primary"
|
||||||
onClick={() =>
|
disabled={
|
||||||
setDraft({ ...draft, items: [...draft.items, { service_uuid: '', price_rials: 0 }] })
|
!draft ||
|
||||||
|
draft.name.trim() === '' ||
|
||||||
|
draft.valid_to <= draft.valid_from ||
|
||||||
|
create.isPending ||
|
||||||
|
update.isPending ||
|
||||||
|
setItems.isPending
|
||||||
}
|
}
|
||||||
|
onClick={save}
|
||||||
>
|
>
|
||||||
<PlusIcon style={{ width: 15 }} /> افزودن قیمت
|
ذخیره
|
||||||
</button>
|
</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)' }}>
|
<div className="field-block">
|
||||||
خدمتی که در این لیست نیاید، قیمتش از تعرفهٔ سال یا خودِ خدمت خوانده میشود —
|
<label>شعبه</label>
|
||||||
پس هیچوقت بیقیمت نمیماند.
|
<SearchableSelect
|
||||||
</span>
|
value={draft.address_uuid ?? ''}
|
||||||
</div>
|
onChange={(v) => setDraft({ ...draft, address_uuid: v ? String(v) : null })}
|
||||||
)}
|
options={[
|
||||||
</Modal>
|
{ value: '', label: 'همهٔ شعبهها' },
|
||||||
</div>
|
...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 ResourceServicesPanel from '../components/resources/ResourceServicesPanel';
|
||||||
import ResourceSkillsPanel from '../components/resources/ResourceSkillsPanel';
|
import ResourceSkillsPanel from '../components/resources/ResourceSkillsPanel';
|
||||||
import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesPanel';
|
import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesPanel';
|
||||||
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
import { useUrlState } from '../hooks/useUrlState';
|
import { useUrlState } from '../hooks/useUrlState';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { useBranches } from '../hooks/useBranches';
|
import { useBranches } from '../hooks/useBranches';
|
||||||
@@ -56,10 +57,20 @@ export default function ResourceDetailPage() {
|
|||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
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 (
|
return (
|
||||||
|
<SettingsLayout active="resources">
|
||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={resource.name}
|
title={resource.name}
|
||||||
@@ -142,6 +153,7 @@ export default function ResourceDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { usePermissions } from '../hooks/usePermissions';
|
|||||||
import { useBranches } from '../hooks/useBranches';
|
import { useBranches } from '../hooks/useBranches';
|
||||||
import { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources';
|
import { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources';
|
||||||
import type { ResourcePool } from '../types';
|
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 (
|
return (
|
||||||
<div className="fade-in">
|
<SettingsLayout active="resources">
|
||||||
<PageHeader
|
<div className="fade-in">
|
||||||
title="استخر منابع"
|
<PageHeader
|
||||||
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید در یک شعبه و از یک نوع باشند."
|
title="استخر منابع"
|
||||||
backTo="/admin/resources"
|
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید در یک شعبه و از یک نوع باشند."
|
||||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'استخر منابع' }]}
|
backTo="/admin/resources"
|
||||||
action={
|
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'استخر منابع' }]}
|
||||||
canUpdate ? (
|
action={
|
||||||
<button type="button" className="btn primary" onClick={() => setCreating(true)}>
|
canUpdate ? (
|
||||||
<PlusIcon style={{ width: 16 }} /> افزودن استخر
|
<button type="button" className="btn primary" onClick={() => setCreating(true)}>
|
||||||
</button>
|
<PlusIcon style={{ width: 16 }} /> افزودن استخر
|
||||||
) : undefined
|
</button>
|
||||||
}
|
) : undefined
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<DataTable
|
<ResourcesSubNav />
|
||||||
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
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CreatePoolModal
|
<DataTable
|
||||||
open={creating}
|
columns={columns}
|
||||||
branches={branches}
|
data={rows}
|
||||||
types={types}
|
loading={loading}
|
||||||
saving={create.isPending}
|
searchValue={urlState.search}
|
||||||
onClose={() => setCreating(false)}
|
onSearchChange={(v) => setUrlState({ search: v })}
|
||||||
onSave={(payload) => create.mutate(payload, { onSuccess: () => setCreating(false) })}
|
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
|
<CreatePoolModal
|
||||||
pool={membersFor}
|
open={creating}
|
||||||
saving={setMembers.isPending}
|
branches={branches}
|
||||||
onClose={() => setMembersFor(null)}
|
types={types}
|
||||||
onSave={(members) =>
|
saving={create.isPending}
|
||||||
membersFor &&
|
onClose={() => setCreating(false)}
|
||||||
setMembers.mutate({ uuid: membersFor.uuid, members }, { onSuccess: () => setMembersFor(null) })
|
onSave={(payload) => create.mutate(payload, { onSuccess: () => setCreating(false) })}
|
||||||
}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<PoolMembersModal
|
||||||
open={!!toDelete}
|
pool={membersFor}
|
||||||
title="حذف استخر"
|
saving={setMembers.isPending}
|
||||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟ منابع عضو حذف نمیشوند.`}
|
onClose={() => setMembersFor(null)}
|
||||||
confirmLabel="حذف"
|
onSave={(members) =>
|
||||||
loading={remove.isPending}
|
membersFor &&
|
||||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
setMembers.mutate({ uuid: membersFor.uuid, members }, { onSuccess: () => setMembersFor(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 { usePermissions } from '../hooks/usePermissions';
|
||||||
import { useResourceTypes } from '../hooks/useResources';
|
import { useResourceTypes } from '../hooks/useResources';
|
||||||
import type { ResourceType } from '../types';
|
import type { ResourceType } from '../types';
|
||||||
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
|
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
||||||
|
|
||||||
/** نوع منبع — کلینیک خودش تعریفش میکند؛ سه نوع سیستمی را backfill میسازد. */
|
/** نوع منبع — کلینیک خودش تعریفش میکند؛ سه نوع سیستمی را backfill میسازد. */
|
||||||
export default function ResourceTypesPage() {
|
export default function ResourceTypesPage() {
|
||||||
@@ -42,73 +44,77 @@ export default function ResourceTypesPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<SettingsLayout active="resources">
|
||||||
<PageHeader
|
<div className="fade-in">
|
||||||
title="نوع منابع"
|
<PageHeader
|
||||||
description="دستهبندی منابع: پزشک، پرسنل، اتاق، دستگاه لیزر، یونیت. نوعهای سیستمی حذف نمیشوند چون پل خودکار منابع به آنها تکیه دارد."
|
title="نوع منابع"
|
||||||
backTo="/admin/resources"
|
description="دستهبندی منابع: پزشک، پرسنل، اتاق، دستگاه لیزر، یونیت. نوعهای سیستمی حذف نمیشوند چون پل خودکار منابع به آنها تکیه دارد."
|
||||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'نوع منابع' }]}
|
backTo="/admin/resources"
|
||||||
action={
|
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'نوع منابع' }]}
|
||||||
canUpdate ? (
|
action={
|
||||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, type: null })}>
|
canUpdate ? (
|
||||||
<PlusIcon style={{ width: 16 }} /> افزودن نوع
|
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, type: null })}>
|
||||||
</button>
|
<PlusIcon style={{ width: 16 }} /> افزودن نوع
|
||||||
) : undefined
|
</button>
|
||||||
}
|
) : undefined
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<DataTable
|
<ResourcesSubNav />
|
||||||
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
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TypeModal
|
<DataTable
|
||||||
open={editing.open}
|
columns={columns}
|
||||||
type={editing.type}
|
data={rows}
|
||||||
saving={create.isPending || update.isPending}
|
loading={loading}
|
||||||
onClose={() => setEditing({ open: false, type: null })}
|
searchValue={urlState.search}
|
||||||
onSave={(payload) => {
|
onSearchChange={(v) => setUrlState({ search: v })}
|
||||||
const opts = { onSuccess: () => setEditing({ open: false, type: null }) };
|
searchPlaceholder="جستجو در نوع منابع..."
|
||||||
if (editing.type) update.mutate({ uuid: editing.type.uuid, d: { name: payload.name, active: payload.active } }, opts);
|
emptyMessage="هنوز نوع منبعی تعریف نشده است"
|
||||||
else create.mutate({ code: payload.code, name: payload.name }, opts);
|
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
|
<TypeModal
|
||||||
open={!!toDelete}
|
open={editing.open}
|
||||||
title="حذف نوع منبع"
|
type={editing.type}
|
||||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
|
saving={create.isPending || update.isPending}
|
||||||
confirmLabel="حذف"
|
onClose={() => setEditing({ open: false, type: null })}
|
||||||
loading={remove.isPending}
|
onSave={(payload) => {
|
||||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
const opts = { onSuccess: () => setEditing({ open: false, type: null }) };
|
||||||
onCancel={() => setToDelete(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);
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import ResourceSkillsModal from '../components/resources/ResourceSkillsModal';
|
|||||||
import ResourceServicesModal from '../components/resources/ResourceServicesModal';
|
import ResourceServicesModal from '../components/resources/ResourceServicesModal';
|
||||||
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
||||||
import type { ClinicResource } from '../types';
|
import type { ClinicResource } from '../types';
|
||||||
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
|
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
||||||
|
|
||||||
/** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */
|
/** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */
|
||||||
const SUBJECT_LABEL: Record<string, string> = {
|
const SUBJECT_LABEL: Record<string, string> = {
|
||||||
@@ -112,160 +114,164 @@ export default function ResourcesPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<SettingsLayout active="resources">
|
||||||
<PageHeader
|
<div className="fade-in">
|
||||||
title="منابع"
|
<PageHeader
|
||||||
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار همزمان."
|
title="منابع"
|
||||||
backTo="/admin/settings-menu"
|
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار همزمان."
|
||||||
action={
|
backTo="/admin/settings-menu"
|
||||||
canUpdate ? (
|
action={
|
||||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, resource: null })}>
|
canUpdate ? (
|
||||||
<PlusIcon style={{ width: 16 }} /> افزودن منبع
|
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, resource: null })}>
|
||||||
</button>
|
<PlusIcon style={{ width: 16 }} /> افزودن منبع
|
||||||
) : undefined
|
</button>
|
||||||
}
|
) : undefined
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<DataTable
|
<ResourcesSubNav />
|
||||||
columns={columns}
|
|
||||||
data={rows}
|
<DataTable
|
||||||
loading={loading}
|
columns={columns}
|
||||||
searchValue={urlState.search}
|
data={rows}
|
||||||
onSearchChange={(v) => setUrlState({ search: v })}
|
loading={loading}
|
||||||
searchPlaceholder="جستجو در منابع..."
|
searchValue={urlState.search}
|
||||||
emptyMessage="هیچ منبعی با این فیلترها یافت نشد"
|
onSearchChange={(v) => setUrlState({ search: v })}
|
||||||
headerExtra={
|
searchPlaceholder="جستجو در منابع..."
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginRight: 'auto' }}>
|
emptyMessage="هیچ منبعی با این فیلترها یافت نشد"
|
||||||
<div style={{ minWidth: 180 }}>
|
headerExtra={
|
||||||
<SearchableSelect
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginRight: 'auto' }}>
|
||||||
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
<div style={{ minWidth: 180 }}>
|
||||||
value={urlState.address || null}
|
<SearchableSelect
|
||||||
onChange={(v) => setUrlState({ address: v ? String(v) : '' })}
|
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||||
placeholder="همهٔ شعبهها"
|
value={urlState.address || null}
|
||||||
isClearable
|
onChange={(v) => setUrlState({ address: v ? String(v) : '' })}
|
||||||
height={36}
|
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>
|
||||||
<div style={{ minWidth: 170 }}>
|
}
|
||||||
<SearchableSelect
|
actions={
|
||||||
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
|
canUpdate
|
||||||
value={urlState.type || null}
|
? (r) => (
|
||||||
onChange={(v) => setUrlState({ type: v ? String(v) : '' })}
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
placeholder="همهٔ نوعها"
|
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, resource: r })}>
|
||||||
isClearable
|
ویرایش
|
||||||
height={36}
|
</button>
|
||||||
/>
|
<button type="button" className="btn secondary sm" onClick={() => setSkillsFor(r)}>
|
||||||
</div>
|
مهارتها
|
||||||
<div style={{ minWidth: 170 }}>
|
</button>
|
||||||
<SearchableSelect
|
<button type="button" className="btn secondary sm" onClick={() => setServicesFor(r)}>
|
||||||
options={skills.map((s) => ({ value: s.uuid, label: s.name }))}
|
سرویسها
|
||||||
value={urlState.skill || null}
|
</button>
|
||||||
onChange={(v) => setUrlState({ skill: v ? String(v) : '' })}
|
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}`}>
|
||||||
placeholder="همهٔ مهارتها"
|
تنظیمات
|
||||||
isClearable
|
</Link>
|
||||||
height={36}
|
{/* مسدودسازی موردی از تقویم جداست: آن الگوی کاری را عوض میکند،
|
||||||
/>
|
این یک بازهٔ مشخص را میبندد. */}
|
||||||
</div>
|
<button type="button" className="btn secondary sm" onClick={() => setBlocksFor(r)}>
|
||||||
<div style={{ minWidth: 140 }}>
|
مسدودسازی
|
||||||
<SearchableSelect
|
</button>
|
||||||
options={[
|
<button type="button" className="btn secondary sm" onClick={() => setToDelete(r)}>
|
||||||
{ value: '1', label: 'فعال' },
|
حذف
|
||||||
{ value: '0', label: 'غیرفعال' },
|
</button>
|
||||||
]}
|
</div>
|
||||||
value={urlState.status || null}
|
)
|
||||||
onChange={(v) => setUrlState({ status: v ? String(v) : '' })}
|
: undefined
|
||||||
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
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ResourceBlocksModal
|
<ResourceBlocksModal
|
||||||
resourceUuid={blocksFor?.uuid ?? null}
|
resourceUuid={blocksFor?.uuid ?? null}
|
||||||
resourceName={blocksFor?.name ?? ''}
|
resourceName={blocksFor?.name ?? ''}
|
||||||
onClose={() => setBlocksFor(null)}
|
onClose={() => setBlocksFor(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResourceFormModal
|
<ResourceFormModal
|
||||||
open={editing.open}
|
open={editing.open}
|
||||||
resource={editing.resource}
|
resource={editing.resource}
|
||||||
branches={branches}
|
branches={branches}
|
||||||
types={types}
|
types={types}
|
||||||
saving={create.isPending || update.isPending}
|
saving={create.isPending || update.isPending}
|
||||||
onClose={() => setEditing({ open: false, resource: null })}
|
onClose={() => setEditing({ open: false, resource: null })}
|
||||||
onSave={(payload) => {
|
onSave={(payload) => {
|
||||||
const opts = { onSuccess: () => setEditing({ open: false, resource: null }) };
|
const opts = { onSuccess: () => setEditing({ open: false, resource: null }) };
|
||||||
if (editing.resource) update.mutate({ uuid: editing.resource.uuid, d: payload }, opts);
|
if (editing.resource) update.mutate({ uuid: editing.resource.uuid, d: payload }, opts);
|
||||||
else create.mutate(payload, opts);
|
else create.mutate(payload, opts);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResourceSkillsModal
|
<ResourceSkillsModal
|
||||||
resource={skillsFor}
|
resource={skillsFor}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
saving={setSkills.isPending}
|
saving={setSkills.isPending}
|
||||||
onClose={() => setSkillsFor(null)}
|
onClose={() => setSkillsFor(null)}
|
||||||
onSave={(lines) =>
|
onSave={(lines) =>
|
||||||
skillsFor &&
|
skillsFor &&
|
||||||
setSkills.mutate({ uuid: skillsFor.uuid, skills: lines }, { onSuccess: () => setSkillsFor(null) })
|
setSkills.mutate({ uuid: skillsFor.uuid, skills: lines }, { onSuccess: () => setSkillsFor(null) })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResourceServicesModal
|
<ResourceServicesModal
|
||||||
resource={servicesFor}
|
resource={servicesFor}
|
||||||
offerings={offerings}
|
offerings={offerings}
|
||||||
services={serviceOptions}
|
services={serviceOptions}
|
||||||
saving={saveServices.isPending}
|
saving={saveServices.isPending}
|
||||||
onClose={() => setServicesFor(null)}
|
onClose={() => setServicesFor(null)}
|
||||||
onSave={(lines) =>
|
onSave={(lines) =>
|
||||||
servicesFor &&
|
servicesFor &&
|
||||||
saveServices.mutate(
|
saveServices.mutate(
|
||||||
{ uuid: servicesFor.uuid, services: lines },
|
{ uuid: servicesFor.uuid, services: lines },
|
||||||
{ onSuccess: () => setServicesFor(null) },
|
{ onSuccess: () => setServicesFor(null) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!toDelete}
|
open={!!toDelete}
|
||||||
title="حذف منبع"
|
title="حذف منبع"
|
||||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
|
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
|
||||||
confirmLabel="حذف"
|
confirmLabel="حذف"
|
||||||
loading={remove.isPending}
|
loading={remove.isPending}
|
||||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
||||||
onCancel={() => setToDelete(null)}
|
onCancel={() => setToDelete(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useUrlState } from '../hooks/useUrlState';
|
|||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { useSkills } from '../hooks/useResources';
|
import { useSkills } from '../hooks/useResources';
|
||||||
import type { Skill } from '../types';
|
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 (
|
return (
|
||||||
<div className="fade-in">
|
<SettingsLayout active="resources">
|
||||||
<PageHeader
|
<div className="fade-in">
|
||||||
title="مهارتها"
|
<PageHeader
|
||||||
description="مهارت روی منابع مینشیند و در انتخاب منبع مناسب استفاده میشود. مهارتی که به منبعی داده شده، تا برداشته نشود حذف نمیشود."
|
title="مهارتها"
|
||||||
backTo="/admin/resources"
|
description="مهارت روی منابع مینشیند و در انتخاب منبع مناسب استفاده میشود. مهارتی که به منبعی داده شده، تا برداشته نشود حذف نمیشود."
|
||||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'مهارتها' }]}
|
backTo="/admin/resources"
|
||||||
action={
|
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'مهارتها' }]}
|
||||||
canUpdate ? (
|
action={
|
||||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, skill: null })}>
|
canUpdate ? (
|
||||||
<PlusIcon style={{ width: 16 }} /> افزودن مهارت
|
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, skill: null })}>
|
||||||
</button>
|
<PlusIcon style={{ width: 16 }} /> افزودن مهارت
|
||||||
) : undefined
|
</button>
|
||||||
}
|
) : undefined
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<DataTable
|
<ResourcesSubNav />
|
||||||
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
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SkillModal
|
<DataTable
|
||||||
open={editing.open}
|
columns={columns}
|
||||||
skill={editing.skill}
|
data={rows}
|
||||||
saving={create.isPending || update.isPending}
|
loading={loading}
|
||||||
onClose={() => setEditing({ open: false, skill: null })}
|
searchValue={urlState.search}
|
||||||
onSave={({ name, active }) => {
|
onSearchChange={(v) => setUrlState({ search: v })}
|
||||||
const opts = { onSuccess: () => setEditing({ open: false, skill: null }) };
|
searchPlaceholder="جستجو در مهارتها..."
|
||||||
if (editing.skill) update.mutate({ uuid: editing.skill.uuid, d: { name, active } }, opts);
|
emptyMessage="هنوز مهارتی تعریف نشده است"
|
||||||
else create.mutate({ name }, opts);
|
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
|
<SkillModal
|
||||||
open={!!toDelete}
|
open={editing.open}
|
||||||
title="حذف مهارت"
|
skill={editing.skill}
|
||||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
|
saving={create.isPending || update.isPending}
|
||||||
confirmLabel="حذف"
|
onClose={() => setEditing({ open: false, skill: null })}
|
||||||
loading={remove.isPending}
|
onSave={({ name, active }) => {
|
||||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
const opts = { onSuccess: () => setEditing({ open: false, skill: null }) };
|
||||||
onCancel={() => setToDelete(null)}
|
if (editing.skill) update.mutate({ uuid: editing.skill.uuid, d: { name, active } }, opts);
|
||||||
/>
|
else create.mutate({ name }, opts);
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: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; }
|
.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 { 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; }
|
/* `a` هم پذیرفته میشود: نوار تبهایی که بین صفحهها جابهجا میکنند باید لینک واقعی باشند
|
||||||
.seg button.on { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
|
(باز کردن در تب جدید، کلیک وسط)، نه دکمهای که 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) */
|
/* همراستا با .mini-btn: ارتفاع لمسیِ حداقلی روی موبایل (WCAG 2.5.5) */
|
||||||
@media (max-width: 640px), (pointer: coarse) {
|
@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 ───────────────────────────────────────────── */
|
/* ── Switch toggle ───────────────────────────────────────────── */
|
||||||
|
|||||||
Reference in New Issue
Block a user