Files
clinicpro/assets/admin/components/ui/AppointmentStatusDropdown.tsx
T
hamedandClaude Fable 5 7354c92e40 feat(appointments): row actions menu + info/move/transfer/replace modals — phase B
Frontend for the Figma عملیات menu on the confirmed-appointments table:

- AppointmentActions composite: six-item row menu (ویرایش، ثبت سرویس، مشاهده،
  جا به جایی، انتقال به لیست رزرو، جایگزینی) plus the four modals it opens.
  ثبت سرویس and the info modal resolve the patient record via the patient-list
  search (mobile) to reuse the existing wallet endpoint and NewSessionPage.
- Info modal mirrors appointments-info.pdf: start time, duration, phone,
  بخش/سرویس/پرسنل, wallet balance, status dropdown, مشاهده پرونده.
- Move/transfer/replace modals PATCH the new general update endpoint with
  optimistic-lock version; transfer uses day-level midnight slots.
- Status labels/transitions updated to the design set (ثبت شده/قطعی شده/
  در حال پیگیری/سالن/ویزیت شده/لغو شده) in AppointmentStatusDropdown and
  StatusBadge; Appointment type gains the new workflow fields; the table gains
  سرویس/پرسنل/عملیات columns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:47:54 +03:30

158 lines
6.2 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../../lib/api';
// Labels follow the Figma نوبت‌ها design (ثبت شده / قطعی شده / ویزیت شده …).
export const STATUS_META: Record<string, { label: string; color: string }> = {
pending: { label: 'ثبت شده', color: '#3b82f6' },
confirmed: { label: 'قطعی شده', color: '#14b8a6' },
following_up: { label: 'در حال پیگیری', color: '#f59e0b' },
salon: { label: 'سالن', color: '#8b5cf6' },
completed: { label: 'ویزیت شده', color: '#22c55e' },
cancelled_by_doctor: { label: 'لغو شده', color: '#ef4444' },
cancelled_by_user: { label: 'لغو توسط بیمار', color: '#ef4444' },
no_show: { label: 'غیبت', color: '#9ca3af' },
expired: { label: 'منقضی شده', color: '#9ca3af' },
};
// Mirrors Appointment::ALLOWED_TRANSITIONS on the backend.
const TRANSITIONS: Record<string, string[]> = {
pending: ['confirmed', 'following_up', 'cancelled_by_doctor', 'cancelled_by_user'],
confirmed: ['completed', 'following_up', 'salon', 'cancelled_by_doctor', 'cancelled_by_user', 'no_show'],
following_up: ['confirmed', 'salon', 'completed', 'cancelled_by_doctor', 'cancelled_by_user', 'no_show'],
salon: ['completed', 'following_up', 'cancelled_by_doctor', 'cancelled_by_user', 'no_show'],
};
interface Props {
uuid: string;
currentStatus: string;
version: number;
queryKey: unknown[];
}
export default function AppointmentStatusDropdown({ uuid, currentStatus, version, queryKey }: Props) {
const [open, setOpen] = useState(false);
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const qc = useQueryClient();
useEffect(() => {
if (!open) return;
function handler(e: MouseEvent) {
const target = e.target as Node;
if (!btnRef.current?.contains(target) && !menuRef.current?.contains(target)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Reposition on scroll/resize while open
useEffect(() => {
if (!open) return;
function reposition() {
if (btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
}
}
window.addEventListener('scroll', reposition, true);
window.addEventListener('resize', reposition);
return () => {
window.removeEventListener('scroll', reposition, true);
window.removeEventListener('resize', reposition);
};
}, [open]);
const mutation = useMutation({
mutationFn: (newStatus: string) =>
api.patch(`/api/v1/appointment/${uuid}/status`, { status: newStatus, version }),
onSuccess: () => {
qc.invalidateQueries({ queryKey });
setOpen(false);
},
onError: () => toast.error('خطا در تغییر وضعیت'),
});
const meta = STATUS_META[currentStatus] ?? { label: currentStatus, color: '#9ca3af' };
const nextStatuses = TRANSITIONS[currentStatus] ?? [];
function handleToggle() {
if (!open && btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
}
setOpen(o => !o);
}
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
ref={btnRef}
onClick={handleToggle}
disabled={nextStatuses.length === 0}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '3px 10px', borderRadius: 99, fontSize: 12, fontWeight: 700,
border: `1.5px solid ${meta.color}30`,
background: `${meta.color}15`,
color: meta.color,
cursor: nextStatuses.length > 0 ? 'pointer' : 'default',
whiteSpace: 'nowrap',
}}
>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: meta.color, flexShrink: 0 }} />
{meta.label}
{nextStatuses.length > 0 && (
<ChevronDownIcon style={{ width: 12, height: 12, flexShrink: 0 }} />
)}
</button>
{open && menuPos && nextStatuses.length > 0 && ReactDOM.createPortal(
<div
ref={menuRef}
style={{
position: 'fixed', top: menuPos.top, right: menuPos.right,
zIndex: 9000,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
minWidth: 160, overflow: 'hidden',
}}
>
{nextStatuses.map(s => {
const sm = STATUS_META[s] ?? { label: s, color: '#9ca3af' };
return (
<button
key={s}
onClick={() => mutation.mutate(s)}
disabled={mutation.isPending}
style={{
display: 'flex', alignItems: 'center', gap: 8,
width: '100%', padding: '8px 12px', fontSize: 13,
background: 'transparent', border: 'none', cursor: 'pointer',
color: sm.color, fontWeight: 400,
textAlign: 'right',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--surface-2)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<span style={{
width: 10, height: 10, borderRadius: '50%', flexShrink: 0,
border: `2px solid ${sm.color}`,
}} />
{sm.label}
</button>
);
})}
</div>,
document.body
)}
</div>
);
}