Files
clinicpro/assets/admin/pages/MySecretariesPage.tsx
T
hamed df7a784701 feat: implement realistic data seeding for doctors, clinics, and secretaries
- Added seed_realistic_data.php to clean existing data and populate the database with realistic entries for doctors, clinics, and secretaries.
- Created a structured approach to generate 100 doctors per city with diverse specialties and services.
- Implemented database cleanup routines to ensure a fresh start for data seeding.
- Enhanced the DoctorSecretaryRepository with improved comments for clarity.
2026-06-15 14:18:25 +03:30

812 lines
33 KiB
TypeScript

import {
CheckCircleIcon,
IdentificationIcon,
NoSymbolIcon,
PencilIcon,
PhoneIcon,
PlusIcon,
} from "@heroicons/react/24/outline";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { z } from "zod";
import ConfirmDialog from "../components/ui/ConfirmDialog";
import DataTable, { type Column } from "../components/ui/DataTable";
import Modal from "../components/ui/Modal";
import PageHeader from "../components/ui/PageHeader";
import { ActiveBadge } from "../components/ui/StatusBadge";
import { useSubscription } from "../hooks/useSubscription";
import type { ApiResponse } from "../lib/api";
import { api } from "../lib/api";
import { formatDate, maskMobile } from "../lib/utils";
import { useAuthStore } from "../stores/authStore";
import type { Secretary, SecretaryPermissions } from "../types";
// ── Default permissions & labels ──────────────────────────────────────────
const DEFAULT_PERMISSIONS: SecretaryPermissions = {
appointments: {
view: true,
create: false,
cancel: false,
update_status: false,
},
addresses: { view: true, create: false, update: false, delete: false },
clinic_info: { view: true, update: false },
insurances: { view: true, create: false, update: false, delete: false },
};
type PermSection = keyof SecretaryPermissions;
const PERMISSION_LABELS: Record<
PermSection,
{ label: string; actions: { key: string; label: string }[] }
> = {
appointments: {
label: "نوبت‌ها",
actions: [
{ key: "view", label: "مشاهده" },
{ key: "create", label: "ایجاد" },
{ key: "cancel", label: "لغو" },
{ key: "update_status", label: "تغییر وضعیت" },
],
},
addresses: {
label: "آدرس‌ها",
actions: [
{ key: "view", label: "مشاهده" },
{ key: "create", label: "ایجاد" },
{ key: "update", label: "ویرایش" },
{ key: "delete", label: "حذف" },
],
},
clinic_info: {
label: "اطلاعات کلینیک",
actions: [
{ key: "view", label: "مشاهده" },
{ key: "update", label: "ویرایش" },
],
},
insurances: {
label: "بیمه‌ها",
actions: [
{ key: "view", label: "مشاهده" },
{ key: "create", label: "ایجاد" },
{ key: "update", label: "ویرایش" },
{ key: "delete", label: "حذف" },
],
},
};
const ALL_ACTIONS = [
"view",
"create",
"update",
"delete",
"cancel",
"update_status",
];
const ACTION_HEADERS = [
"مشاهده",
"ایجاد",
"ویرایش",
"حذف",
"لغو",
"تغییر وضعیت",
];
function PermissionsMatrix({
permissions,
onChange,
}: {
permissions: SecretaryPermissions;
onChange: (p: SecretaryPermissions) => void;
}) {
const toggle = (section: PermSection, action: string) => {
const cur = (permissions[section] as Record<string, boolean>)[action];
onChange({
...permissions,
[section]: {
...(permissions[section] as Record<string, boolean>),
[action]: !cur,
},
});
};
return (
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<thead>
<tr style={{ background: "oklch(0.97 0.01 256)" }}>
<th
style={{
textAlign: "right",
padding: "10px 12px",
fontWeight: 600,
color: "var(--text-2)",
borderBottom: "2px solid var(--border)",
}}
>
بخش
</th>
{ACTION_HEADERS.map((h) => (
<th
key={h}
style={{
textAlign: "center",
padding: "10px 8px",
fontSize: 12,
fontWeight: 600,
color: "var(--text-2)",
borderBottom: "2px solid var(--border)",
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{(Object.keys(PERMISSION_LABELS) as PermSection[]).map(
(section, idx) => {
const config = PERMISSION_LABELS[section];
const sectionPrm = permissions[section] as Record<
string,
boolean
>;
return (
<tr
key={section}
style={{
background:
idx % 2 === 0
? "transparent"
: "oklch(0.985 0.005 256)",
}}
>
<td
style={{
padding: "10px 12px",
borderBottom:
"1px solid var(--border)",
fontWeight: 600,
}}
>
{config.label}
</td>
{ALL_ACTIONS.map((action) => {
const ac = config.actions.find(
(a) => a.key === action,
);
if (!ac)
return (
<td
key={action}
style={{
textAlign: "center",
borderBottom:
"1px solid var(--border)",
color: "var(--border)",
}}
>
</td>
);
return (
<td
key={action}
style={{
textAlign: "center",
borderBottom:
"1px solid var(--border)",
}}
>
<input
type="checkbox"
checked={
sectionPrm[action] ??
false
}
onChange={() =>
toggle(section, action)
}
style={{
width: 16,
height: 16,
accentColor:
"var(--primary)",
cursor: "pointer",
}}
/>
</td>
);
})}
</tr>
);
},
)}
</tbody>
</table>
</div>
);
}
// ── Create form schema ─────────────────────────────────────────────────────
const createSchema = z.object({
name: z.string().min(2, "نام الزامی است"),
mobile_number: z.string().regex(/^09[0-9]{9}$/, "شماره موبایل معتبر نیست"),
});
type CreateForm = z.infer<typeof createSchema>;
interface ClinicDoctor {
uuid: string;
name: string;
}
// ── Main component ─────────────────────────────────────────────────────────
export default function MySecretariesPage() {
const qc = useQueryClient();
const { doctorUuid, dbUuid, primaryRole } = useAuthStore();
const isClinic = primaryRole === "clinic";
const { maxSecretaries } = useSubscription();
// for clinic: selected doctor to add secretary for
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>("");
const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? "");
const [createOpen, setCreateOpen] = useState(false);
const [editTarget, setEditTarget] = useState<Secretary | null>(null);
const [editPerms, setEditPerms] =
useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
// clinic: load clinic's doctors
const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } =
useQuery<ApiResponse<{ data: ClinicDoctor[] }>>({
queryKey: ["clinic-doctors", dbUuid],
queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
enabled: isClinic && !!dbUuid,
});
const clinicDoctors: ClinicDoctor[] = clinicDoctorsData?.data?.data ?? [];
// clinic: load ALL secretaries across all its doctors
const { data: clinicSecrData, isLoading: clinicSecrLoading } = useQuery<
ApiResponse<Secretary[]>
>({
queryKey: ["my-secretaries-clinic", dbUuid],
queryFn: () => api.get(`/api/v1/secretaries/clinic/${dbUuid}`),
enabled: isClinic && !!dbUuid,
});
// doctor: load secretaries for the doctor
const { data: doctorSecrData, isLoading: doctorSecrLoading } = useQuery<
ApiResponse<Secretary[]>
>({
queryKey: ["my-secretaries", activeDoctorUuid],
queryFn: () => api.get(`/api/v1/secretaries/${activeDoctorUuid}`),
enabled: !isClinic && !!activeDoctorUuid,
});
const secretaries = isClinic
? (clinicSecrData?.data ?? [])
: (doctorSecrData?.data ?? []);
const activeSecretaryCount = secretaries.filter((s) => s.is_active).length;
const isLoading = isClinic ? clinicSecrLoading : doctorSecrLoading;
const createForm = useForm<CreateForm>({
resolver: zodResolver(createSchema),
});
const createMutation = useMutation({
mutationFn: (body: CreateForm) =>
api.post("/api/v1/secretary", {
...body,
doctor_uuid: activeDoctorUuid,
}),
onSuccess: () => {
toast.success("منشی اضافه شد");
setCreateOpen(false);
createForm.reset();
qc.invalidateQueries({ queryKey: ["my-secretaries"] });
qc.invalidateQueries({ queryKey: ["my-secretaries-clinic"] });
},
onError: (e: any) => toast.error(e.message),
});
const updatePermsMutation = useMutation({
mutationFn: ({
uuid,
permissions,
}: {
uuid: string;
permissions: SecretaryPermissions;
}) =>
api.patch(`/api/v1/secretary/${uuid}`, {
permissions: { version: 1, resources: permissions },
}),
onSuccess: () => {
toast.success("دسترسی‌ها بروزرسانی شد");
setEditTarget(null);
qc.invalidateQueries({ queryKey: ["my-secretaries"] });
qc.invalidateQueries({ queryKey: ["my-secretaries-clinic"] });
},
onError: (e: any) => toast.error(e.message),
});
const toggleActiveMutation = useMutation({
mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) =>
api.patch(`/api/v1/secretary/${uuid}`, { active }),
onSuccess: (_, { active }) => {
toast.success(active ? "منشی فعال شد" : "منشی غیرفعال شد");
setDeleteTarget(null);
qc.invalidateQueries({ queryKey: ["my-secretaries"] });
qc.invalidateQueries({ queryKey: ["my-secretaries-clinic"] });
},
onError: (e: any) => toast.error(e.message),
});
const openEdit = (s: Secretary) => {
setEditTarget(s);
setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS);
};
const handleCreateOpen = () => {
if (isClinic && !selectedDoctorUuid) {
toast.error("ابتدا یک پزشک را انتخاب کنید");
return;
}
setCreateOpen(true);
};
const selectedDoctorName =
clinicDoctors.find((d) => d.uuid === selectedDoctorUuid)?.name ?? "";
// for clinic: show doctor column in the table
const clinicColumns: Column<Secretary>[] = isClinic
? [
{
key: "doctor_name",
header: "پزشک",
render: (s) => (
<span
style={{
fontSize: 13,
color: "var(--text-2)",
fontWeight: 500,
}}
>
{s.doctor_name}
</span>
),
},
]
: [];
const allColumns: Column<Secretary>[] = [
{
key: "user_name",
header: "منشی",
render: (s) => (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div
className="avatar sm"
style={{
background:
"linear-gradient(145deg, oklch(0.62 0.15 295), oklch(0.48 0.16 295))",
flexShrink: 0,
fontWeight: 700,
}}
>
{(s.user_name ?? "?").charAt(0)}
</div>
<div style={{ fontWeight: 600, fontSize: 14 }}>
{s.user_name}
</div>
</div>
),
},
...clinicColumns,
{
key: "mobile_number",
header: "موبایل",
render: (s) => (
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 13,
color: "var(--text-2)",
}}
>
<PhoneIcon style={{ width: 13 }} />
<span dir="ltr">{maskMobile(s.mobile_number)}</span>
</div>
),
},
{
key: "is_active",
header: "وضعیت",
render: (s) => <ActiveBadge active={s.is_active} />,
},
{
key: "created_at",
header: "تاریخ ثبت",
render: (s) => (
<span style={{ fontSize: 13, color: "var(--text-3)" }}>
{formatDate(Number(s.created_at))}
</span>
),
},
{
key: "uuid",
header: "عملیات",
render: (s) => (
<div style={{ display: "flex", gap: 6 }}>
<button
className="btn sm"
onClick={() => openEdit(s)}
title="ویرایش دسترسی‌ها"
>
<PencilIcon style={{ width: 14 }} />
</button>
<button
className="btn sm"
onClick={() => setDeleteTarget(s)}
title={s.is_active ? "غیرفعال‌سازی" : "فعال‌سازی"}
>
{s.is_active ? (
<NoSymbolIcon style={{ width: 14 }} />
) : (
<CheckCircleIcon
style={{ width: 14, color: "var(--success)" }}
/>
)}
</button>
</div>
),
},
];
return (
<>
<PageHeader
title="منشی ها من"
description="مدیریت منشی ها و دسترسی‌های آن‌ها"
action={
activeSecretaryCount >= maxSecretaries ? (
<Link
to="/admin/subscription"
className="btn sm"
style={{ textDecoration: "none", opacity: 0.8 }}
title={`حداکثر ${maxSecretaries} منشی مجاز است`}
>
ارتقاء پنل برای افزودن منشی
</Link>
) : (
<button
className="btn primary sm"
onClick={handleCreateOpen}
disabled={isClinic && !selectedDoctorUuid}
>
<PlusIcon style={{ width: 16 }} />
افزودن منشی
</button>
)
}
/>
{/* کلینیک: انتخاب پزشک برای افزودن منشی */}
{isClinic && (
<div className="card card-pad" style={{ marginBottom: 16 }}>
<div className="field" style={{ marginBottom: 0 }}>
<label>پزشک مورد نظر برای افزودن منشی جدید</label>
{clinicDoctorsLoading ? (
<p style={{ fontSize: 13, color: "var(--text-3)" }}>
در حال بارگذاری...
</p>
) : clinicDoctors.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--text-3)" }}>
هیچ پزشکی در این کلینیک تعریف نشده است. ابتدا
پزشک اضافه کنید.
</p>
) : (
<select
value={selectedDoctorUuid}
onChange={(e) =>
setSelectedDoctorUuid(e.target.value)
}
style={{ width: "100%", maxWidth: 360 }}
>
<option value="">
یک پزشک را انتخاب کنید
</option>
{clinicDoctors.map((d) => (
<option key={d.uuid} value={d.uuid}>
{d.name}
</option>
))}
</select>
)}
</div>
</div>
)}
{!isClinic && !doctorUuid ? (
<div
className="card card-pad"
style={{ textAlign: "center", color: "var(--text-3)" }}
>
پروفایل پزشک یافت نشد
</div>
) : (
<div className="card">
{secretaries.length === 0 && !isLoading ? (
<div
style={{
textAlign: "center",
padding: "60px 24px",
}}
>
<IdentificationIcon
style={{
width: 48,
color: "var(--text-3)",
margin: "0 auto 16px",
display: "block",
opacity: 0.4,
}}
/>
<div
style={{
fontWeight: 600,
fontSize: 15,
marginBottom: 8,
color: "var(--text-2)",
}}
>
هنوز منشی‌ای اضافه نشده
</div>
<div
style={{
color: "var(--text-3)",
fontSize: 13.5,
marginBottom: 20,
}}
>
{isClinic
? "برای افزودن منشی، ابتدا یک پزشک را از لیست بالا انتخاب کنید"
: "منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند"}
</div>
{!isClinic && (
<button
className="btn primary sm"
onClick={handleCreateOpen}
>
<PlusIcon style={{ width: 16 }} /> افزودن
اولین منشی
</button>
)}
</div>
) : (
<DataTable
columns={allColumns}
data={secretaries}
loading={isLoading}
emptyMessage="منشی‌ای ثبت نشده است"
/>
)}
</div>
)}
{/* Modal افزودن منشی */}
<Modal
open={createOpen}
onClose={() => {
setCreateOpen(false);
createForm.reset();
}}
title="افزودن منشی جدید"
size="sm"
footer={
<>
<button
type="button"
className="btn ghost sm"
onClick={() => {
setCreateOpen(false);
createForm.reset();
}}
>
انصراف
</button>
<button
form="create-secretary-form"
type="submit"
className="btn primary sm"
disabled={createMutation.isPending}
>
{createMutation.isPending
? "در حال افزودن..."
: "افزودن منشی"}
</button>
</>
}
>
{isClinic && selectedDoctorName && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "9px 12px",
marginBottom: 20,
background: "var(--primary-subtle)",
borderRadius: 8,
fontSize: 13,
color: "var(--primary)",
}}
>
<IdentificationIcon
style={{ width: 16, flexShrink: 0 }}
/>
منشی برای <strong>{selectedDoctorName}</strong> ثبت
می‌شود
</div>
)}
<form
id="create-secretary-form"
onSubmit={createForm.handleSubmit((d) =>
createMutation.mutate(d),
)}
>
<div className="field">
<label>نام و نام خانوادگی *</label>
<input
{...createForm.register("name")}
placeholder="مثال: سارا احمدی"
autoComplete="off"
autoFocus
style={
createForm.formState.errors.name
? { borderColor: "var(--danger)" }
: undefined
}
/>
{createForm.formState.errors.name && (
<span className="field-error">
{createForm.formState.errors.name.message}
</span>
)}
</div>
<div className="field" style={{ marginTop: 16 }}>
<label>شماره موبایل *</label>
<input
{...createForm.register("mobile_number")}
placeholder="09123456789"
dir="ltr"
inputMode="numeric"
maxLength={11}
autoComplete="off"
onChange={(e) => {
const digits = e.target.value.replace(
/\D/g,
"",
);
createForm.setValue("mobile_number", digits, {
shouldValidate:
createForm.formState.isSubmitted,
});
}}
style={
createForm.formState.errors.mobile_number
? { borderColor: "var(--danger)" }
: undefined
}
/>
{createForm.formState.errors.mobile_number && (
<span className="field-error">
{
createForm.formState.errors.mobile_number
.message
}
</span>
)}
</div>
<p
style={{
fontSize: 12.5,
color: "var(--text-3)",
margin: "4px 0 0",
lineHeight: 1.7,
}}
>
شماره باید با ۰۹ شروع شده و ۱۱ رقم باشد. اگر کاربری با
این شماره در سیستم وجود داشته باشد، به عنوان منشی اضافه
می‌شود.
</p>
</form>
</Modal>
{/* Modal ویرایش دسترسی‌ها */}
<Modal
open={!!editTarget}
onClose={() => setEditTarget(null)}
title={`دسترسی‌های ${editTarget?.user_name ?? ""}`}
size="lg"
footer={
<>
<button
className="btn ghost sm"
onClick={() => setEditTarget(null)}
>
لغو
</button>
<button
className="btn primary sm"
disabled={updatePermsMutation.isPending}
onClick={() =>
editTarget &&
updatePermsMutation.mutate({
uuid: editTarget.uuid,
permissions: editPerms,
})
}
>
{updatePermsMutation.isPending
? "در حال ذخیره..."
: "ذخیره دسترسی‌ها"}
</button>
</>
}
>
<PermissionsMatrix
permissions={editPerms}
onChange={setEditPerms}
/>
</Modal>
{/* Confirm تغییر وضعیت منشی */}
<ConfirmDialog
open={!!deleteTarget}
title={
deleteTarget?.is_active
? "غیرفعال‌سازی منشی"
: "فعال‌سازی منشی"
}
message={
deleteTarget?.is_active
? `آیا مطمئن هستید که می‌خواهید منشی «${deleteTarget?.user_name}» را غیرفعال کنید؟`
: `آیا مطمئن هستید که می‌خواهید منشی «${deleteTarget?.user_name}» را دوباره فعال کنید؟`
}
confirmLabel={
deleteTarget?.is_active ? "غیرفعال‌سازی" : "فعال‌سازی"
}
danger={deleteTarget?.is_active}
loading={toggleActiveMutation.isPending}
onConfirm={() =>
deleteTarget &&
toggleActiveMutation.mutate({
uuid: deleteTarget.uuid,
active: !deleteTarget.is_active,
})
}
onCancel={() => setDeleteTarget(null)}
/>
</>
);
}