- Refactor color palette in `ui-design-spec.md` to utilize CSS variables exclusively, eliminating fixed hex values and Tailwind utility classes. - Complete dark mode implementation in `uiStore.ts`, ensuring proper theme application via `applyTheme()` and `applyBrand()`. - Create `admin-theme-dark-light-audit.md` to document the transition process, outlining issues with inline styles and fixed colors. - Introduce `theme-tokens.test.ts` to enforce rules against fixed hex colors and ensure compliance with the design system. - Update various components and styles to replace inline styles and fixed colors with CSS variables, ensuring consistent theming across light and dark modes. - Ensure all changes maintain visual integrity in both light and dark modes, with a focus on accessibility and contrast standards.
182 lines
7.2 KiB
TypeScript
182 lines
7.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';
|
|
import ConfirmAppointmentModal from '../appointments/ConfirmAppointmentModal';
|
|
|
|
// Labels follow the Figma نوبتها design (ثبت شده / قطعی شده / ویزیت شده …).
|
|
export const STATUS_META: Record<string, { label: string; color: string }> = {
|
|
pending: { label: 'ثبت شده', color: 'var(--info)' },
|
|
confirmed: { label: 'قطعی شده', color: 'var(--primary)' },
|
|
following_up: { label: 'در حال پیگیری', color: 'var(--warning)' },
|
|
salon: { label: 'سالن', color: 'var(--violet)' },
|
|
completed: { label: 'ویزیت شده', color: 'var(--success)' },
|
|
cancelled_by_doctor: { label: 'لغو شده', color: 'var(--danger)' },
|
|
cancelled_by_user: { label: 'لغو توسط بیمار', color: 'var(--danger)' },
|
|
no_show: { label: 'غیبت', color: 'var(--text-3)' },
|
|
expired: { label: 'منقضی شده', color: 'var(--text-3)' },
|
|
};
|
|
|
|
// 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 [confirmOpen, setConfirmOpen] = 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: (e: any) => toast.error(e?.message || 'خطا در تغییر وضعیت'),
|
|
});
|
|
|
|
const meta = STATUS_META[currentStatus] ?? { label: currentStatus, color: 'var(--text-3)' };
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* «قطعی شده» راه میانبر ندارد: قطعیکردن یعنی ثبت هزینهها و پرداخت در پرونده،
|
|
* پس همیشه از مودال رد میشود. بقیهٔ وضعیتها همان PATCH سادهاند.
|
|
*/
|
|
function handlePick(status: string) {
|
|
if (status === 'confirmed') {
|
|
setOpen(false);
|
|
setConfirmOpen(true);
|
|
return;
|
|
}
|
|
mutation.mutate(status);
|
|
}
|
|
|
|
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: 'var(--text-3)' };
|
|
return (
|
|
<button
|
|
key={s}
|
|
onClick={() => handlePick(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
|
|
)}
|
|
|
|
<ConfirmAppointmentModal
|
|
open={confirmOpen}
|
|
appointmentUuid={uuid}
|
|
onClose={() => setConfirmOpen(false)}
|
|
queryKey={queryKey}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|