- Integrated TourButton component into SettingsMenuPage, SkillsPage, SmsWalletPage, StaffPage, StaffSessionDetailPage, StaffTreatmentSessionsPage, SubscriptionPage, TagsSettingsPage, TreatmentCasesPage to enhance user onboarding experience. - Created new tour definitions for appointments, clinics, staff management, financial management, and patient management, ensuring comprehensive guidance for users navigating the admin panel. - Updated documentation to reflect the addition of tours and their implementation details.
201 lines
8.1 KiB
TypeScript
201 lines
8.1 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import { Link } from 'react-router';
|
|
import { PlusIcon } from '@heroicons/react/24/outline';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
import { ActiveBadge } from '../components/ui/StatusBadge';
|
|
import { formatNumber } from '../lib/utils';
|
|
import { useUrlState } from '../hooks/useUrlState';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import { useResourceQuota, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
|
|
import ResourceFormModal from '../components/resources/ResourceFormModal';
|
|
import type { ClinicResource } from '../types';
|
|
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
|
|
|
/** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */
|
|
const SUBJECT_LABEL: Record<string, string> = {
|
|
doctor: 'پزشک',
|
|
staff: 'پرسنل',
|
|
room: 'اتاق',
|
|
};
|
|
|
|
/**
|
|
* منابع — هر چیزی که ممکن است اشغال باشد.
|
|
*
|
|
* فیلترها (شعبه/نوع/مهارت/وضعیت) در URL مینشینند تا «بازگشت» و رفرش همان نما را
|
|
* بدهند، و همانها مستقیم به سرور میروند: فیلتر کردن سمت کلاینت با فهرستی که خودش
|
|
* فیلترشده آمده، دوبارهکاری و منبع اختلاف است.
|
|
*/
|
|
export default function ResourcesPage() {
|
|
const [urlState, setUrlState] = useUrlState({
|
|
search: '', type: '', skill: '', status: '',
|
|
});
|
|
|
|
const { types } = useResourceTypes();
|
|
const { skills } = useSkills();
|
|
const { can } = usePermissions();
|
|
const canUpdate = can('resources', 'update');
|
|
|
|
const { resources, loading, create } = useResources({
|
|
type_uuid: urlState.type || undefined,
|
|
skill_uuid: urlState.skill || undefined,
|
|
active: urlState.status || undefined,
|
|
});
|
|
|
|
// ساخت تنها کاری است که به منبعِ موجود گره نمیخورد، پس تنها مودالی است که میماند.
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
|
|
const quota = useResourceQuota();
|
|
|
|
const rows = useMemo(() => {
|
|
const q = urlState.search.trim();
|
|
return q === '' ? resources : resources.filter((r) => `${r.name} ${r.type_name}`.includes(q));
|
|
}, [resources, urlState.search]);
|
|
|
|
const columns: Column<ClinicResource>[] = [
|
|
{
|
|
key: 'name',
|
|
header: 'منبع',
|
|
render: (r) => (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<span style={{ fontWeight: 600 }}>{r.name}</span>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
{r.type_name}
|
|
{r.subject_kind ? ` · ${SUBJECT_LABEL[r.subject_kind]}` : ' · تجهیزات'}
|
|
</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'capacity',
|
|
header: 'ظرفیت همزمان',
|
|
render: (r) => <span style={{ fontSize: 13 }}>{r.capacity} نفر</span>,
|
|
},
|
|
{
|
|
key: 'buffers',
|
|
header: 'آمادهسازی / تمیزکاری',
|
|
render: (r) => (
|
|
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
|
|
{r.setup_minutes} / {r.cleanup_minutes} دقیقه
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'skills',
|
|
header: 'مهارتها',
|
|
render: (r) =>
|
|
r.skills.length === 0 ? (
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>—</span>
|
|
) : (
|
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
{r.skills.map((s) => (
|
|
<span key={s.skill_uuid} className="badge blue" style={{ fontSize: 11 }}>
|
|
{s.skill_name} · {s.level}
|
|
</span>
|
|
))}
|
|
</div>
|
|
),
|
|
},
|
|
{ key: 'active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.active} /> },
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<PageHeader
|
|
title="منابع"
|
|
tourId="resources"
|
|
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار همزمان."
|
|
/* سقف پلن پیش از باز شدن فرم گفته میشود، نه بعد از پر کردنش: خطای ۴۲۲ ته کار
|
|
همان اطلاعات را دیرتر و گرانتر میداد. تصمیم نهایی همچنان با سرور است. */
|
|
action={
|
|
canUpdate ? (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
|
{!quota.unlimited && (
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
|
{formatNumber(quota.used)} از {formatNumber(quota.limit)} منبع پلن فعلی
|
|
</span>
|
|
)}
|
|
{quota.atLimit ? (
|
|
<Link to="/admin/subscription" className="btn primary" style={{ textDecoration: 'none' }}>
|
|
ارتقای پنل
|
|
</Link>
|
|
) : (
|
|
<button type="button" className="btn primary" onClick={() => setCreateOpen(true)}>
|
|
<PlusIcon style={{ width: 16 }} /> افزودن منبع
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
<ResourcesSubNav />
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={rows}
|
|
loading={loading}
|
|
searchValue={urlState.search}
|
|
onSearchChange={(v) => setUrlState({ search: v })}
|
|
searchPlaceholder="جستجو در منابع..."
|
|
emptyMessage="هیچ منبعی با این فیلترها یافت نشد"
|
|
headerExtra={
|
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginRight: 'auto' }}>
|
|
<div style={{ minWidth: 170 }}>
|
|
<SearchableSelect
|
|
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
|
|
value={urlState.type || null}
|
|
onChange={(v) => setUrlState({ type: v ? String(v) : '' })}
|
|
placeholder="همهٔ نوعها"
|
|
isClearable
|
|
height={36}
|
|
/>
|
|
</div>
|
|
<div style={{ minWidth: 170 }}>
|
|
<SearchableSelect
|
|
options={skills.map((s) => ({ value: s.uuid, label: s.name }))}
|
|
value={urlState.skill || null}
|
|
onChange={(v) => setUrlState({ skill: v ? String(v) : '' })}
|
|
placeholder="همهٔ مهارتها"
|
|
isClearable
|
|
height={36}
|
|
/>
|
|
</div>
|
|
<div style={{ minWidth: 140 }}>
|
|
<SearchableSelect
|
|
options={[
|
|
{ value: '1', label: 'فعال' },
|
|
{ value: '0', label: 'غیرفعال' },
|
|
]}
|
|
value={urlState.status || null}
|
|
onChange={(v) => setUrlState({ status: v ? String(v) : '' })}
|
|
placeholder="همهٔ وضعیتها"
|
|
isClearable
|
|
height={36}
|
|
/>
|
|
</div>
|
|
</div>
|
|
}
|
|
/* یک اقدام، نه ششتا: ویرایش/مهارتها/سرویسها/مسدودسازی/حذف همگی تبهای
|
|
خودِ منبعاند. شش دکمه در هر ردیف، جدول را از لبه بیرون میزد و همان کار
|
|
را دو جای مختلف (مودالِ ردیف و تبِ منبع) قابل انجام میکرد. */
|
|
actions={(r) => (
|
|
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}`}>
|
|
مدیریت
|
|
</Link>
|
|
)}
|
|
/>
|
|
|
|
<ResourceFormModal
|
|
open={createOpen}
|
|
resource={null}
|
|
types={types}
|
|
saving={create.isPending}
|
|
onClose={() => setCreateOpen(false)}
|
|
onSave={(payload) => create.mutate(payload, { onSuccess: () => setCreateOpen(false) })}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|