- Colony end releases the queen in the same transaction; open suggestions become obsolete and a CLEANUP suggestion lists open tasks and the queen; apply-cleanup validates evidence and blocks reactivated colonies - Withdrawal chain: plausibility warnings on harvest and bottling, conflict notifications also when a treatment is entered or corrected after the harvest (naming bottled lots), withdrawal spans in the calendar and M365 sync, Berlin-day end events - Next 3 steps per colony and the "Heute" endpoint with ranking and an explicit merge table by intent - Inspection-triggered rule suggestions follow the colony's latest inspection (resolve when the finding is gone, refresh when it stays) - Frontend: colony card takeover in the inspection form, feed status as condition, occupied frames 0-40; legacy offline entries keep syncing - Notifications and preferences are deleted with their user Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
"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<typeof NavSheet>["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<string, number>();
|
|
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 ? (
|
|
<p className="flex items-start gap-2 text-sm text-destructive">
|
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
|
|
<span>Abgelehnt: {rejectionText(lastError.message)}</span>
|
|
</p>
|
|
) : (
|
|
<p className="flex items-start gap-2 text-sm text-muted-foreground">
|
|
<Clock className="mt-0.5 size-4 shrink-0" aria-hidden />
|
|
<span>Wartet auf Senden</span>
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/** „Ohne V-3 erneut senden“ - or, when a code is unknown, without naming them. */
|
|
function ResendWithoutButton({
|
|
resend,
|
|
disabled,
|
|
onResend,
|
|
}: {
|
|
resend: NonNullable<QueueItem["resendWithout"]>;
|
|
disabled: boolean;
|
|
onResend: () => void;
|
|
}) {
|
|
const isNamed = resend.codes.length === resend.colonyIds.length;
|
|
const without = isNamed ? resend.codes.join(", ") : "die abgelehnten Völker";
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="touch"
|
|
className="self-start"
|
|
disabled={disabled}
|
|
onClick={onResend}
|
|
>
|
|
<RotateCw aria-hidden />
|
|
Ohne {without} erneut senden
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<NavSheet
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
trigger={trigger}
|
|
title="Wartende Einträge"
|
|
finalFocus={finalFocus}
|
|
>
|
|
<div className="flex flex-col gap-3 px-4 pb-4">
|
|
{isSuccess && items.length === 0 && photos.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">Von dir wartet nichts mehr.</p>
|
|
) : null}
|
|
<ul className="flex flex-col gap-2">
|
|
{items.map((item, index) => (
|
|
<li
|
|
key={`${item.store}-${item.id}`}
|
|
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="font-medium">{item.label}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
Erfasst am {queuedAtLabel(item.queuedAt)}
|
|
</p>
|
|
{item.photoCount ? (
|
|
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
<Camera className="size-4 shrink-0" aria-hidden />
|
|
{photosAttached(item.photoCount)}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<ConfirmDialog
|
|
triggerLabel="Verwerfen"
|
|
triggerAriaLabel={`Verwerfen: ${names[index]}`}
|
|
triggerVariant="ghost"
|
|
triggerSize="touch"
|
|
title={`${names[index]} verwerfen?`}
|
|
description={discardDescription(item)}
|
|
confirmLabel="Verwerfen"
|
|
onConfirm={() => discard.mutate(item)}
|
|
isPending={discard.isPending}
|
|
/>
|
|
</div>
|
|
<QueueStatus lastError={item.lastError} />
|
|
{item.resendWithout && typeof item.id === "number" ? (
|
|
<ResendWithoutButton
|
|
resend={item.resendWithout}
|
|
disabled={resendBulk.isPending}
|
|
onResend={() =>
|
|
resendBulk.mutate({
|
|
id: item.id as number,
|
|
colonyIds: item.resendWithout?.colonyIds ?? [],
|
|
})
|
|
}
|
|
/>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
{photos.map((photo, index) => (
|
|
<li
|
|
key={`photo-${photo.id}`}
|
|
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<PhotoThumbnail
|
|
blob={photo.blob}
|
|
alt=""
|
|
className="size-14 shrink-0 rounded-lg border border-border/60 object-cover"
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="font-medium">Foto zur Durchsicht</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
Aufgenommen am {queuedAtLabel(photo.createdAt)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<QueueStatus lastError={photo.lastError} />
|
|
<div className="flex flex-wrap gap-2">
|
|
{photo.lastError ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="touch"
|
|
aria-label={`Erneut senden: ${photoNames[index]}`}
|
|
disabled={retryPhoto.isPending}
|
|
onClick={() => retryPhoto.mutate(photo.id)}
|
|
>
|
|
<RotateCw aria-hidden />
|
|
Erneut senden
|
|
</Button>
|
|
) : null}
|
|
<ConfirmDialog
|
|
triggerLabel="Verwerfen"
|
|
triggerAriaLabel={`Verwerfen: ${photoNames[index]}`}
|
|
triggerVariant="ghost"
|
|
triggerSize="touch"
|
|
title={`${photoNames[index]} verwerfen?`}
|
|
description="Das Foto wird von diesem Gerät gelöscht und nicht mehr gesendet."
|
|
confirmLabel="Verwerfen"
|
|
onConfirm={() => discardPhoto.mutate(photo.id)}
|
|
isPending={discardPhoto.isPending}
|
|
/>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{foreignCount > 0 ? (
|
|
<p className="text-sm text-muted-foreground">{foreignEntriesWaiting(foreignCount)}</p>
|
|
) : null}
|
|
</div>
|
|
</NavSheet>
|
|
);
|
|
}
|