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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user