Files
clinicpro/assets/admin/components/ui/AppointmentStatusDropdown.tsx
T

152 lines
5.6 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';
const STATUS_META: Record<string, { label: string; color: string }> = {
pending: { label: 'رزرو شده', color: '#3b82f6' },
confirmed: { label: 'تأیید شده', color: '#22c55e' },
completed: { label: 'تکمیل شده', color: '#16a34a' },
cancelled_by_doctor: { label: 'لغو پزشک', color: '#ef4444' },
cancelled_by_user: { label: 'لغو بیمار', color: '#ef4444' },
no_show: { label: 'غیبت', color: '#9ca3af' },
expired: { label: 'منقضی شده', color: '#9ca3af' },
};
const TRANSITIONS: Record<string, string[]> = {
pending: ['confirmed', 'cancelled_by_doctor', 'cancelled_by_user', 'expired'],
confirmed: ['completed', '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>
);
}