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:
+11
-3
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { Routes, Route, Navigate, useLocation, useParams } from 'react-router-dom';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { usePermissions } from './hooks/usePermissions';
|
||||
import AdminLayout from './components/layout/AdminLayout';
|
||||
@@ -84,7 +84,7 @@ import ResourceTypesPage from './pages/ResourceTypesPage';
|
||||
import CatalogCategoriesPage from './pages/CatalogCategoriesPage';
|
||||
import SkillsPage from './pages/SkillsPage';
|
||||
import ResourcePoolsPage from './pages/ResourcePoolsPage';
|
||||
import ResourceCalendarPage from './pages/ResourceCalendarPage';
|
||||
import ResourceDetailPage from './pages/ResourceDetailPage';
|
||||
import HolidaysSettingsPage from './pages/HolidaysSettingsPage';
|
||||
import PatientRecordFormPage from './pages/PatientRecordFormPage';
|
||||
import PatientDetailPage from './pages/PatientDetailPage';
|
||||
@@ -132,6 +132,12 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** `/resources/{uuid}/calendar` قدیمی → تب «ساعات کاری» صفحهٔ منبع. */
|
||||
function ResourceCalendarRedirect() {
|
||||
const { resourceUuid } = useParams<{ resourceUuid: string }>();
|
||||
return <Navigate to={`/admin/resources/${resourceUuid}?tab=hours`} replace />;
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
|
||||
@@ -297,7 +303,9 @@ export default function App() {
|
||||
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
|
||||
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
|
||||
<Route path="resources/pools" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcePoolsPage /></RoleRoute>} />
|
||||
<Route path="resources/:resourceUuid/calendar" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceCalendarPage /></RoleRoute>} />
|
||||
<Route path="resources/:resourceUuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceDetailPage /></RoleRoute>} />
|
||||
{/* تقویم منبع در تب «ساعات کاری» همان صفحه حل شده؛ لینکهای قدیمی نباید بشکنند. */}
|
||||
<Route path="resources/:resourceUuid/calendar" element={<ResourceCalendarRedirect />} />
|
||||
<Route path="holidays" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><HolidaysSettingsPage /></RoleRoute>} />
|
||||
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['sms', 'view']}><SmsWalletPage /></RoleRoute>} />
|
||||
<Route path="my-secretaries" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><MySecretariesPage /></RoleRoute>} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -70,10 +70,22 @@ export function useResources(filters: ResourceFilters = {}) {
|
||||
onError: (e) => fail(e, 'ذخیرهٔ مهارتها ناموفق بود'),
|
||||
});
|
||||
|
||||
/** دستهٔ منبع از کاتالوگ سراسری میآید؛ ساختنش فقط در «تنظیمات ← دستهبندیها» ممکن است. */
|
||||
const setCategories = useMutation({
|
||||
mutationFn: ({ uuid, categoryUuids }: { uuid: string; categoryUuids: string[] }) =>
|
||||
api.put<ApiResponse<ClinicResource>>(`/api/v1/resource/${uuid}/categories`, { category_uuids: categoryUuids }),
|
||||
onSuccess: () => {
|
||||
toast.success('دستهبندیهای منبع ذخیره شد');
|
||||
invalidate();
|
||||
qc.invalidateQueries({ queryKey: ['resource-detail'] });
|
||||
},
|
||||
onError: (e) => fail(e, 'ذخیرهٔ دستهبندیها ناموفق بود'),
|
||||
});
|
||||
|
||||
return {
|
||||
resources: query.data?.data ?? [],
|
||||
loading: query.isLoading,
|
||||
create, update, remove, setSkills,
|
||||
create, update, remove, setSkills, setCategories,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import {
|
||||
useResourceAvailability, useResourceCalendar, useResourceExceptions,
|
||||
} from '../hooks/useResourceCalendar';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import type { ResourceException } from '../types';
|
||||
|
||||
/** ۰ = شنبه — همان قرارداد بکاند و ساعت کاری شعبه. */
|
||||
const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
|
||||
|
||||
const MINUTES_IN_DAY = 1440;
|
||||
|
||||
/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده میشد. */
|
||||
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: 'تعطیلی موردی' },
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** نیمهشبِ امروز بهصورت timestamp ثانیهای. */
|
||||
function todayMidnight(): number {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* تقویم یک منبع: شیفت هفتگی، استثناها، و پیشنمایش ساعت آزاد.
|
||||
*
|
||||
* پیشنمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبتهای ثبتشده در آن
|
||||
* کسر نشدهاند و اشتباه گرفتنش با «وقت قابل رزرو» به بیشرزروی میانجامد.
|
||||
*/
|
||||
export default function ResourceCalendarPage() {
|
||||
const { resourceUuid } = useParams<{ resourceUuid: string }>();
|
||||
const { calendar, loading, save } = useResourceCalendar(resourceUuid);
|
||||
const { exceptions, create, remove } = useResourceExceptions(resourceUuid);
|
||||
const { resources } = useResources();
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('appointment_settings', 'update');
|
||||
|
||||
const resource = resources.find((r) => r.uuid === resourceUuid);
|
||||
|
||||
const [draft, setDraft] = useState<Record<number, Draft[]>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toDelete, setToDelete] = useState<ResourceException | null>(null);
|
||||
|
||||
const previewFrom = todayMidnight();
|
||||
const previewTo = previewFrom + 13 * 86400;
|
||||
const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo);
|
||||
|
||||
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 className="fade-in">
|
||||
<PageHeader
|
||||
title={`تقویم ${resource?.name ?? 'منبع'}`}
|
||||
description="شیفت هفتگی منبع. ساعت واقعی از تقاطع این شیفتها با ساعت کاری شعبه بهدست میآید و تعطیلات و مرخصی از آن کسر میشود."
|
||||
backTo="/admin/resources"
|
||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'تقویم' }]}
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" disabled={save.isPending} onClick={submit}>
|
||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ شیفتها'}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="card"
|
||||
style={{ padding: '12px 16px', marginBottom: 16, color: 'var(--danger)', background: 'var(--danger-bg)', fontSize: 13 }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))' }}>
|
||||
<section style={{ display: 'grid', gap: 12 }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>
|
||||
شیفت هفتگی {totalShifts > 0 && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>({totalShifts} شیفت)</span>}
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
) : (
|
||||
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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ display: 'grid', gap: 12, alignContent: '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>
|
||||
</section>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+69
-27
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
@@ -11,15 +12,38 @@ vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () =>
|
||||
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { api } from '../lib/api';
|
||||
import ResourceCalendarPage from './ResourceCalendarPage';
|
||||
import ResourceDetailPage from './ResourceDetailPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const resource = {
|
||||
uuid: 'r1',
|
||||
name: 'لیزر دایود',
|
||||
address_uuid: 'a1',
|
||||
address_name: 'شعبهٔ مرکزی',
|
||||
type_uuid: 't1',
|
||||
type_code: 'device',
|
||||
type_name: 'دستگاه لیزر',
|
||||
capacity: 1,
|
||||
setup_minutes: 5,
|
||||
cleanup_minutes: 10,
|
||||
attributes: {},
|
||||
subject_kind: null,
|
||||
subject_uuid: null,
|
||||
skills: [{ skill_uuid: 's1', skill_name: 'کار با لیزر', level: 4 }],
|
||||
categories: [{ uuid: 'c-hand', name: 'دست' }],
|
||||
active: true,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
upcoming_appointments: 0,
|
||||
};
|
||||
|
||||
const emptyDays = (): Record<string, unknown[]> =>
|
||||
Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), [] as unknown[]]));
|
||||
|
||||
function mockApi(days: Record<string, unknown[]>, availabilityDays: unknown[]) {
|
||||
function mockApi(days: Record<string, unknown[]> = emptyDays(), availabilityDays: unknown[] = []) {
|
||||
get.mockImplementation((path: string) => {
|
||||
if (path === '/api/v1/resource/r1') return Promise.resolve({ success: true, data: resource });
|
||||
if (path.endsWith('/calendar')) {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
@@ -32,38 +56,52 @@ function mockApi(days: Record<string, unknown[]>, availabilityDays: unknown[]) {
|
||||
data: { resource_uuid: 'r1', timezone: 'Asia/Tehran', days: availabilityDays },
|
||||
});
|
||||
}
|
||||
if (path.endsWith('/exceptions')) return Promise.resolve({ success: true, data: [] });
|
||||
if (path.startsWith('/api/v1/resources')) return Promise.resolve({ success: true, data: [] });
|
||||
if (path.includes('/service-categories/tree')) {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: [{ uuid: 'c-hand', name: 'دست', sort_order: 0, active: true, children: [] },
|
||||
{ uuid: 'c-foot', name: 'پا', sort_order: 1, active: true, children: [] }],
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
function renderPage(route = '/admin/resources/r1') {
|
||||
return renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/admin/resources/:resourceUuid/calendar" element={<ResourceCalendarPage />} />
|
||||
<Route path="/admin/resources/:resourceUuid" element={<ResourceDetailPage />} />
|
||||
</Routes>,
|
||||
{ route: '/admin/resources/r1/calendar' },
|
||||
{ route },
|
||||
);
|
||||
}
|
||||
|
||||
describe('ResourceCalendarPage', () => {
|
||||
describe('ResourceDetailPage', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders seven days and marks shiftless ones', async () => {
|
||||
mockApi(emptyDays(), []);
|
||||
it('اطلاعات منبع را در تب پیشفرض نشان میدهد', async () => {
|
||||
mockApi();
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
|
||||
expect(screen.getByText('ظرفیت همزمان')).toBeInTheDocument();
|
||||
expect(screen.getByText('کار با لیزر · 4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('تب از URL خوانده میشود تا بازگشت و رفرش همان نما را بدهد', async () => {
|
||||
mockApi();
|
||||
renderPage('/admin/resources/r1?tab=hours');
|
||||
|
||||
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
|
||||
expect(screen.getByText('جمعه')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('بدون شیفت')).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('shows stored shifts as times', async () => {
|
||||
it('شیفت ذخیرهشده را بهصورت ساعت نشان میدهد', async () => {
|
||||
const days = emptyDays();
|
||||
days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 1020, start_time: '09:00', end_time: '17:00', active: true }];
|
||||
mockApi(days, []);
|
||||
renderPage();
|
||||
mockApi(days);
|
||||
renderPage('/admin/resources/r1?tab=hours');
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue('17:00')).toBeInTheDocument();
|
||||
@@ -73,13 +111,13 @@ describe('ResourceCalendarPage', () => {
|
||||
* دلیلِ خالی بودن روز باید فارسی نشان داده شود؛ نشان دادن کلید خام سرور
|
||||
* («outside_branch_hours») به کاربر یعنی پیام بیمعنا.
|
||||
*/
|
||||
it('translates every empty-day reason into Persian', async () => {
|
||||
it('دلیل خالی بودن روز را فارسی میکند', async () => {
|
||||
mockApi(emptyDays(), [
|
||||
{ date: 1785529800, day_of_week: 0, intervals: [], total_minutes: 0, reasons: ['national_holiday'] },
|
||||
{ date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['outside_branch_hours'] },
|
||||
{ date: 1785702600, day_of_week: 2, intervals: [], total_minutes: 0, reasons: ['exception'] },
|
||||
]);
|
||||
renderPage();
|
||||
renderPage('/admin/resources/r1?tab=exceptions');
|
||||
|
||||
await waitFor(() => expect(screen.getByText('تعطیل رسمی')).toBeInTheDocument());
|
||||
expect(screen.getByText('شیفت بیرون از ساعت کاری شعبه')).toBeInTheDocument();
|
||||
@@ -87,20 +125,24 @@ describe('ResourceCalendarPage', () => {
|
||||
expect(screen.queryByText('outside_branch_hours')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows free minutes for a day that has availability', async () => {
|
||||
mockApi(emptyDays(), [
|
||||
{ date: 1785529800, day_of_week: 0, intervals: [{ start: 1, end: 2 }], total_minutes: 480, reasons: [] },
|
||||
]);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('480 دقیقه')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
/** پیشنمایش نباید «وقت قابل رزرو» خوانده شود — نوبتها هنوز کسر نشدهاند. */
|
||||
it('warns that the preview is raw availability', async () => {
|
||||
mockApi(emptyDays(), []);
|
||||
renderPage();
|
||||
it('پیشنمایش را خام معرفی میکند', async () => {
|
||||
mockApi();
|
||||
renderPage('/admin/resources/r1?tab=exceptions');
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/نوبتهای ثبتشده هنوز از آن کسر نشدهاند/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('تب دستهبندی فقط انتخاب میدهد، نه ساخت', async () => {
|
||||
mockApi();
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
|
||||
await user.click(screen.getByRole('button', { name: 'دستهبندیها' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/فقط انتخاب میشود/)).toBeInTheDocument());
|
||||
expect(screen.queryByRole('button', { name: /افزودن دستهبندی جدید/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('تنظیمات ← دستهبندیها')).toHaveAttribute('href', '/admin/service-categories');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { PencilIcon } from '@heroicons/react/24/outline';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import ResourceFormModal from '../components/resources/ResourceFormModal';
|
||||
import ResourceWorkingHoursPanel from '../components/resources/ResourceWorkingHoursPanel';
|
||||
import ResourceExceptionsPanel from '../components/resources/ResourceExceptionsPanel';
|
||||
import ResourceServicesPanel from '../components/resources/ResourceServicesPanel';
|
||||
import ResourceSkillsPanel from '../components/resources/ResourceSkillsPanel';
|
||||
import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesPanel';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useBranches } from '../hooks/useBranches';
|
||||
import { useResourceDetail, useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
|
||||
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
||||
import type { ClinicResource } from '../types';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'info', label: 'اطلاعات' },
|
||||
{ id: 'hours', label: 'ساعات کاری' },
|
||||
{ id: 'exceptions', label: 'تعطیلات و استثنا' },
|
||||
{ id: 'services', label: 'سرویسها' },
|
||||
{ id: 'skills', label: 'مهارتها' },
|
||||
{ id: 'categories', label: 'دستهبندیها' },
|
||||
] as const;
|
||||
type TabId = typeof TABS[number]['id'];
|
||||
|
||||
const SUBJECT_LABEL: Record<string, string> = {
|
||||
doctor: 'پزشک',
|
||||
staff: 'پرسنل',
|
||||
room: 'اتاق',
|
||||
};
|
||||
|
||||
/**
|
||||
* یک منبع و همهٔ تنظیمات مستقلش، در یک صفحهٔ تببندیشده — همان ساختار صفحهٔ
|
||||
* «مدیریت نوبتدهی» کلینیک.
|
||||
*
|
||||
* منبع در مدل Resource-First واحدِ ظرفیت است، پس ساعت کاری و تعطیلات را خودش دارد نه
|
||||
* فقط پزشکِ پشتش؛ تقویم منبع درون برنامهٔ هفتگی تنگتر میشود، آن را گشاد نمیکند.
|
||||
*/
|
||||
export default function ResourceDetailPage() {
|
||||
const { resourceUuid } = useParams<{ resourceUuid: string }>();
|
||||
const [urlState, setUrlState] = useUrlState({ tab: 'info' });
|
||||
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId;
|
||||
|
||||
const { resource, loading } = useResourceDetail(resourceUuid);
|
||||
const { branches } = useBranches();
|
||||
const { types } = useResourceTypes();
|
||||
const { skills } = useSkills();
|
||||
const { update, setSkills, setCategories } = useResources();
|
||||
const { offerings, save: saveServices } = useResourceServices(resourceUuid);
|
||||
const { items: serviceOptions } = useAllServiceItems();
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('appointment_settings', 'update');
|
||||
|
||||
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>;
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={resource.name}
|
||||
description={`${resource.type_name}${resource.subject_kind ? ` · ${SUBJECT_LABEL[resource.subject_kind]}` : ' · تجهیزات'}${resource.address_name ? ` · ${resource.address_name}` : ''}`}
|
||||
backTo="/admin/resources"
|
||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: resource.name }]}
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary sm" onClick={() => setEditOpen(true)}>
|
||||
<PencilIcon style={{ width: 15 }} /> ویرایش
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 'var(--gap)' }}>
|
||||
<ActiveBadge active={resource.active} />
|
||||
</div>
|
||||
|
||||
<div className="seg" style={{ marginBottom: 'var(--gap)', overflowX: 'auto', flexWrap: 'nowrap' }}>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={tab === t.id ? 'active' : ''}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
onClick={() => setUrlState({ tab: t.id })}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'info' && <InfoTab resource={resource} />}
|
||||
|
||||
{tab === 'hours' && <ResourceWorkingHoursPanel resourceUuid={resourceUuid} canUpdate={canUpdate} />}
|
||||
|
||||
{tab === 'exceptions' && <ResourceExceptionsPanel resourceUuid={resourceUuid} canUpdate={canUpdate} />}
|
||||
|
||||
{tab === 'services' && (
|
||||
<div className="card card-pad">
|
||||
<ResourceServicesPanel
|
||||
resource={resource}
|
||||
offerings={offerings}
|
||||
services={serviceOptions}
|
||||
saving={saveServices.isPending}
|
||||
onSave={(lines) => saveServices.mutate({ uuid: resource.uuid, services: lines })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'skills' && (
|
||||
<div className="card card-pad">
|
||||
<ResourceSkillsPanel
|
||||
resource={resource}
|
||||
skills={skills}
|
||||
saving={setSkills.isPending}
|
||||
onSave={(lines) => setSkills.mutate({ uuid: resource.uuid, skills: lines })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'categories' && (
|
||||
<ResourceCategoriesPanel
|
||||
resource={resource}
|
||||
canUpdate={canUpdate}
|
||||
saving={setCategories.isPending}
|
||||
onSave={(categoryUuids) => setCategories.mutate({ uuid: resource.uuid, categoryUuids })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ResourceFormModal
|
||||
open={editOpen}
|
||||
resource={resource}
|
||||
branches={branches}
|
||||
types={types}
|
||||
saving={update.isPending}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSave={(payload) =>
|
||||
update.mutate({ uuid: resource.uuid, d: payload }, { onSuccess: () => setEditOpen(false) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
gap: 12, padding: '11px 0', borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>{label}</span>
|
||||
<span style={{ fontSize: 13.5, color: 'var(--text-2)', textAlign: 'left', minWidth: 0 }}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoTab({ resource }: { resource: ClinicResource }) {
|
||||
const attributes = Object.entries(resource.attributes ?? {});
|
||||
|
||||
return (
|
||||
<div className="card card-pad">
|
||||
<Row label="شعبه">{resource.address_name || '—'}</Row>
|
||||
<Row label="نوع منبع">{resource.type_name}</Row>
|
||||
<Row label="ظرفیت همزمان">{resource.capacity} نفر</Row>
|
||||
<Row label="آمادهسازی / تمیزکاری">
|
||||
{resource.setup_minutes} / {resource.cleanup_minutes} دقیقه
|
||||
</Row>
|
||||
<Row label="مهارتها">
|
||||
{resource.skills.length === 0 ? '—' : (
|
||||
<span style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{resource.skills.map((s) => (
|
||||
<span key={s.skill_uuid} className="badge blue" style={{ fontSize: 11 }}>
|
||||
{s.skill_name} · {s.level}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</Row>
|
||||
<Row label="دستهبندیها">
|
||||
{(resource.categories ?? []).length === 0 ? '—' : (
|
||||
<span style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{(resource.categories ?? []).map((c) => (
|
||||
<span key={c.uuid} className="badge gray" style={{ fontSize: 11 }}>{c.name}</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</Row>
|
||||
{attributes.map(([key, value]) => (
|
||||
<Row key={key} label={key}>{String(value)}</Row>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -194,8 +194,8 @@ export default function ResourcesPage() {
|
||||
<button type="button" className="btn secondary sm" onClick={() => setServicesFor(r)}>
|
||||
سرویسها
|
||||
</button>
|
||||
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}/calendar`}>
|
||||
تقویم
|
||||
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}`}>
|
||||
تنظیمات
|
||||
</Link>
|
||||
{/* مسدودسازی موردی از تقویم جداست: آن الگوی کاری را عوض میکند،
|
||||
این یک بازهٔ مشخص را میبندد. */}
|
||||
|
||||
@@ -989,6 +989,8 @@ export interface ClinicResource {
|
||||
subject_kind: 'doctor' | 'staff' | 'room' | null;
|
||||
subject_uuid: string | null;
|
||||
skills: ResourceSkillLine[];
|
||||
/** دستهٔ سراسری؛ فقط انتخاب میشود — ساختش در «تنظیمات ← دستهبندیها» است */
|
||||
categories?: Array<{ uuid: string; name: string }>;
|
||||
active: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
> **Base:** `/api/v1` · **Auth:** JWT · **مجوز:** `appointment_settings`
|
||||
> مکمل [resource.md](resource.md) — منبع آنجا ساخته میشود، تقویمش اینجا.
|
||||
>
|
||||
> **پنل:** این اندپوینتها در تبهای «ساعات کاری» و «تعطیلات و استثنا»ی صفحهٔ
|
||||
> `/admin/resources/{uuid}` مصرف میشوند. مسیر قدیمی `/admin/resources/{uuid}/calendar`
|
||||
> با redirect به `?tab=hours` میرود.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user