"use client"; import type { ComponentProps, ReactNode } from "react"; import { AlertTriangle, Camera, Clock, RotateCw } from "lucide-react"; import { toast } from "sonner"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { NavSheet } from "@/components/nav/nav-sheet"; import { ConfirmDialog } from "@/components/confirm-dialog"; import { PhotoThumbnail } from "@/components/photos/photo-capture-field"; import { Button } from "@/components/ui/button"; import { discardQueueItem, listQueue, retryBulkWithout, type QueueError, type QueueItem, } from "@/lib/offline/db"; import { entriesSynced, foreignEntriesWaiting, photosAttached, rejectionText } from "@/lib/offline/queue-texts"; import { syncPendingEntries } from "@/lib/offline/sync"; import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation"; import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store"; interface PendingQueueSheetProps { userId: string; /** Entries of another account on this device - shown only as a count. */ foreignCount: number; open: boolean; onOpenChange: (open: boolean) => void; /** The `SheetTrigger` that opens the sheet, or null when opened elsewhere. */ trigger: ReactNode; /** Where focus goes on close; by default back to the trigger. */ finalFocus?: ComponentProps["finalFocus"]; } interface Named { label: string; queuedAt: string; } const PHOTO_LABEL = "Foto"; function queuedAtLabel(queuedAt: string): string { return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" }); } /** * „Durchsicht vom 13.10.26, 10:15:05" - a name per entry for its buttons and * dialog. Seconds tell apart entries of the same minute; a running number * „(2)" those queued in the same second. */ function entryNames(items: readonly Named[]): string[] { const seen = new Map(); return items.map((item) => { const time = new Date(item.queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium", }); const name = `${item.label} vom ${time}`; const occurrence = (seen.get(name) ?? 0) + 1; seen.set(name, occurrence); return occurrence === 1 ? name : `${name} (${occurrence})`; }); } /** A queued inspection takes its photos along when it is discarded. */ function discardDescription(item: QueueItem): string { if (!item.photoCount) { return "Der Eintrag wird von diesem Gerät gelöscht und nicht mehr gesendet."; } const photos = item.photoCount === 1 ? "sein Foto" : `seine ${item.photoCount} Fotos`; return `Der Eintrag und ${photos} werden von diesem Gerät gelöscht und nicht mehr gesendet.`; } /** Rejected with the server's reason, or waiting - in words, never by colour alone. */ function QueueStatus({ lastError }: { lastError?: QueueError }) { return lastError ? (

Abgelehnt: {rejectionText(lastError.message)}

) : (

Wartet auf Senden

); } /** „Ohne V-3 erneut senden“ - or, when a code is unknown, without naming them. */ function ResendWithoutButton({ resend, disabled, onResend, }: { resend: NonNullable; disabled: boolean; onResend: () => void; }) { const isNamed = resend.codes.length === resend.colonyIds.length; const without = isNamed ? resend.codes.join(", ") : "die abgelehnten Völker"; return ( ); } /** * What this device still has to send for the signed-in user: each entry with * its state, a rejected one with the server's reason, and a way to discard it. * Photos whose inspection is already on the server are listed on their own; * a rejected one can be sent again. */ export function PendingQueueSheet({ userId, foreignCount, open, onOpenChange, trigger, finalFocus, }: PendingQueueSheetProps) { const queryClient = useQueryClient(); const { data: items = [], isSuccess } = useQuery({ queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "items"], queryFn: () => listQueue(userId), enabled: open, // Reads this device's storage, never the network. networkMode: "always", }); const { data: photos = [] } = useQuery({ queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "photos"], queryFn: () => listPendingPhotos(userId), enabled: open, networkMode: "always", }); const discard = useMutation({ mutationFn: (item: QueueItem) => discardQueueItem(item), networkMode: "always", onSuccess: () => { void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }); toast.success("Eintrag verworfen"); }, onError: () => toast.error("Eintrag konnte nicht verworfen werden."), }); const discardPhoto = useMutation({ mutationFn: (photoId: string) => discardReadyPhoto(photoId), networkMode: "always", onSuccess: () => { void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }); toast.success("Foto verworfen"); }, onError: () => toast.error("Foto konnte nicht verworfen werden."), }); // „Erneut senden": clear the rejection, then a normal sync run sends it. const retryPhoto = useMutation({ mutationFn: async (photoId: string) => { await resetPhotoError(photoId); // A sync already running may have passed this photo: it runs once more. return syncPendingEntries(userId, { afterChange: true }); }, networkMode: "always", onSuccess: (result) => { if (result.synced > 0) { toast.success(entriesSynced(result.synced)); // New files on the server: document lists may be stale now. void queryClient.invalidateQueries(); return; } void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }); }, onError: () => toast.error("Foto konnte nicht erneut gesendet werden."), }); // „Ohne … erneut senden“: the refused colonies leave the bulk entry (same // key, rejection cleared), then a normal run sends the rest. const resendBulk = useMutation({ mutationFn: async ({ id, colonyIds }: { id: number; colonyIds: string[] }) => { await retryBulkWithout(id, colonyIds); // A sync already running may have passed this entry: it runs once more. return syncPendingEntries(userId, { afterChange: true }); }, networkMode: "always", onSuccess: (result) => { if (result.synced > 0) { toast.success(entriesSynced(result.synced)); void queryClient.invalidateQueries(); return; } void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }); }, onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."), }); const names = entryNames(items); const photoNames = entryNames( photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })), ); return (
{isSuccess && items.length === 0 && photos.length === 0 ? (

Von dir wartet nichts mehr.

) : null}
    {items.map((item, index) => (
  • {item.label}

    Erfasst am {queuedAtLabel(item.queuedAt)}

    {item.photoCount ? (

    {photosAttached(item.photoCount)}

    ) : null}
    discard.mutate(item)} isPending={discard.isPending} />
    {item.resendWithout && typeof item.id === "number" ? ( resendBulk.mutate({ id: item.id as number, colonyIds: item.resendWithout?.colonyIds ?? [], }) } /> ) : null}
  • ))} {photos.map((photo, index) => (
  • Foto zur Durchsicht

    Aufgenommen am {queuedAtLabel(photo.createdAt)}

    {photo.lastError ? ( ) : null} discardPhoto.mutate(photo.id)} isPending={discardPhoto.isPending} />
  • ))}
{foreignCount > 0 ? (

{foreignEntriesWaiting(foreignCount)}

) : null}
); }