Files
nexarch/web/dms-upload/lib/uploadQueue.ts
patrickandClaude Sonnet 5 6d1130e23e DOC-03: drag-and-drop-upload-oberflaeche
Next.js/React/TypeScript auf web/shl (SHL-01), Next.js/Test-Aufbau nach
dem Muster von web/pdf-viewer (DOC-05). lib/chunkUpload.ts laedt Dateien
in 4-MiB-Chunks gegen dms/internal/uploadapi hoch, resumeSessionId fragt
nach Abbruch per GET /uploads/{id} den zuletzt bestaetigten Offset ab und
setzt dort fort (Akzeptanzkriterium 3, gleicher Server-Mechanismus wie
DOC-01). lib/uploadQueue.ts ist reine, DOM-unabhaengige Reducer-Logik fuer
Status/Fortschritt je Datei (Akzeptanzkriterien 1+2) - Ids werden VOR dem
Dispatch erzeugt (createQueuedItem), nicht im Reducer selbst: ein frueher
Versuch mit einer Id-Factory-Funktion im Reducer scheiterte real beim
Testen (useReducer ruft den Reducer erst in der Render-Phase auf, Code
direkt nach dispatch() sah noch ein leeres Ids-Array).

components/Dropzone.tsx nutzt ein natives <label for>+<input type=file
multiple> fuer Tastatur-/Dateidialog-Bedienung statt eigenem
Tastatur-Handler.

14/14 Vitest-Tests (uploadQueue, chunkUpload, Dropzone, UploadManager),
mindestens ein Test je Akzeptanzkriterium. npx tsc --noEmit sauber,
npm run build erfolgreich.

Pruefungen vor der Abnahme (Details siehe DOC-03-PRUEFPROTOKOLL.md):
1. Tastatur/Dateidialog bedienbar - bestanden (Dropzone.test.tsx)
2. Netzabbruch im Browser - real gegen echten HTTP-Mock-Server + echten
   Chromium (Playwright) getestet, echter net::ERR_CONNECTION_RESET,
   Retry fuehrt zum Abschluss
3. Mobile Ansicht (375x667) - real per Playwright geprueft, kein
   horizontales Scrollen, Bedienelemente vollstaendig erreichbar

QA-03 (Pruefgate Dokumentenkern) bewusst NICHT implementiert -
completeUpload() liefert lediglich die documentId als Anknuepfungspunkt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 12:28:22 +02:00

90 lines
3.3 KiB
TypeScript

// Reine, DOM-unabhaengige Zustandslogik fuer die Upload-Warteschlange
// (Akzeptanzkriterien 1-3: mehrere Dateien, Fortschritt/Status je Datei,
// einzeln wiederholbar) — analog lib/viewerState.ts aus DOC-05.
export type UploadItemStatus = "queued" | "uploading" | "success" | "error";
export interface UploadItem {
id: string;
file: File;
status: UploadItemStatus;
bytesUploaded: number;
errorMessage?: string;
documentId?: string;
sessionId?: string;
}
export interface UploadQueueState {
items: UploadItem[];
}
export function initialQueueState(): UploadQueueState {
return { items: [] };
}
export type UploadQueueAction =
| { type: "filesAdded"; items: UploadItem[] }
| { type: "uploadStarted"; id: string }
| { type: "sessionAssigned"; id: string; sessionId: string }
| { type: "progress"; id: string; bytesUploaded: number }
| { type: "succeeded"; id: string; documentId: string }
| { type: "failed"; id: string; message: string }
| { type: "retry"; id: string }
| { type: "removed"; id: string };
let counter = 0;
// Ids werden VOR dem Dispatch erzeugt (createQueuedItem), nicht innerhalb
// des Reducers — useReducer ruft den Reducer erst waehrend der naechsten
// Render-Phase auf, ein Nebeneffekt (ID-Zaehler) dort waere zu spaet fuer
// Code, das sich direkt im Anschluss an dispatch() auf die neuen IDs
// verlaesst (siehe UploadManager.handleFilesSelected).
export function createQueuedItem(file: File): UploadItem {
counter += 1;
return {
id: `upload-${Date.now()}-${counter}-${Math.random().toString(36).slice(2, 8)}`,
file,
status: "queued",
bytesUploaded: 0,
};
}
export function applyUploadQueueAction(state: UploadQueueState, action: UploadQueueAction): UploadQueueState {
switch (action.type) {
case "filesAdded":
return { items: [...state.items, ...action.items] };
case "uploadStarted":
return { items: mapItem(state, action.id, (item) => ({ ...item, status: "uploading", errorMessage: undefined })) };
case "sessionAssigned":
return { items: mapItem(state, action.id, (item) => ({ ...item, sessionId: action.sessionId })) };
case "progress":
return { items: mapItem(state, action.id, (item) => ({ ...item, bytesUploaded: action.bytesUploaded })) };
case "succeeded":
return {
items: mapItem(state, action.id, (item) => ({
...item,
status: "success",
bytesUploaded: item.file.size,
documentId: action.documentId,
})),
};
case "failed":
return { items: mapItem(state, action.id, (item) => ({ ...item, status: "error", errorMessage: action.message })) };
case "retry":
return { items: mapItem(state, action.id, (item) => ({ ...item, status: "queued", errorMessage: undefined })) };
case "removed":
return { items: state.items.filter((item) => item.id !== action.id) };
default:
return state;
}
}
function mapItem(state: UploadQueueState, id: string, updater: (item: UploadItem) => UploadItem): UploadItem[] {
return state.items.map((item) => (item.id === id ? updater(item) : item));
}
export function progressPercent(item: UploadItem): number {
if (item.file.size === 0) return item.status === "success" ? 100 : 0;
return Math.min(100, Math.round((item.bytesUploaded / item.file.size) * 100));
}