feat(resources): one tabbed page per resource

In the resource-first model a resource is the unit of capacity, so its
working hours, holidays, services, skills and categories belong to it — not
scattered across a list page's modals plus a separate calendar page.

/admin/resources/{uuid} now carries six tabs and the active tab lives in the
query string, so back and refresh land on the same view. The old
/calendar URL redirects to ?tab=hours instead of 404ing.

The skills and services modal bodies became panels the tab renders directly;
the modals are now thin wrappers, so the list page keeps working unchanged
and there is still one implementation of each editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 13:06:06 +03:30
co-authored by Claude Opus 5
parent 03d8d7d68a
commit e08876f7b4
15 changed files with 1126 additions and 705 deletions
@@ -0,0 +1,94 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import SearchableSelect from '../ui/SearchableSelect';
import { useCatalogCategories } from '../../hooks/useCatalogCategories';
import type { CatalogCategory, ClinicResource } from '../../types';
type Flat = { uuid: string; name: string; depth: number };
function flatten(nodes: CatalogCategory[], depth = 0): Flat[] {
return nodes.flatMap((n) => [
{ uuid: n.uuid, name: n.name, depth },
...flatten(n.children ?? [], depth + 1),
]);
}
/**
* دسته‌بندی یک منبع — فقط **انتخاب** از کاتالوگ سراسری.
*
* ساختن دسته اینجا عمداً ممکن نیست؛ تنها جای ساخت «تنظیمات ← دسته‌بندی‌ها» است، وگرنه
* هر کاربر نسخهٔ خودش از «تمام بدن» را می‌سازد.
*/
export default function ResourceCategoriesPanel({ resource, canUpdate, saving, onSave }: {
resource: ClinicResource | null;
canUpdate: boolean;
saving: boolean;
onSave: (categoryUuids: string[]) => void;
}) {
const { tree, loading } = useCatalogCategories();
const [chosen, setChosen] = useState<string[]>([]);
useEffect(() => {
setChosen((resource?.categories ?? []).map((c) => c.uuid));
}, [resource]);
const all = useMemo(() => flatten(tree), [tree]);
const picked = new Set(chosen);
const available = all.filter((c) => !picked.has(c.uuid));
const nameOf = (uuid: string) => all.find((c) => c.uuid === uuid)?.name ?? uuid;
return (
<div className="card card-pad" style={{ display: 'grid', gap: 14 }}>
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
دستهبندی سراسری است و اینجا فقط انتخاب میشود. برای ساخت یا ویرایش به{' '}
<Link to="/admin/service-categories" style={{ color: 'var(--primary)' }}>تنظیمات دستهبندیها</Link>{' '}
بروید.
</p>
{loading ? (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</span>
) : chosen.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>این منبع هیچ دستهبندیای ندارد.</p>
) : (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{chosen.map((uuid) => (
<span key={uuid} className="badge blue" style={{ fontSize: 12, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{nameOf(uuid)}
{canUpdate && (
<button
type="button"
onClick={() => setChosen((c) => c.filter((x) => x !== uuid))}
aria-label={`حذف ${nameOf(uuid)}`}
style={{ background: 'none', border: 0, cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1 }}
>
</button>
)}
</span>
))}
</div>
)}
{canUpdate && available.length > 0 && (
<div style={{ display: 'grid', gap: 6, maxWidth: 380 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>افزودن دستهبندی</label>
<SearchableSelect
options={available.map((c) => ({ value: c.uuid, label: '— '.repeat(c.depth) + c.name }))}
value={null}
onChange={(v) => v && setChosen((c) => [...c, String(v)])}
placeholder="یک دسته‌بندی انتخاب کنید"
height={38}
/>
</div>
)}
{canUpdate && (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button type="button" className="btn primary" disabled={saving} onClick={() => onSave(chosen)}>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,196 @@
import React, { useState } from 'react';
import ConfirmDialog from '../ui/ConfirmDialog';
import SearchableSelect from '../ui/SearchableSelect';
import PersianDateInput from '../ui/PersianDateInput';
import { useResourceAvailability, useResourceExceptions } from '../../hooks/useResourceCalendar';
import { formatDate } from '../../lib/utils';
import { DAY_LABELS } from './ResourceWorkingHoursPanel';
import type { ResourceException } from '../../types';
/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده می‌شد. */
const REASON_LABELS: Record<string, string> = {
national_holiday: 'تعطیل رسمی',
tenant_holiday: 'تعطیلی این محیط',
no_shift: 'شیفتی تعریف نشده',
branch_closed: 'شعبه این روز بسته است',
outside_branch_hours: 'شیفت بیرون از ساعت کاری شعبه',
exception: 'مرخصی یا سرویس',
resource_inactive: 'منبع غیرفعال است',
branch_inactive: 'شعبه غیرفعال است',
};
const EXCEPTION_TYPES = [
{ value: 'leave', label: 'مرخصی' },
{ value: 'absence', label: 'غیبت' },
{ value: 'maintenance', label: 'سرویس دوره‌ای' },
{ value: 'closure', label: 'تعطیلی موردی' },
];
/** نیمه‌شبِ امروز به‌صورت timestamp ثانیه‌ای. */
function todayMidnight(): number {
const d = new Date();
d.setHours(0, 0, 0, 0);
return Math.floor(d.getTime() / 1000);
}
/**
* تعطیلات و استثناهای یک منبع، کنار پیش‌نمایش دو هفتهٔ ساعت آزاد.
*
* پیش‌نمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبت‌های ثبت‌شده در آن
* کسر نشده‌اند و اشتباه گرفتنش با «وقت قابل رزرو» به بیش‌رزروی می‌انجامد.
*/
export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
resourceUuid?: string;
canUpdate: boolean;
}) {
const { exceptions, create, remove } = useResourceExceptions(resourceUuid);
const [toDelete, setToDelete] = useState<ResourceException | null>(null);
const previewFrom = todayMidnight();
const previewTo = previewFrom + 13 * 86400;
const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo);
return (
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', alignItems: 'start' }}>
<ExceptionsCard
exceptions={exceptions}
canUpdate={canUpdate}
saving={create.isPending}
onCreate={(payload) => create.mutate(payload)}
onDelete={setToDelete}
/>
<div className="card" style={{ padding: 14 }}>
<h2 className="section-title" style={{ margin: '0 0 4px' }}>پیشنمایش دو هفته</h2>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px' }}>
ساعت <strong>خام</strong> نوبتهای ثبتشده هنوز از آن کسر نشدهاند.
</p>
<div style={{ display: 'grid', gap: 6 }}>
{(availability?.days ?? []).map((day) => (
<div
key={day.date}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, fontSize: 13 }}
>
<span style={{ color: 'var(--text-2)' }}>
{DAY_LABELS[day.day_of_week]} · {formatDate(day.date * 1000)}
</span>
{day.intervals.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'}
</span>
) : (
<span style={{ fontWeight: 600 }}>{day.total_minutes} دقیقه</span>
)}
</div>
))}
</div>
</div>
<ConfirmDialog
open={!!toDelete}
title="حذف استثنا"
message={`آیا از حذف «${toDelete?.type_label}» مطمئن هستید؟`}
confirmLabel="حذف"
loading={remove.isPending}
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
onCancel={() => setToDelete(null)}
/>
</div>
);
}
function ExceptionsCard({
exceptions, canUpdate, saving, onCreate, onDelete,
}: {
exceptions: ResourceException[];
canUpdate: boolean;
saving: boolean;
onCreate: (payload: { type: string; starts_at: number; ends_at: number; reason?: string | null }) => void;
onDelete: (e: ResourceException) => void;
}) {
const [type, setType] = useState<string>('leave');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [reason, setReason] = useState('');
const toTimestamp = (value: string): number | null => {
if (value === '') return null;
const ms = new Date(`${value}T00:00:00`).getTime();
return Number.isNaN(ms) ? null : Math.floor(ms / 1000);
};
const start = toTimestamp(startDate);
const end = toTimestamp(endDate);
// پایان روزِ انتخاب‌شده، نه آغازش: مرخصیِ «تا سه‌شنبه» شامل خودِ سه‌شنبه است.
const endExclusive = end === null ? null : end + 86400;
const invalid = start === null || endExclusive === null || endExclusive <= start;
return (
<div className="card" style={{ padding: 14 }}>
<h2 className="section-title" style={{ margin: '0 0 10px' }}>مرخصی و سرویس</h2>
{exceptions.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: '0 0 10px' }}>استثنایی ثبت نشده است.</p>
) : (
<div style={{ display: 'grid', gap: 8, marginBottom: 12 }}>
{exceptions.map((e) => (
<div key={e.uuid} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span className="badge amber" style={{ fontSize: 11 }}>{e.type_label}</span>
<span style={{ flex: 1, color: 'var(--text-2)' }}>
{formatDate(e.starts_at * 1000)} تا {formatDate(e.ends_at * 1000)}
{e.reason ? ` · ${e.reason}` : ''}
</span>
{canUpdate && (
<button type="button" className="btn secondary sm" onClick={() => onDelete(e)} aria-label="حذف استثنا">
</button>
)}
</div>
))}
</div>
)}
{canUpdate && (
<div style={{ display: 'grid', gap: 8 }}>
<SearchableSelect
options={EXCEPTION_TYPES}
value={type}
onChange={(v) => setType(v ? String(v) : 'leave')}
placeholder="نوع استثنا"
height={36}
/>
{/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی می‌گوید و
ترجمهٔ ذهنی همان‌جایی است که استثنا یک روز جابه‌جا ثبت می‌شود. */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<PersianDateInput value={startDate} onChange={setStartDate} placeholder="از تاریخ" />
</div>
<div style={{ flex: 1 }}>
<PersianDateInput value={endDate} onChange={setEndDate} placeholder="تا تاریخ" />
</div>
</div>
<input className="field" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="توضیح (اختیاری)" />
<button
type="button"
className="btn secondary"
disabled={saving || invalid}
onClick={() => {
onCreate({
type,
starts_at: start!,
ends_at: endExclusive!,
reason: reason.trim() === '' ? null : reason.trim(),
});
setStartDate('');
setEndDate('');
setReason('');
}}
>
{saving ? 'در حال ثبت...' : 'ثبت استثنا'}
</button>
</div>
)}
</div>
);
}
@@ -1,215 +1,31 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import Modal from '../ui/Modal';
import SearchableSelect from '../ui/SearchableSelect';
import { formatRial } from '../../lib/utils';
import ResourceServicesPanel, { type ServiceOfferingLine } from './ResourceServicesPanel';
import type { ClinicResource, ResourceServiceOffering, ServiceItemOption } from '../../types';
type Line = {
service_uuid: string;
service_name: string;
duration_minutes: string;
price_rials: string;
active: boolean;
effective_duration_minutes: number | null;
effective_price_rials: number;
duration_source: string | null;
price_source: string;
};
interface Props {
resource: ClinicResource | null;
offerings: ResourceServiceOffering[];
services: ServiceItemOption[];
saving: boolean;
onClose: () => void;
onSave: (lines: Array<{ service_uuid: string; duration_minutes: string; price_rials: string; active: boolean }>) => void;
onSave: (lines: ServiceOfferingLine[]) => void;
}
/** برچسب فارسیِ سطحی که مقدار مؤثر از آن آمده. */
const SOURCE_LABELS: Record<string, string> = {
resource_option: 'همین منبع',
resource_service: 'منبع، روی سرویس والد',
branch: 'شعبه',
service_default: 'پیش‌فرض سرویس',
};
/**
* سرویس‌هایی که یک منبع ارائه می‌دهد، با مدت و قیمت اختصاصی.
*
* خالی‌گذاشتن مدت یا قیمت یعنی «ارث از سطح بالاتر»، نه صفر — به همین دلیل مقدار مؤثر
* به‌صورت placeholder با برچسب منبعش نشان داده می‌شود، وگرنه کاربر نمی‌فهمد خانهٔ خالی
* یعنی «تنظیم نشده» یا «رایگان».
*
* ذخیره یک PUT است و **جایگزینی کامل**، همان قرارداد مودال مهارت‌ها.
*/
export default function ResourceServicesModal({ resource, offerings, services, saving, onClose, onSave }: Props) {
const [lines, setLines] = useState<Line[]>([]);
useEffect(() => {
if (!resource) return;
setLines(
offerings.map((o) => ({
service_uuid: o.service_uuid,
service_name: o.service_name,
duration_minutes: o.duration_minutes === null ? '' : String(o.duration_minutes),
price_rials: o.price_rials === null ? '' : String(o.price_rials),
active: o.active,
effective_duration_minutes: o.effective_duration_minutes,
effective_price_rials: o.effective_price_rials,
duration_source: o.duration_source,
price_source: o.price_source,
})),
);
}, [resource, offerings]);
const chosen = new Set(lines.map((l) => l.service_uuid));
const available = services.filter((s) => !chosen.has(s.uuid));
const patch = (index: number, changes: Partial<Line>) =>
setLines((l) => l.map((x, i) => (i === index ? { ...x, ...changes } : x)));
/** همان پنل سرویس‌ها، در قاب مودالِ فهرست منابع. */
export default function ResourceServicesModal({
resource, offerings, services, saving, onClose, onSave,
}: Props) {
return (
<Modal
open={resource !== null}
onClose={onClose}
title={`سرویس‌های ${resource?.name ?? 'منبع'}`}
>
<div style={{ display: 'grid', gap: 14 }}>
{services.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
هنوز هیچ سرویسی تعریف نشده است. اول از صفحهٔ «سرویسها» یکی بسازید.
</p>
)}
{lines.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
این منبع هیچ سرویسی ارائه نمیدهد.
</p>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{lines.map((line, index) => (
<div
key={line.service_uuid}
style={{
display: 'grid',
gap: 8,
padding: 10,
borderRadius: 'var(--r-sm)',
background: 'var(--surface-2)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{line.service_name}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
<input
type="checkbox"
checked={line.active}
onChange={(e) => patch(index, { active: e.target.checked })}
/>
فعال
</label>
<button
type="button"
className="btn secondary sm"
onClick={() => setLines((l) => l.filter((_, i) => i !== index))}
aria-label={`حذف ${line.service_name}`}
>
</button>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<div className="field-block" style={{ flex: 1 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>مدت (دقیقه)</label>
<input
className="field"
inputMode="numeric"
value={line.duration_minutes}
onChange={(e) => patch(index, { duration_minutes: e.target.value })}
placeholder={
line.effective_duration_minutes === null
? 'تعیین نشده'
: `${line.effective_duration_minutes}${SOURCE_LABELS[line.duration_source ?? ''] ?? '—'}`
}
/>
</div>
<div className="field-block" style={{ flex: 1 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>قیمت (ریال)</label>
<input
className="field"
inputMode="numeric"
value={line.price_rials}
onChange={(e) => patch(index, { price_rials: e.target.value })}
placeholder={`${formatRial(line.effective_price_rials)}${SOURCE_LABELS[line.price_source] ?? '—'}`}
/>
</div>
</div>
</div>
))}
</div>
)}
{available.length > 0 && (
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>افزودن سرویس</label>
<SearchableSelect
options={available.map((s) => ({ value: s.uuid, label: s.name }))}
value={null}
onChange={(v) => {
const picked = services.find((s) => s.uuid === String(v));
if (!picked) return;
setLines((l) => [
...l,
{
service_uuid: picked.uuid,
service_name: picked.name,
duration_minutes: '',
price_rials: '',
active: true,
effective_duration_minutes: picked.duration_minutes ?? null,
effective_price_rials: picked.price_rials ?? 0,
duration_source: 'service_default',
price_source: 'service_default',
},
]);
}}
placeholder="یک سرویس انتخاب کنید"
height={38}
/>
</div>
)}
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
خالی گذاشتن مدت یا قیمت یعنی همان مقدارِ سطح بالاتر استفاده شود. ذخیره کل فهرست را
جایگزین میکند؛ سرویسی که اینجا نباشد از این منبع برداشته میشود.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
<button
type="button"
className="btn primary"
disabled={saving}
onClick={() =>
onSave(
lines.map((l) => ({
service_uuid: l.service_uuid,
duration_minutes: l.duration_minutes.trim(),
price_rials: l.price_rials.trim(),
active: l.active,
})),
)
}
>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
<Modal open={resource !== null} onClose={onClose} title={`سرویس‌های ${resource?.name ?? 'منبع'}`}>
<ResourceServicesPanel
resource={resource}
offerings={offerings}
services={services}
saving={saving}
onCancel={onClose}
onSave={onSave}
/>
</Modal>
);
}
@@ -0,0 +1,220 @@
import React, { useEffect, useState } from 'react';
import SearchableSelect from '../ui/SearchableSelect';
import { formatRial } from '../../lib/utils';
import type { ClinicResource, ResourceServiceOffering, ServiceItemOption } from '../../types';
type Line = {
service_uuid: string;
service_name: string;
duration_minutes: string;
price_rials: string;
active: boolean;
effective_duration_minutes: number | null;
effective_price_rials: number;
duration_source: string | null;
price_source: string;
};
export type ServiceOfferingLine = {
service_uuid: string;
duration_minutes: string;
price_rials: string;
active: boolean;
};
interface Props {
resource: ClinicResource | null;
offerings: ResourceServiceOffering[];
services: ServiceItemOption[];
saving: boolean;
/** وقتی داده نشود دکمهٔ «انصراف» نمایش داده نمی‌شود — حالت تب. */
onCancel?: () => void;
onSave: (lines: ServiceOfferingLine[]) => void;
}
/** برچسب فارسیِ سطحی که مقدار مؤثر از آن آمده. */
const SOURCE_LABELS: Record<string, string> = {
resource_option: 'همین منبع',
resource_service: 'منبع، روی سرویس والد',
branch: 'شعبه',
service_default: 'پیش‌فرض سرویس',
};
/**
* سرویس‌هایی که یک منبع ارائه می‌دهد، با مدت و قیمت اختصاصی.
*
* خالی‌گذاشتن مدت یا قیمت یعنی «ارث از سطح بالاتر»، نه صفر — به همین دلیل مقدار مؤثر
* به‌صورت placeholder با برچسب منبعش نشان داده می‌شود، وگرنه کاربر نمی‌فهمد خانهٔ خالی
* یعنی «تنظیم نشده» یا «رایگان».
*
* ذخیره یک PUT است و **جایگزینی کامل**، همان قرارداد پنل مهارت‌ها.
*/
export default function ResourceServicesPanel({
resource, offerings, services, saving, onCancel, onSave,
}: Props) {
const [lines, setLines] = useState<Line[]>([]);
useEffect(() => {
if (!resource) return;
setLines(
offerings.map((o) => ({
service_uuid: o.service_uuid,
service_name: o.service_name,
duration_minutes: o.duration_minutes === null ? '' : String(o.duration_minutes),
price_rials: o.price_rials === null ? '' : String(o.price_rials),
active: o.active,
effective_duration_minutes: o.effective_duration_minutes,
effective_price_rials: o.effective_price_rials,
duration_source: o.duration_source,
price_source: o.price_source,
})),
);
}, [resource, offerings]);
const chosen = new Set(lines.map((l) => l.service_uuid));
const available = services.filter((s) => !chosen.has(s.uuid));
const patch = (index: number, changes: Partial<Line>) =>
setLines((l) => l.map((x, i) => (i === index ? { ...x, ...changes } : x)));
return (
<div style={{ display: 'grid', gap: 14 }}>
{services.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
هنوز هیچ سرویسی تعریف نشده است. اول از صفحهٔ «سرویسها» یکی بسازید.
</p>
)}
{lines.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
این منبع هیچ سرویسی ارائه نمیدهد.
</p>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{lines.map((line, index) => (
<div
key={line.service_uuid}
style={{
display: 'grid',
gap: 8,
padding: 10,
borderRadius: 'var(--r-sm)',
background: 'var(--surface-2)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{line.service_name}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
<input
type="checkbox"
checked={line.active}
onChange={(e) => patch(index, { active: e.target.checked })}
/>
فعال
</label>
<button
type="button"
className="btn secondary sm"
onClick={() => setLines((l) => l.filter((_, i) => i !== index))}
aria-label={`حذف ${line.service_name}`}
>
</button>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<div className="field-block" style={{ flex: 1 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>مدت (دقیقه)</label>
<input
className="field"
inputMode="numeric"
value={line.duration_minutes}
onChange={(e) => patch(index, { duration_minutes: e.target.value })}
placeholder={
line.effective_duration_minutes === null
? 'تعیین نشده'
: `${line.effective_duration_minutes}${SOURCE_LABELS[line.duration_source ?? ''] ?? '—'}`
}
/>
</div>
<div className="field-block" style={{ flex: 1 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>قیمت (ریال)</label>
<input
className="field"
inputMode="numeric"
value={line.price_rials}
onChange={(e) => patch(index, { price_rials: e.target.value })}
placeholder={`${formatRial(line.effective_price_rials)}${SOURCE_LABELS[line.price_source] ?? '—'}`}
/>
</div>
</div>
</div>
))}
</div>
)}
{available.length > 0 && (
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>افزودن سرویس</label>
<SearchableSelect
options={available.map((s) => ({ value: s.uuid, label: s.name }))}
value={null}
onChange={(v) => {
const picked = services.find((s) => s.uuid === String(v));
if (!picked) return;
setLines((l) => [
...l,
{
service_uuid: picked.uuid,
service_name: picked.name,
duration_minutes: '',
price_rials: '',
active: true,
effective_duration_minutes: picked.duration_minutes ?? null,
effective_price_rials: picked.price_rials ?? 0,
duration_source: 'service_default',
price_source: 'service_default',
},
]);
}}
placeholder="یک سرویس انتخاب کنید"
height={38}
/>
</div>
)}
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
خالی گذاشتن مدت یا قیمت یعنی همان مقدارِ سطح بالاتر استفاده شود. ذخیره کل فهرست را
جایگزین میکند؛ سرویسی که اینجا نباشد از این منبع برداشته میشود.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
{onCancel && (
<button type="button" className="btn secondary" onClick={onCancel}>انصراف</button>
)}
<button
type="button"
className="btn primary"
disabled={saving}
onClick={() =>
onSave(
lines.map((l) => ({
service_uuid: l.service_uuid,
duration_minutes: l.duration_minutes.trim(),
price_rials: l.price_rials.trim(),
active: l.active,
})),
)
}
>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
);
}
@@ -1,104 +1,27 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import Modal from '../ui/Modal';
import SearchableSelect from '../ui/SearchableSelect';
import ResourceSkillsPanel, { type SkillLine } from './ResourceSkillsPanel';
import type { ClinicResource, Skill } from '../../types';
type Line = { skill_uuid: string; level: number };
interface Props {
resource: ClinicResource | null;
skills: Skill[];
saving: boolean;
onClose: () => void;
onSave: (lines: Line[]) => void;
onSave: (lines: SkillLine[]) => void;
}
/**
* مهارت‌های یک منبع. ذخیره یک PUT است و **جایگزینی کامل**: مهارتی که اینجا نباشد،
* از منبع برداشته می‌شود.
*/
/** همان پنل مهارت‌ها، در قاب مودالِ فهرست منابع. */
export default function ResourceSkillsModal({ resource, skills, saving, onClose, onSave }: Props) {
const [lines, setLines] = useState<Line[]>([]);
useEffect(() => {
if (!resource) return;
setLines(resource.skills.map((s) => ({ skill_uuid: s.skill_uuid, level: s.level })));
}, [resource]);
const chosen = new Set(lines.map((l) => l.skill_uuid));
const available = skills.filter((s) => !chosen.has(s.uuid));
const nameOf = (uuid: string) => skills.find((s) => s.uuid === uuid)?.name ?? uuid;
return (
<Modal
open={resource !== null}
onClose={onClose}
title={`مهارت‌های ${resource?.name ?? 'منبع'}`}
>
<div style={{ display: 'grid', gap: 14 }}>
{skills.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
هنوز هیچ مهارتی تعریف نشده است. اول از صفحهٔ «مهارتها» یکی بسازید.
</p>
)}
{lines.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>این منبع هیچ مهارتی ندارد.</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{lines.map((line, index) => (
<div key={line.skill_uuid} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{nameOf(line.skill_uuid)}</span>
<label style={{ fontSize: 12, color: 'var(--text-3)' }}>سطح</label>
<div style={{ width: 92 }}>
<SearchableSelect
options={[1, 2, 3, 4, 5].map((lv) => ({ value: String(lv), label: String(lv) }))}
value={String(line.level)}
onChange={(v) =>
setLines((l) => l.map((x, i) => (i === index ? { ...x, level: Number(v) || 1 } : x)))
}
placeholder="سطح"
height={36}
/>
</div>
<button
type="button"
className="btn secondary sm"
onClick={() => setLines((l) => l.filter((_, i) => i !== index))}
aria-label={`حذف ${nameOf(line.skill_uuid)}`}
>
</button>
</div>
))}
</div>
)}
{available.length > 0 && (
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>افزودن مهارت</label>
<SearchableSelect
options={available.map((s) => ({ value: s.uuid, label: s.name }))}
value={null}
onChange={(v) => v && setLines((l) => [...l, { skill_uuid: String(v), level: 1 }])}
placeholder="یک مهارت انتخاب کنید"
height={38}
/>
</div>
)}
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
ذخیره کل فهرست را جایگزین میکند؛ مهارتی که اینجا نباشد از منبع برداشته میشود.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
<button type="button" className="btn primary" disabled={saving} onClick={() => onSave(lines)}>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
<Modal open={resource !== null} onClose={onClose} title={`مهارت‌های ${resource?.name ?? 'منبع'}`}>
<ResourceSkillsPanel
resource={resource}
skills={skills}
saving={saving}
onCancel={onClose}
onSave={onSave}
/>
</Modal>
);
}
@@ -0,0 +1,103 @@
import React, { useEffect, useState } from 'react';
import SearchableSelect from '../ui/SearchableSelect';
import type { ClinicResource, Skill } from '../../types';
export type SkillLine = { skill_uuid: string; level: number };
interface Props {
resource: ClinicResource | null;
skills: Skill[];
saving: boolean;
/** وقتی داده نشود دکمهٔ «انصراف» نمایش داده نمی‌شود — حالت تب، که جایی برای بستن ندارد. */
onCancel?: () => void;
onSave: (lines: SkillLine[]) => void;
}
/**
* مهارت‌های یک منبع. ذخیره یک PUT است و **جایگزینی کامل**: مهارتی که اینجا نباشد،
* از منبع برداشته می‌شود.
*
* بدنه از مودال جدا شده تا هم در فهرست منابع (مودال) و هم در تب منبع بدون تکرار
* استفاده شود.
*/
export default function ResourceSkillsPanel({ resource, skills, saving, onCancel, onSave }: Props) {
const [lines, setLines] = useState<SkillLine[]>([]);
useEffect(() => {
if (!resource) return;
setLines(resource.skills.map((s) => ({ skill_uuid: s.skill_uuid, level: s.level })));
}, [resource]);
const chosen = new Set(lines.map((l) => l.skill_uuid));
const available = skills.filter((s) => !chosen.has(s.uuid));
const nameOf = (uuid: string) => skills.find((s) => s.uuid === uuid)?.name ?? uuid;
return (
<div style={{ display: 'grid', gap: 14 }}>
{skills.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
هنوز هیچ مهارتی تعریف نشده است. اول از صفحهٔ «مهارتها» یکی بسازید.
</p>
)}
{lines.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>این منبع هیچ مهارتی ندارد.</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{lines.map((line, index) => (
<div key={line.skill_uuid} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{nameOf(line.skill_uuid)}</span>
<label style={{ fontSize: 12, color: 'var(--text-3)' }}>سطح</label>
<div style={{ width: 92 }}>
<SearchableSelect
options={[1, 2, 3, 4, 5].map((lv) => ({ value: String(lv), label: String(lv) }))}
value={String(line.level)}
onChange={(v) =>
setLines((l) => l.map((x, i) => (i === index ? { ...x, level: Number(v) || 1 } : x)))
}
placeholder="سطح"
height={36}
/>
</div>
<button
type="button"
className="btn secondary sm"
onClick={() => setLines((l) => l.filter((_, i) => i !== index))}
aria-label={`حذف ${nameOf(line.skill_uuid)}`}
>
</button>
</div>
))}
</div>
)}
{available.length > 0 && (
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>افزودن مهارت</label>
<SearchableSelect
options={available.map((s) => ({ value: s.uuid, label: s.name }))}
value={null}
onChange={(v) => v && setLines((l) => [...l, { skill_uuid: String(v), level: 1 }])}
placeholder="یک مهارت انتخاب کنید"
height={38}
/>
</div>
)}
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
ذخیره کل فهرست را جایگزین میکند؛ مهارتی که اینجا نباشد از منبع برداشته میشود.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
{onCancel && (
<button type="button" className="btn secondary" onClick={onCancel}>انصراف</button>
)}
<button type="button" className="btn primary" disabled={saving} onClick={() => onSave(lines)}>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
);
}
@@ -0,0 +1,188 @@
import React, { useEffect, useMemo, useState } from 'react';
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import { useResourceCalendar } from '../../hooks/useResourceCalendar';
/** ۰ = شنبه — همان قرارداد بک‌اند و ساعت کاری شعبه. */
export const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
const MINUTES_IN_DAY = 1440;
type Draft = { start: string; end: string; endOfDay: boolean };
function toTime(minute: number): string {
return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`;
}
function toMinutes(time: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
if (!m) return null;
const minutes = Number(m[1]) * 60 + Number(m[2]);
return minutes >= 0 && minutes <= MINUTES_IN_DAY ? minutes : null;
}
/**
* شیفت هفتگی یک منبع — روزهای کاری و ساعت هر روز.
*
* ساعت واقعی منبع تقاطع این شیفت‌ها با ساعت کاری شعبه است، نه خودشان؛ پس شیفتِ
* بیرون از ساعت شعبه ذخیره می‌شود ولی در دسترس‌پذیری اثری ندارد.
*/
export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
resourceUuid?: string;
canUpdate: boolean;
}) {
const { calendar, loading, save } = useResourceCalendar(resourceUuid);
const [draft, setDraft] = useState<Record<number, Draft[]>>({});
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!calendar) return;
const next: Record<number, Draft[]> = {};
DAY_LABELS.forEach((_, day) => {
next[day] = (calendar.days[String(day)] ?? []).map((r) => ({
start: toTime(r.start_minute),
end: r.end_minute === MINUTES_IN_DAY ? '23:59' : toTime(r.end_minute),
endOfDay: r.end_minute === MINUTES_IN_DAY,
}));
});
setDraft(next);
}, [calendar]);
const totalShifts = useMemo(
() => Object.values(draft).reduce((sum, rows) => sum + rows.length, 0),
[draft],
);
const editRange = (day: number, index: number, patch: Partial<Draft>) =>
setDraft((d) => ({ ...d, [day]: (d[day] ?? []).map((r, i) => (i === index ? { ...r, ...patch } : r)) }));
const submit = () => {
const days: Record<string, { start_minute: number; end_minute: number }[]> = {};
for (const [dayKey, rows] of Object.entries(draft)) {
const parsed: { start_minute: number; end_minute: number }[] = [];
for (const row of rows) {
const start = toMinutes(row.start);
const end = row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end);
if (start === null || end === null) {
setError(`ساعت روز ${DAY_LABELS[Number(dayKey)]} را به شکل ۰۹:۰۰ وارد کنید`);
return;
}
if (end <= start) {
setError(`در روز ${DAY_LABELS[Number(dayKey)]} پایان شیفت باید بعد از شروع آن باشد`);
return;
}
parsed.push({ start_minute: start, end_minute: end });
}
days[dayKey] = parsed;
}
setError(null);
save.mutate(days);
};
return (
<div style={{ display: 'grid', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)' }}>
روزهای کاری و ساعت هر روز. ساعت واقعی از تقاطع این شیفتها با ساعت کاری شعبه بهدست
میآید و تعطیلات و مرخصی از آن کسر میشود.
{totalShifts > 0 && <> · {totalShifts} شیفت</>}
</p>
{canUpdate && (
<button type="button" className="btn primary sm" disabled={save.isPending} onClick={submit}>
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ شیفت‌ها'}
</button>
)}
</div>
{error && (
<div
className="card"
style={{ padding: '12px 16px', color: 'var(--danger)', background: 'var(--danger-bg)', fontSize: 13 }}
>
{error}
</div>
)}
{loading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : (
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))' }}>
{DAY_LABELS.map((label, day) => {
const rows = draft[day] ?? [];
return (
<div key={day} className="card" style={{ padding: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: rows.length ? 10 : 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 700, fontSize: 14 }}>{label}</span>
{rows.length === 0 && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>بدون شیفت</span>}
</div>
{canUpdate && (
<button
type="button"
className="btn secondary sm"
onClick={() => setDraft((d) => ({ ...d, [day]: [...(d[day] ?? []), { start: '09:00', end: '17:00', endOfDay: false }] }))}
>
<PlusIcon style={{ width: 15 }} /> شیفت
</button>
)}
</div>
<div style={{ display: 'grid', gap: 8 }}>
{rows.map((row, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<input
type="time"
className="field"
value={row.start}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { start: e.target.value })}
style={{ width: 116 }}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>تا</span>
{row.endOfDay ? (
<span className="field" style={{ width: 116, color: 'var(--text-2)' }}>۲۴:۰۰</span>
) : (
<input
type="time"
className="field"
value={row.end}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { end: e.target.value })}
style={{ width: 116 }}
/>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'var(--text-3)' }}>
<input
type="checkbox"
checked={row.endOfDay}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { endOfDay: e.target.checked })}
/>
تا پایان روز
</label>
{canUpdate && (
<button
type="button"
className="btn secondary sm"
onClick={() => setDraft((d) => ({ ...d, [day]: (d[day] ?? []).filter((_, i) => i !== index) }))}
aria-label="حذف شیفت"
>
<TrashIcon style={{ width: 15 }} />
</button>
)}
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
}