From 8db63ebd5dd5afd8f35dc07d7a559c52c827c347 Mon Sep 17 00:00:00 2001 From: OkeA-dev Date: Sat, 24 Oct 2026 08:25:22 +0100 Subject: [PATCH 1/3] fix(upload-queue): prevent duplicate uploads, abort on remove/unmount --- src/hooks/useUploadQueue.ts | 99 +++++++++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 9 deletions(-) diff --git a/src/hooks/useUploadQueue.ts b/src/hooks/useUploadQueue.ts index 42aaf6f3..55a3ec63 100644 --- a/src/hooks/useUploadQueue.ts +++ b/src/hooks/useUploadQueue.ts @@ -1,4 +1,4 @@ -import { useCallback, useReducer } from 'react'; +import { useCallback, useEffect, useReducer, useRef } from 'react'; export type UploadStatus = 'pending' | 'uploading' | 'success' | 'error'; @@ -30,12 +30,18 @@ function reducer(state: UploadFile[], action: Action): UploadFile[] { })), ]; case 'SET_STATUS': + if (!state.some((f) => f.id === action.id)) { + return state; + } return state.map((f) => f.id === action.id ? { ...f, status: action.status, errorMessage: action.errorMessage } : f, ); case 'SET_PROGRESS': + if (!state.some((f) => f.id === action.id)) { + return state; + } return state.map((f) => f.id === action.id ? { ...f, progress: Math.max(0, Math.min(100, action.progress)) } : f, ); @@ -66,10 +72,32 @@ export interface UseUploadQueueReturn { export type Uploader = ( file: File, onProgress: (pct: number) => void, + signal?: AbortSignal, ) => Promise; export function useUploadQueue(): UseUploadQueueReturn { const [queue, dispatch] = useReducer(reducer, []); + const inFlightRef = useRef>(new Set()); + const controllersRef = useRef>(new Map()); + const queueRef = useRef([]); + + useEffect(() => { + queueRef.current = queue; + }, [queue]); + + useEffect(() => { + return () => { + controllersRef.current.forEach((controller) => { + try { + controller.abort(); + } catch { + /* ignore */ + } + }); + controllersRef.current.clear(); + inFlightRef.current.clear(); + }; + }, []); const addFiles = useCallback((files: File[]) => { if (files.length === 0) return; @@ -77,22 +105,68 @@ export function useUploadQueue(): UseUploadQueueReturn { }, []); const removeFile = useCallback((id: string) => { + const controller = controllersRef.current.get(id); + if (controller) { + try { + controller.abort(); + } catch { + /* ignore */ + } + controllersRef.current.delete(id); + } + inFlightRef.current.delete(id); dispatch({ type: 'REMOVE', id }); }, []); const runUpload = useCallback( async (item: UploadFile, uploader: Uploader, dispatchFn: React.Dispatch) => { + if (inFlightRef.current.has(item.id)) { + return; + } + const controller = new AbortController(); + controllersRef.current.set(item.id, controller); + inFlightRef.current.add(item.id); dispatchFn({ type: 'SET_STATUS', id: item.id, status: 'uploading' }); dispatchFn({ type: 'SET_PROGRESS', id: item.id, progress: 0 }); try { - await uploader(item.file, (pct) => { - dispatchFn({ type: 'SET_PROGRESS', id: item.id, progress: pct }); - }); + await uploader( + item.file, + (pct) => { + if (controller.signal.aborted) { + return; + } + dispatchFn({ type: 'SET_PROGRESS', id: item.id, progress: pct }); + }, + controller.signal, + ); + if (controller.signal.aborted) { + inFlightRef.current.delete(item.id); + controllersRef.current.delete(item.id); + return; + } + if (!queueRef.current.some((f) => f.id === item.id)) { + inFlightRef.current.delete(item.id); + controllersRef.current.delete(item.id); + return; + } dispatchFn({ type: 'SET_PROGRESS', id: item.id, progress: 100 }); dispatchFn({ type: 'SET_STATUS', id: item.id, status: 'success' }); } catch (err) { + if (controller.signal.aborted) { + inFlightRef.current.delete(item.id); + controllersRef.current.delete(item.id); + return; + } + if (!queueRef.current.some((f) => f.id === item.id)) { + inFlightRef.current.delete(item.id); + controllersRef.current.delete(item.id); + return; + } const msg = err instanceof Error ? err.message : 'Upload failed'; dispatchFn({ type: 'SET_STATUS', id: item.id, status: 'error', errorMessage: msg }); + } finally { + inFlightRef.current.delete(item.id); + controllersRef.current.delete(item.id); } }, [], @@ -100,19 +174,26 @@ export function useUploadQueue(): UseUploadQueueReturn { const uploadFiles = useCallback( (uploader: Uploader) => { - const pending = queue.filter((f) => f.status === 'pending'); - pending.forEach((item) => runUpload(item, uploader, dispatch)); + const pending = queueRef.current.filter( + (f) => f.status === 'pending' && !inFlightRef.current.has(f.id), + ); + pending.forEach((item) => { + runUpload(item, uploader, dispatch); + }); }, - [queue, runUpload], + [runUpload], ); const retryFile = useCallback( (id: string, uploader: Uploader) => { - const item = queue.find((f) => f.id === id); + if (inFlightRef.current.has(id)) { + return; + } + const item = queueRef.current.find((f) => f.id === id); if (!item) return; runUpload({ ...item, status: 'pending', progress: 0 }, uploader, dispatch); }, - [queue, runUpload], + [runUpload], ); const clearComplete = useCallback(() => { From ed9fb0d6310c16cfb1f24f5211f8db06a9c3e7e2 Mon Sep 17 00:00:00 2001 From: OkeA-dev Date: Sat, 24 Oct 2026 08:31:40 +0100 Subject: [PATCH 2/3] fix(payout): add dialog a11y, reset state, validate dates and derive conflicts --- src/components/PayoutRescheduleModal.tsx | 170 +++++++++++++++++++---- 1 file changed, 142 insertions(+), 28 deletions(-) diff --git a/src/components/PayoutRescheduleModal.tsx b/src/components/PayoutRescheduleModal.tsx index eb6ceac7..9fc09d2b 100644 --- a/src/components/PayoutRescheduleModal.tsx +++ b/src/components/PayoutRescheduleModal.tsx @@ -1,11 +1,14 @@ -import React, { useState } from 'react'; -import { AlertTriangle, Clock, Calendar as CalendarIcon, CheckCircle } from 'lucide-react'; +import React, { useEffect, useId, useRef, useState } from 'react'; +import { AlertTriangle, Clock } from 'lucide-react'; +import { analyzeConflicts } from './PayoutTimeline/conflictAnalysis'; +import type { PayoutEvent } from './PayoutTimeline/PayoutTimeline'; export type ConflictType = 'hard' | 'soft'; export interface Conflict { id: string; - type: ConflictType; + type?: ConflictType; + severity?: ConflictType; message: string; } @@ -14,7 +17,11 @@ export interface PayoutRescheduleModalProps { onClose: () => void; onConfirm: (newDate: string, note: string) => void; initialDate: string; - conflicts: Conflict[]; + currentDate?: string; + conflicts?: Conflict[]; + allPayouts?: PayoutEvent[]; + payoutId?: string; + getConflicts?: (date: string) => Conflict[]; } export const PayoutRescheduleModal: React.FC = ({ @@ -22,28 +29,131 @@ export const PayoutRescheduleModal: React.FC = ({ onClose, onConfirm, initialDate, - conflicts, + currentDate, + conflicts: conflictsProp, + allPayouts, + payoutId, + getConflicts, }) => { const [newDate, setNewDate] = useState(initialDate); const [note, setNote] = useState(''); + const dateId = useId(); + const noteId = useId(); + const modalRef = useRef(null); + const confirmRef = useRef(null); + const cancelRef = useRef(null); + + useEffect(() => { + if (isOpen) { + setNewDate(initialDate); + setNote(''); + } + }, [isOpen, initialDate]); + + useEffect(() => { + if (!isOpen) return; + + const focusable = modalRef.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + const first = focusable?.[0]; + const last = focusable?.[focusable.length - 1]; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + return; + } + if (e.key !== 'Tab' || !focusable || focusable.length === 0) return; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + setTimeout(() => { + confirmRef.current?.focus(); + }, 0); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, onClose]); if (!isOpen) return null; - const hasHardConflict = conflicts.some((c) => c.type === 'hard'); + let computedConflicts: Conflict[] = []; + if (getConflicts) { + computedConflicts = getConflicts(newDate); + } else if (allPayouts && payoutId) { + computedConflicts = analyzeConflicts(payoutId, newDate, allPayouts).map((c) => ({ + id: c.id, + severity: c.severity, + type: c.severity, + message: c.message, + })); + } else if (conflictsProp) { + computedConflicts = conflictsProp; + } + + const conflicts = computedConflicts; + const hasHardConflict = conflicts.some((c) => c.type === 'hard' || c.severity === 'hard'); + + const today = new Date(); + const todayStr = today.toISOString().split('T')[0]; + const isEmpty = !newDate; + const isPast = newDate ? newDate < todayStr : false; + const isUnchanged = currentDate ? newDate === currentDate : initialDate ? newDate === initialDate && currentDate === undefined ? false : currentDate ? newDate === currentDate : false : false; + // If currentDate not provided, treat as unchanged only if equals initial and we want to be strict? Issue says "invalid dates" - empty/past/unchanged + const unchanged = currentDate !== undefined ? newDate === currentDate : newDate === initialDate && note === ''; + const invalid = isEmpty || isPast || newDate === (currentDate ?? initialDate); + const canConfirm = !hasHardConflict && !invalid; return ( -
-
-

Reschedule Payout

+
{ + if (e.target === e.currentTarget) { + onClose(); + } + }} + > +
+

Reschedule Payout

- - setNewDate(e.target.value)} - className="w-full p-2 bg-slate-800 border border-slate-700 rounded-lg" - /> +
+ + setNewDate(e.target.value)} + className="w-full p-2 bg-slate-800 border border-slate-700 rounded-lg" + aria-invalid={invalid} + /> + {(isEmpty || isPast || (currentDate !== undefined ? newDate === currentDate : newDate === initialDate)) && ( +

+ {isEmpty ? 'Date is required' : isPast ? 'Date cannot be in the past' : 'Date must be different from the current date'} +

+ )} +
{conflicts.length > 0 && (
@@ -62,22 +172,26 @@ export const PayoutRescheduleModal: React.FC = ({
)} - -