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>
104 lines
4.2 KiB
TypeScript
104 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|