feat(admin): session action menu (view invoice + archive) and archive filter
SessionServiceCard's '...' icon becomes a real dropdown with «مشاهده فاکتور»
and «آرشیو»/«خروج از آرشیو» (click-outside close), plus an «آرشیو» badge on
archived cards. PatientDetailPage adds a فعالها/همه/آرشیو segmented filter on
the services tab (default active), threads the filter into the sessions query,
and wires the archive action to PATCH /session/{uuid} { archived }.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
import { formatDate, formatRial } from '../lib/utils';
|
||||
import { FilesServiceSuccess, FilesServiceMore } from './icons/FilesServiceIcons';
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface SessionCardData {
|
||||
is_paid?: boolean;
|
||||
invoice_uuid?: string | null;
|
||||
notes?: string | null;
|
||||
archived?: boolean;
|
||||
created_at?: number;
|
||||
// تسویه چندتکه + تخفیف (backend session settlement fields)
|
||||
discount_type?: 'percent' | 'fixed' | null;
|
||||
@@ -49,16 +50,26 @@ function Row({ label, value, valueStyle }: { label: string; value: string; value
|
||||
* pixel-for-pixel from tauri files/services/ServiceCard. Paid cards show
|
||||
* «مشاهده فاکتور», unpaid ones «تکمیل پرداخت».
|
||||
*/
|
||||
export default function SessionServiceCard({ session, onSettle, onViewInvoice, settling, issuing }: {
|
||||
export default function SessionServiceCard({ session, onSettle, onViewInvoice, onArchive, settling, issuing }: {
|
||||
session: SessionCardData;
|
||||
onSettle: (uuid: string) => void;
|
||||
/** کل session پاس میشود؛ اگر invoice_uuid نداشت، caller فاکتور را میسازد. */
|
||||
onViewInvoice: (session: SessionCardData) => void;
|
||||
/** آرشیو/خروج از آرشیو مراجعه. */
|
||||
onArchive?: (session: SessionCardData, archived: boolean) => void;
|
||||
settling?: boolean;
|
||||
/** true وقتی صدور فاکتور همین لحظه در جریان است (دکمه قفل میشود). */
|
||||
issuing?: boolean;
|
||||
}) {
|
||||
const paid = !!session.is_paid;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDoc = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false); };
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [menuOpen]);
|
||||
const names = (session.services ?? []).map((s) => s.service_name || s.name).filter(Boolean) as string[];
|
||||
if ((session.visit_price_rials ?? 0) > 0) names.unshift('ویزیت');
|
||||
// A «مراجعه» = the visit plus the services performed in it.
|
||||
@@ -75,11 +86,33 @@ export default function SessionServiceCard({ session, onSettle, onViewInvoice, s
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<FilesServiceSuccess />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3, minWidth: 0 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 }}>{title}</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, fontWeight: 600, color: '#525252', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 }}>
|
||||
{title}
|
||||
{session.archived && <span className="badge" style={{ fontSize: 10, color: '#9CA3AF', border: '1px solid #E0E0E0', borderRadius: 4, padding: '1px 6px' }}>آرشیو</span>}
|
||||
</span>
|
||||
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, color: '#6B7280', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 200 }}>{subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<FilesServiceMore style={{ color: '#9CA3AF', flexShrink: 0 }} />
|
||||
<div ref={menuRef} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button type="button" aria-label="عملیات" onClick={() => setMenuOpen((o) => !o)}
|
||||
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: 2, display: 'flex' }}>
|
||||
<FilesServiceMore style={{ color: '#9CA3AF' }} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="card dark:bg-[#222433] dark:border-[#35343D]" style={{ position: 'absolute', insetInlineEnd: 0, top: '100%', marginTop: 4, minWidth: 150, zIndex: 20, padding: 4, background: '#fff', border: '1px solid var(--border)', borderRadius: 8, boxShadow: 'var(--shadow-lg)' }}>
|
||||
<button type="button" onClick={() => { setMenuOpen(false); onViewInvoice(session); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'right', padding: '8px 10px', fontSize: 13, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--text)', fontFamily: 'inherit' }}>
|
||||
مشاهده فاکتور
|
||||
</button>
|
||||
{onArchive && (
|
||||
<button type="button" onClick={() => { setMenuOpen(false); onArchive(session, !session.archived); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'right', padding: '8px 10px', fontSize: 13, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer', color: session.archived ? 'var(--text)' : '#EF4444', fontFamily: 'inherit' }}>
|
||||
{session.archived ? 'خروج از آرشیو' : 'آرشیو'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dark:border-[#35343D]" style={{ borderTop: '1px solid #F1F1F1' }} />
|
||||
|
||||
@@ -81,6 +81,7 @@ export default function PatientDetailPage() {
|
||||
const qc = useQueryClient();
|
||||
const nav = useNavigate();
|
||||
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
|
||||
const [sessionFilter, setSessionFilter] = useState<'active' | 'all' | 'archived'>('active');
|
||||
|
||||
// «مشاهده فاکتور»: اگر session هنوز فاکتور ندارد، اول صادر میشود (idempotent).
|
||||
const issueInvoiceMut = useIssueInvoice(uuid);
|
||||
@@ -92,6 +93,16 @@ export default function PatientDetailPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const archiveMut = useMutation({
|
||||
mutationFn: ({ sessionUuid, archived }: { sessionUuid: string; archived: boolean }) =>
|
||||
api.patch(`/api/v1/session/${sessionUuid}`, { archived }),
|
||||
onSuccess: (_r, v) => {
|
||||
qc.invalidateQueries({ queryKey: ['patient-sessions', uuid] });
|
||||
toast.success(v.archived ? 'مراجعه آرشیو شد' : 'مراجعه از آرشیو خارج شد');
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || 'خطا در آرشیو'),
|
||||
});
|
||||
|
||||
// ── فرم «اطلاعات پرونده» (اینلاین، معادل tauri FileInfoSection) ──────────────
|
||||
// استان/شهر (Location) و بیمهٔ پایه (insurance-pricing) برای گزینههای فرم.
|
||||
const [editProvinceId, setEditProvinceId] = useState<number | null>(null);
|
||||
@@ -138,8 +149,8 @@ export default function PatientDetailPage() {
|
||||
|
||||
// sessions + appointments load eagerly so the banner (debt / next visit) is ready.
|
||||
const sessionsQ = useQuery<ApiResponse<SessionCardData[]>>({
|
||||
queryKey: ['patient-sessions', uuid],
|
||||
queryFn: () => api.get(`/api/v1/patient/${uuid}/sessions`),
|
||||
queryKey: ['patient-sessions', uuid, sessionFilter],
|
||||
queryFn: () => api.get(`/api/v1/patient/${uuid}/sessions?filter=${sessionFilter}`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
const appointmentsQ = useQuery<ApiResponse<any[]>>({
|
||||
@@ -212,11 +223,13 @@ export default function PatientDetailPage() {
|
||||
</div>
|
||||
) : tab === 'services' ? (
|
||||
<div>
|
||||
{/* filter + new-service (tauri ServiceCardsSection header) */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginBottom: 16 }}>
|
||||
<button type="button" aria-label="فیلتر" className="flex items-center justify-center rounded-[4px] cursor-pointer" style={{ width: 48, height: 48, border: '1px solid var(--border)', background: 'transparent' }}>
|
||||
<TurnsFilter color="#5559ce" />
|
||||
</button>
|
||||
{/* filter (همه/فعال/آرشیو) + new-service */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<div className="seg">
|
||||
<button className={sessionFilter === 'active' ? 'on' : ''} onClick={() => setSessionFilter('active')}>فعالها</button>
|
||||
<button className={sessionFilter === 'all' ? 'on' : ''} onClick={() => setSessionFilter('all')}>همه</button>
|
||||
<button className={sessionFilter === 'archived' ? 'on' : ''} onClick={() => setSessionFilter('archived')}>آرشیو</button>
|
||||
</div>
|
||||
<Link to={`/admin/patients/${uuid}/session/new`} className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: '#5559ce', color: '#fff', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
|
||||
<AddTurn color="#fff" /> سرویس جدید
|
||||
</Link>
|
||||
@@ -228,7 +241,7 @@ export default function PatientDetailPage() {
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 6, rowGap: 10, alignItems: 'stretch' }}>
|
||||
{sessions.map((s) => (
|
||||
<SessionServiceCard key={s.uuid} session={s} onSettle={(u) => nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={viewInvoice} issuing={issueInvoiceMut.isPending} />
|
||||
<SessionServiceCard key={s.uuid} session={s} onSettle={(u) => nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={viewInvoice} onArchive={(sess, archived) => archiveMut.mutate({ sessionUuid: sess.uuid, archived })} issuing={issueInvoiceMut.isPending} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user