Files
clinicpro/assets/admin/components/resources/ResourceSkillsPanel.tsx
T
hamedandClaude Opus 5 e08876f7b4 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>
2026-08-02 13:06:06 +03:30

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>
);
}