feat: T4 wave 11 – bottling form with article values and confirmation, Telegram in the app
- Bottling form: jar size and best-before from the product with source, differs-hints before saving, prefill status, water-content confirmation naming the measured value, idempotency key renewed on body change; product form gets shelf life (months) and whole-gram jar weight. - Telegram frontend: link card with step-up dialog, t.me-only deep link, status polling, unlink; system Telegram card with receiver/sender state and confirmed retry; StepUpDialog shared with the backup import. - Reauth calls never refresh-and-retry on 401 (no double lockout count); German texts for expired session and 429; service worker never caches Telegram, notification settings and system responses. - Telegram backend round 2: shared link removal on every disconnect path, lease released on shutdown, connection errors not counted as poison. - ARCHITECTURE.md §2, §6 (Annahmen 56–58), §10, §11.12, §11.13; TASKS.md T4. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
fab7f7e12f
commit
9760ce154f
73 files changed
+3633
-269
No files matched your search
@@ -1,9 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import NewBottlingBatchPage from "./page";
|
||||
import { createBottlingBatch } from "@/lib/bottling/api";
|
||||
import { WaterContentConfirmDialog } from "./water-content-confirm-dialog";
|
||||
import { createBottlingBatch, fetchBottlingPrefill } from "@/lib/bottling/api";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
import { fetchHoneyBatches, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
|
||||
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
|
||||
import { fetchProducts } from "@/lib/products/api";
|
||||
@@ -20,11 +23,41 @@ vi.mock("@/lib/honey-batches/api", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/treatments/api", () => ({ fetchWithdrawalStatus: vi.fn() }));
|
||||
vi.mock("@/lib/products/api", () => ({ fetchProducts: vi.fn() }));
|
||||
vi.mock("@/lib/bottling/api", () => ({ createBottlingBatch: vi.fn() }));
|
||||
vi.mock("@/lib/bottling/api", () => ({
|
||||
createBottlingBatch: vi.fn(),
|
||||
fetchBottlingPrefill: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), warning: vi.fn(), info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const EMPTY_PREFILL = {
|
||||
jarSize: null,
|
||||
jarSizeSource: null,
|
||||
bestBeforeDate: null,
|
||||
shelfLifeMonths: null,
|
||||
waterContent: null,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const PRODUCT_PREFILL = {
|
||||
...EMPTY_PREFILL,
|
||||
jarSize: 250,
|
||||
jarSizeSource: "PRODUCT" as const,
|
||||
bestBeforeDate: "2028-10-14",
|
||||
shelfLifeMonths: 24,
|
||||
};
|
||||
|
||||
function confirmationRequired(waterContent: number) {
|
||||
return new ApiError(409, "Wassergehalt zu hoch.", {
|
||||
statusCode: 409,
|
||||
code: "CONFIRMATION_REQUIRED",
|
||||
message: "Wassergehalt zu hoch.",
|
||||
waterContent,
|
||||
warnings: [],
|
||||
});
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
@@ -47,6 +80,8 @@ describe("NewBottlingBatchPage", () => {
|
||||
vi.mocked(fetchHoneyBatchTraceability).mockResolvedValue({ harvests: [] } as never);
|
||||
vi.mocked(fetchWithdrawalStatus).mockResolvedValue([]);
|
||||
vi.mocked(createBottlingBatch).mockReset();
|
||||
vi.mocked(fetchBottlingPrefill).mockReset();
|
||||
vi.mocked(fetchBottlingPrefill).mockResolvedValue(EMPTY_PREFILL);
|
||||
vi.mocked(toast.success).mockReset();
|
||||
vi.mocked(toast.warning).mockReset();
|
||||
});
|
||||
@@ -162,4 +197,425 @@ describe("NewBottlingBatchPage", () => {
|
||||
renderPage();
|
||||
expect(await screen.findByText("Wartezeit konnte nicht geprüft werden")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("values from the product", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchProducts).mockResolvedValue([
|
||||
{ id: "p-1", name: "Frühtracht 250 g", kind: "BOTTLED_HONEY" },
|
||||
{ id: "p-2", name: "Sommer 125 g", kind: "BOTTLED_HONEY" },
|
||||
] as never);
|
||||
vi.mocked(fetchBottlingPrefill).mockImplementation(async (query) => {
|
||||
if (query.productId === "p-1") return PRODUCT_PREFILL;
|
||||
if (query.productId === "p-2") {
|
||||
return { ...PRODUCT_PREFILL, jarSize: 125, bestBeforeDate: "2028-01-01", shelfLifeMonths: 12 };
|
||||
}
|
||||
return EMPTY_PREFILL;
|
||||
});
|
||||
});
|
||||
|
||||
async function chooseProduct(name = "Frühtracht 250 g") {
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Produkt/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name }));
|
||||
}
|
||||
|
||||
it("a product change after a manual edit brings the new product's values and source", async () => {
|
||||
renderPage();
|
||||
await chooseProduct();
|
||||
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
|
||||
const jarSize = screen.getByLabelText(/Glasgröße/);
|
||||
await userEvent.clear(jarSize);
|
||||
await userEvent.type(jarSize, "500");
|
||||
|
||||
await chooseProduct("Sommer 125 g");
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(125));
|
||||
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/)).toHaveValue("2028-01-01");
|
||||
expect(screen.getByText(/aus Artikel: 125 g/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/aus Artikel: 12 Monate/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not label values of the previous day as article values while the new ones load", async () => {
|
||||
renderPage();
|
||||
await chooseProduct();
|
||||
await waitFor(() => expect(screen.getByText(/aus Artikel: 250 g/)).toBeInTheDocument());
|
||||
vi.mocked(fetchBottlingPrefill).mockReturnValue(new Promise(() => undefined));
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/^Datum/), { target: { value: "2026-11-01" } });
|
||||
|
||||
await screen.findByText("Artikelwerte werden geladen …");
|
||||
expect(screen.queryByText(/aus Artikel/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/weicht vom Artikel ab/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hints when the entered best before differs from the product", async () => {
|
||||
renderPage();
|
||||
await chooseProduct();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/)).toHaveValue("2028-10-14"),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), {
|
||||
target: { value: "2029-01-01" },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText("MHD weicht vom Artikel ab (Artikel: 14.10.2028)."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hints when the entered jar size differs from the product", async () => {
|
||||
renderPage();
|
||||
await chooseProduct();
|
||||
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
|
||||
const jarSize = screen.getByLabelText(/Glasgröße/);
|
||||
await userEvent.clear(jarSize);
|
||||
await userEvent.type(jarSize, "500");
|
||||
|
||||
expect(
|
||||
screen.getByText("Glasgröße weicht vom Artikel ab (Artikel: 250 g)."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Hinweis")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fills jar size and best before from the product and names the source", async () => {
|
||||
renderPage();
|
||||
await chooseProduct();
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
|
||||
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/)).toHaveValue("2028-10-14");
|
||||
expect(screen.getByText(/aus Artikel: 24 Monate/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/aus Artikel: 250 g/)).toBeInTheDocument();
|
||||
expect(fetchBottlingPrefill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ honeyBatchId: "hb-1", productId: "p-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a manual edit when the prefill is fetched again", async () => {
|
||||
renderPage();
|
||||
await chooseProduct();
|
||||
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
|
||||
|
||||
const jarSize = screen.getByLabelText(/Glasgröße/);
|
||||
await userEvent.clear(jarSize);
|
||||
await userEvent.type(jarSize, "500");
|
||||
fireEvent.change(screen.getByLabelText(/^Datum/), { target: { value: "2026-11-01" } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchBottlingPrefill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ date: "2026-11-01" }),
|
||||
),
|
||||
);
|
||||
expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(500);
|
||||
expect(screen.queryByText(/aus Artikel: 250 g/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the measured water content and its notes as information", async () => {
|
||||
vi.mocked(fetchBottlingPrefill).mockResolvedValue({
|
||||
...EMPTY_PREFILL,
|
||||
waterContent: 17.5,
|
||||
warnings: [
|
||||
{
|
||||
code: "JAR_SIZE_DIFFERS",
|
||||
severity: "HINWEIS",
|
||||
message: "Die Glasgröße weicht vom Artikel ab.",
|
||||
refs: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/Wassergehalt der Charge: 17,5 %/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Die Glasgröße weicht vom Artikel ab\./)).toBeInTheDocument();
|
||||
expect(screen.getByText("Hinweis")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmation above the legal water content", () => {
|
||||
async function fillAndSubmit() {
|
||||
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
|
||||
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
|
||||
}
|
||||
|
||||
it("a 409 opens the confirmation and the confirmed retry sends acknowledgedWarnings with the same key", async () => {
|
||||
vi.mocked(createBottlingBatch)
|
||||
.mockRejectedValueOnce(confirmationRequired(20.5))
|
||||
.mockResolvedValueOnce({ id: "b-1" } as never);
|
||||
renderPage();
|
||||
await fillAndSubmit();
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent(
|
||||
"Wassergehalt 20,5 % über der gesetzlichen Grenze. Trotzdem abfüllen?",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
|
||||
|
||||
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(2));
|
||||
const [first, firstOptions] = vi.mocked(createBottlingBatch).mock.calls[0];
|
||||
const [second, secondOptions] = vi.mocked(createBottlingBatch).mock.calls[1];
|
||||
expect(first.acknowledgedWarnings).toBeUndefined();
|
||||
expect(second).toMatchObject({
|
||||
honeyBatchId: "hb-1",
|
||||
jarCount: 24,
|
||||
acknowledgedWarnings: ["WATER_CONTENT_ABOVE_LEGAL"],
|
||||
acknowledgedWaterContent: 20.5,
|
||||
});
|
||||
expect(firstOptions?.idempotencyKey).toBeTruthy();
|
||||
expect(secondOptions?.idempotencyKey).toBe(firstOptions?.idempotencyKey);
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith("/bottling/b-1"));
|
||||
});
|
||||
|
||||
it("cancel writes nothing, keeps the form and puts the focus back on the button", async () => {
|
||||
vi.mocked(createBottlingBatch).mockRejectedValueOnce(confirmationRequired(20.5));
|
||||
renderPage();
|
||||
await fillAndSubmit();
|
||||
await screen.findByRole("dialog");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abbrechen" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(createBottlingBatch).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByLabelText(/Anzahl Gläser/)).toHaveValue(24);
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Abfüllung anlegen" })).toHaveFocus(),
|
||||
);
|
||||
});
|
||||
|
||||
it("asks again with the new value when the server answers a fresh 409", async () => {
|
||||
vi.mocked(createBottlingBatch)
|
||||
.mockRejectedValueOnce(confirmationRequired(20.5))
|
||||
.mockRejectedValueOnce(confirmationRequired(21))
|
||||
.mockResolvedValueOnce({ id: "b-1" } as never);
|
||||
renderPage();
|
||||
await fillAndSubmit();
|
||||
await screen.findByRole("dialog");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent(
|
||||
"Wassergehalt 21 % über der gesetzlichen Grenze.",
|
||||
),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
|
||||
|
||||
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(3));
|
||||
expect(vi.mocked(createBottlingBatch).mock.calls[2][0].acknowledgedWaterContent).toBe(21);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a server message per field in German", async () => {
|
||||
vi.mocked(createBottlingBatch).mockRejectedValue(
|
||||
new ApiError(400, "Bad Request", {
|
||||
issues: [{ field: "date", message: "Das Datum ist ungültig." }],
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
|
||||
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
|
||||
|
||||
expect(await screen.findByText("Das Datum ist ungültig.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is off offline and says why", () => {
|
||||
reportRequestOutcome("network-error");
|
||||
try {
|
||||
renderPage();
|
||||
const button = screen.getByRole("button", { name: "Abfüllung anlegen" });
|
||||
expect(button).toBeDisabled();
|
||||
expect(screen.getByText("Nur mit Verbindung")).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute("aria-describedby");
|
||||
} finally {
|
||||
reportRequestOutcome("reached-server");
|
||||
}
|
||||
});
|
||||
|
||||
describe("prefill status", () => {
|
||||
it("disables the submit with a visible reason while the values load", async () => {
|
||||
vi.mocked(fetchBottlingPrefill).mockReturnValue(new Promise(() => undefined));
|
||||
renderPage();
|
||||
|
||||
const button = screen.getByRole("button", { name: "Abfüllung anlegen" });
|
||||
expect(button).toBeDisabled();
|
||||
const reason = await screen.findByText("Artikelwerte werden geladen …");
|
||||
expect(button.getAttribute("aria-describedby")).toContain(reason.id);
|
||||
});
|
||||
|
||||
it("the retry button asks the server again and the note goes away", async () => {
|
||||
vi.mocked(fetchBottlingPrefill).mockRejectedValueOnce(new Error("down"));
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Erneut versuchen" }));
|
||||
|
||||
await waitFor(() => expect(fetchBottlingPrefill).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "Erneut versuchen" })).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows one loading line, not two", async () => {
|
||||
vi.mocked(fetchBottlingPrefill).mockReturnValue(new Promise(() => undefined));
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("Artikelwerte werden geladen …");
|
||||
expect(screen.queryByText("Hinweise werden geladen …")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("on a failed prefill keeps the form values and says they are not from the product", async () => {
|
||||
vi.mocked(fetchBottlingPrefill).mockRejectedValue(new Error("down"));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/nicht aus Artikel/)).toHaveLength(2);
|
||||
expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(500);
|
||||
expect(screen.queryByText(/aus Artikel: 500/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Abfüllung anlegen" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("idempotency key and confirmation edge cases", () => {
|
||||
it("uses a fresh key after a changed field following an attempted submit, the same key when unchanged", async () => {
|
||||
vi.mocked(createBottlingBatch)
|
||||
.mockRejectedValueOnce(new ApiError(500, "Fehler", {}))
|
||||
.mockRejectedValueOnce(new ApiError(500, "Fehler", {}))
|
||||
.mockRejectedValueOnce(new ApiError(500, "Fehler", {}));
|
||||
renderPage();
|
||||
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
|
||||
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
|
||||
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(1));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
|
||||
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(2));
|
||||
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "0");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
|
||||
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(3));
|
||||
|
||||
const keys = vi.mocked(createBottlingBatch).mock.calls.map((call) => call[1]?.idempotencyKey);
|
||||
expect(keys[1]).toBe(keys[0]);
|
||||
expect(keys[2]).not.toBe(keys[0]);
|
||||
});
|
||||
|
||||
it("a prefill refetch that changes the jar size after a network error leads to a new key", async () => {
|
||||
vi.mocked(fetchBottlingPrefill).mockRejectedValueOnce(new Error("down"));
|
||||
vi.mocked(createBottlingBatch).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
renderPage();
|
||||
await screen.findByRole("button", { name: "Erneut versuchen" });
|
||||
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
|
||||
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
|
||||
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(1));
|
||||
|
||||
vi.mocked(fetchBottlingPrefill).mockResolvedValue(PRODUCT_PREFILL);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
|
||||
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
|
||||
await waitFor(() => expect(createBottlingBatch).toHaveBeenCalledTimes(2));
|
||||
|
||||
const [first, second] = vi.mocked(createBottlingBatch).mock.calls;
|
||||
expect(second[1]?.idempotencyKey).not.toBe(first[1]?.idempotencyKey);
|
||||
});
|
||||
|
||||
it("shows the message of a duplicate 409 without a code inside the dialog", async () => {
|
||||
vi.mocked(createBottlingBatch)
|
||||
.mockRejectedValueOnce(confirmationRequired(20.5))
|
||||
.mockRejectedValueOnce(new ApiError(409, "Die Anfrage läuft noch.", {}));
|
||||
renderPage();
|
||||
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
|
||||
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
|
||||
|
||||
await waitFor(() => expect(dialog).toHaveTextContent("Die Anfrage läuft noch."));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a double click on Trotzdem abfüllen sends exactly one request", async () => {
|
||||
vi.mocked(createBottlingBatch).mockRejectedValueOnce(confirmationRequired(20.5));
|
||||
vi.mocked(createBottlingBatch).mockReturnValue(new Promise(() => undefined));
|
||||
renderPage();
|
||||
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
|
||||
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
|
||||
await screen.findByRole("dialog");
|
||||
|
||||
await userEvent.dblClick(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
|
||||
|
||||
expect(createBottlingBatch).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Tab stays inside the dialog", async () => {
|
||||
vi.mocked(createBottlingBatch).mockRejectedValueOnce(confirmationRequired(20.5));
|
||||
renderPage();
|
||||
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
|
||||
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
const outside = [
|
||||
screen.getByLabelText(/Anzahl Gläser/),
|
||||
screen.getByLabelText(/Glasgröße/),
|
||||
document.querySelector<HTMLElement>("main a"),
|
||||
];
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await userEvent.tab();
|
||||
expect(outside).not.toContain(document.activeElement);
|
||||
}
|
||||
expect(dialog).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the confirmation text while the dialog closes", () => {
|
||||
const { rerender } = render(
|
||||
<WaterContentConfirmDialog
|
||||
waterContent={20.5}
|
||||
isPending={false}
|
||||
message={null}
|
||||
returnFocusTo={{ current: null }}
|
||||
onConfirm={() => undefined}
|
||||
onCancel={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Wassergehalt 20,5 %");
|
||||
|
||||
rerender(
|
||||
<WaterContentConfirmDialog
|
||||
waterContent={null}
|
||||
isPending={false}
|
||||
message={null}
|
||||
returnFocusTo={{ current: null }}
|
||||
onConfirm={() => undefined}
|
||||
onCancel={() => undefined}
|
||||
/>,
|
||||
);
|
||||
const closing = screen.queryByRole("dialog");
|
||||
if (closing) expect(closing).toHaveTextContent("Wassergehalt 20,5 %");
|
||||
});
|
||||
|
||||
it("restoring a draft keeps the typed flags and marks the values as from the draft", async () => {
|
||||
window.localStorage.setItem(
|
||||
"apicore:draft:anonymous:bottling-new",
|
||||
JSON.stringify({
|
||||
savedAt: Date.now(),
|
||||
values: {
|
||||
honeyBatchId: "hb-1",
|
||||
productId: "",
|
||||
date: "2026-10-14",
|
||||
jarSize: "250",
|
||||
jarCount: "12",
|
||||
bestBeforeDate: "2028-01-01",
|
||||
lotNumber: "",
|
||||
manual: { jarSize: true, bestBeforeDate: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
|
||||
|
||||
expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250);
|
||||
expect(screen.getByText(/aus Entwurf/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/aus Entwurf/)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { FormEvent, useId, useMemo, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, Package } from "lucide-react";
|
||||
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
|
||||
import { fetchProducts } from "@/lib/products/api";
|
||||
import { createBottlingBatch } from "@/lib/bottling/api";
|
||||
import { createBottlingBatch, fetchBottlingPrefill } from "@/lib/bottling/api";
|
||||
import { WATER_CONTENT_ABOVE_LEGAL, type BottlingPrefill } from "@/lib/bottling/types";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import type { PlausibilityWarning } from "@/lib/warnings/types";
|
||||
import { PrefillStatus } from "@/components/warnings/prefill-status";
|
||||
import { useBottlingWithdrawalHints } from "@/lib/bottling/use-withdrawal-hints";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { confirmationRequired, describeSaveError } from "@/lib/honey-batches/errors";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { toastWarnings } from "@/lib/warnings/toast-warnings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -23,6 +29,8 @@ import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import { WaterContentConfirmDialog } from "./water-content-confirm-dialog";
|
||||
import { WaterContentNotes } from "./water-content-notes";
|
||||
import {
|
||||
BottlingFormValues,
|
||||
initialBottlingValues,
|
||||
@@ -67,18 +75,91 @@ function validate(values: BottlingFormValues): FieldErrors {
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Jar size and best before may come from the product until the person types their own. */
|
||||
interface ManualEdits {
|
||||
jarSize: boolean;
|
||||
bestBeforeDate: boolean;
|
||||
}
|
||||
|
||||
const NO_MANUAL_EDITS: ManualEdits = { jarSize: false, bestBeforeDate: false };
|
||||
|
||||
/** What the draft keeps: the values as shown plus which of them the person typed. */
|
||||
type BottlingDraft = BottlingFormValues & { manual?: ManualEdits };
|
||||
|
||||
/** The prefill with the batch and product it was asked for. */
|
||||
type PrefillResult = BottlingPrefill & { honeyBatchId: string; productId: string; date: string };
|
||||
|
||||
function germanDay(day: string): string {
|
||||
return day.split("-").reverse().join(".");
|
||||
}
|
||||
|
||||
export default function NewBottlingBatchPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [values, setValues] = useState<BottlingFormValues>(() =>
|
||||
const [formValues, setValues] = useState<BottlingFormValues>(() =>
|
||||
initialBottlingValues(searchParams.get("honeyBatchId") ?? ""),
|
||||
);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const draft = useFormDraft("bottling-new", values, dirty);
|
||||
const [manual, setManual] = useState<ManualEdits>(NO_MANUAL_EDITS);
|
||||
const [fromDraft, setFromDraft] = useState<ManualEdits>(NO_MANUAL_EDITS);
|
||||
const [confirmation, setConfirmation] = useState<number | null>(null);
|
||||
const idempotencyKey = useRef(newIdempotencyKey());
|
||||
// The body of the last request sent (without key and confirmation): a different body gets a new key.
|
||||
const lastBody = useRef<string | null>(null);
|
||||
const [dialogMessage, setDialogMessage] = useState<string | null>(null);
|
||||
const confirmInFlight = useRef(false);
|
||||
const submitButton = useRef<HTMLButtonElement>(null);
|
||||
const loadingId = useId();
|
||||
const { isOffline, hintId, describedBy: offlineDescribedBy } = useOnlineOnly();
|
||||
|
||||
const prefillEnabled = Boolean(formValues.honeyBatchId && formValues.date);
|
||||
const prefillQuery = useQuery<PrefillResult>({
|
||||
queryKey: ["bottling-prefill", formValues.honeyBatchId, formValues.productId, formValues.date],
|
||||
queryFn: async () => ({
|
||||
...(await fetchBottlingPrefill({
|
||||
honeyBatchId: formValues.honeyBatchId,
|
||||
productId: formValues.productId || null,
|
||||
date: formValues.date,
|
||||
})),
|
||||
honeyBatchId: formValues.honeyBatchId,
|
||||
productId: formValues.productId,
|
||||
date: formValues.date,
|
||||
}),
|
||||
enabled: prefillEnabled,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
// Values of another product (kept while the new ones load) are never shown as the article's.
|
||||
const fetched = prefillQuery.isError ? undefined : prefillQuery.data;
|
||||
const sameProduct =
|
||||
fetched &&
|
||||
fetched.honeyBatchId === formValues.honeyBatchId &&
|
||||
fetched.productId === formValues.productId
|
||||
? fetched
|
||||
: undefined;
|
||||
// Labels, hints and notes only for the answer to exactly this question (batch, product, day).
|
||||
const prefill = sameProduct && sameProduct.date === formValues.date ? sameProduct : undefined;
|
||||
|
||||
// Master data of the product fills jar size and best before until the person edits them.
|
||||
const values = useMemo<BottlingFormValues>(
|
||||
() => ({
|
||||
...formValues,
|
||||
jarSize:
|
||||
!manual.jarSize && sameProduct?.jarSize != null
|
||||
? String(sameProduct.jarSize)
|
||||
: formValues.jarSize,
|
||||
bestBeforeDate:
|
||||
!manual.bestBeforeDate && sameProduct?.bestBeforeDate
|
||||
? sameProduct.bestBeforeDate
|
||||
: formValues.bestBeforeDate,
|
||||
}),
|
||||
[formValues, manual, sameProduct],
|
||||
);
|
||||
const draftValues = useMemo<BottlingDraft>(() => ({ ...values, manual }), [values, manual]);
|
||||
const draft = useFormDraft<BottlingDraft>("bottling-new", draftValues, dirty);
|
||||
|
||||
const { data: honeyBatches, isLoading: honeyBatchesLoading } = useQuery({
|
||||
queryKey: ["honey-batches"],
|
||||
@@ -98,6 +179,14 @@ export default function NewBottlingBatchPage() {
|
||||
value: BottlingFormValues[K],
|
||||
) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
if (key === "productId") {
|
||||
// A new product brings new master data; earlier typing no longer applies.
|
||||
setManual(NO_MANUAL_EDITS);
|
||||
setFromDraft(NO_MANUAL_EDITS);
|
||||
} else if (key === "jarSize" || key === "bestBeforeDate") {
|
||||
setManual((current) => ({ ...current, [key]: true }));
|
||||
setFromDraft((current) => ({ ...current, [key]: false }));
|
||||
}
|
||||
setDirty(true);
|
||||
if ((REQUIRED_KEYS as string[]).includes(key)) {
|
||||
setFieldErrors((current) => ({ ...current, [key]: undefined }));
|
||||
@@ -105,9 +194,23 @@ export default function NewBottlingBatchPage() {
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createBottlingBatch,
|
||||
mutationFn: (acknowledged: boolean) =>
|
||||
createBottlingBatch(
|
||||
{
|
||||
...toBottlingInput(values),
|
||||
...(acknowledged && confirmation !== null
|
||||
? {
|
||||
acknowledgedWarnings: [WATER_CONTENT_ABOVE_LEGAL],
|
||||
acknowledgedWaterContent: confirmation,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{ idempotencyKey: idempotencyKey.current },
|
||||
),
|
||||
onSuccess: (bottlingBatch) => {
|
||||
draft.clear();
|
||||
idempotencyKey.current = newIdempotencyKey();
|
||||
lastBody.current = null;
|
||||
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
|
||||
if (bottlingBatch.warnings?.length) {
|
||||
@@ -118,12 +221,71 @@ export default function NewBottlingBatchPage() {
|
||||
router.push(`/bottling/${bottlingBatch.id}`);
|
||||
},
|
||||
onError: (mutationError: Error) => {
|
||||
setSubmitError(
|
||||
errorMessage(mutationError, "Abfüllung konnte nicht angelegt werden."),
|
||||
);
|
||||
if (
|
||||
mutationError instanceof ApiError &&
|
||||
mutationError.status === 409 &&
|
||||
!confirmationRequired(mutationError) &&
|
||||
confirmation !== null
|
||||
) {
|
||||
// A duplicate request in flight: the question stays, the answer of the first one counts.
|
||||
setDialogMessage(mutationError.message);
|
||||
return;
|
||||
}
|
||||
const needsConfirmation = confirmationRequired(mutationError);
|
||||
if (needsConfirmation) {
|
||||
// First ask, or ask again: the value changed meanwhile.
|
||||
setDialogMessage(null);
|
||||
setConfirmation(needsConfirmation.waterContent);
|
||||
return;
|
||||
}
|
||||
setConfirmation(null);
|
||||
showSaveError(mutationError);
|
||||
},
|
||||
});
|
||||
|
||||
function showSaveError(error: Error) {
|
||||
const { general, fields } = describeSaveError(error, "Abfüllung konnte nicht angelegt werden.");
|
||||
const known: FieldErrors = {};
|
||||
const other: string[] = general ? [general] : [];
|
||||
for (const [field, message] of Object.entries(fields)) {
|
||||
if ((REQUIRED_KEYS as string[]).includes(field)) known[field as RequiredKey] = message;
|
||||
else other.push(message);
|
||||
}
|
||||
setFieldErrors(known);
|
||||
setSubmitError(other.length > 0 ? other.join(" ") : null);
|
||||
const firstInvalid = REQUIRED_KEYS.find((key) => known[key]);
|
||||
if (firstInvalid) {
|
||||
document.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function renewKeyIfBodyChanged() {
|
||||
const body = JSON.stringify(toBottlingInput(values));
|
||||
if (lastBody.current !== null && lastBody.current !== body) {
|
||||
idempotencyKey.current = newIdempotencyKey();
|
||||
}
|
||||
lastBody.current = body;
|
||||
}
|
||||
|
||||
function confirmWaterContent() {
|
||||
if (confirmInFlight.current || mutation.isPending) return;
|
||||
renewKeyIfBodyChanged();
|
||||
setDialogMessage(null);
|
||||
confirmInFlight.current = true;
|
||||
mutation.mutate(true, {
|
||||
onSettled: () => {
|
||||
confirmInFlight.current = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cancelConfirmation() {
|
||||
// Nothing was written. A new key for whatever the person changes next.
|
||||
setConfirmation(null);
|
||||
setDialogMessage(null);
|
||||
idempotencyKey.current = newIdempotencyKey();
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSubmitError(null);
|
||||
@@ -136,13 +298,69 @@ export default function NewBottlingBatchPage() {
|
||||
?.focus();
|
||||
return;
|
||||
}
|
||||
mutation.mutate(toBottlingInput(values));
|
||||
renewKeyIfBodyChanged();
|
||||
mutation.mutate(false);
|
||||
}
|
||||
|
||||
const jarSizeFromProduct =
|
||||
!manual.jarSize && prefill?.jarSizeSource === "PRODUCT" && prefill.jarSize != null;
|
||||
const jarSizeBase = "Honig pro Glas in Gramm, z. B. 500 oder 250.";
|
||||
const jarSizeHint = fromDraft.jarSize
|
||||
? `aus Entwurf. ${jarSizeBase}`
|
||||
: jarSizeFromProduct
|
||||
? `aus Artikel: ${prefill.jarSize} g. Du kannst ihn ändern.`
|
||||
: prefillQuery.isError
|
||||
? `nicht aus Artikel. ${jarSizeBase}`
|
||||
: jarSizeBase;
|
||||
const bestBeforeFromProduct =
|
||||
!manual.bestBeforeDate && prefill?.bestBeforeDate != null && prefill.shelfLifeMonths != null;
|
||||
const bestBeforeBase = "Kommt aufs Etikett (MHD) – bei Honig meist 2 Jahre nach dem Abfüllen.";
|
||||
const bestBeforeHint = fromDraft.bestBeforeDate
|
||||
? `aus Entwurf. ${bestBeforeBase}`
|
||||
: bestBeforeFromProduct
|
||||
? `aus Artikel: ${prefill.shelfLifeMonths} Monate. Du kannst es ändern.`
|
||||
: prefillQuery.isError
|
||||
? `nicht aus Artikel. ${bestBeforeBase}`
|
||||
: formValues.productId && prefill && prefill.bestBeforeDate == null
|
||||
? "Am Artikel ist keine Haltbarkeit hinterlegt. Bitte das MHD eintragen."
|
||||
: bestBeforeBase;
|
||||
|
||||
// The entered values against the product, before saving (hints, never a block).
|
||||
const localNotes: PlausibilityWarning[] = [];
|
||||
if (prefill && formValues.productId) {
|
||||
if (prefill.jarSize != null && values.jarSize.trim() !== "" && Number(values.jarSize) !== prefill.jarSize) {
|
||||
localNotes.push({
|
||||
code: "JAR_SIZE_DIFFERS",
|
||||
severity: "HINWEIS",
|
||||
message: `Glasgröße weicht vom Artikel ab (Artikel: ${prefill.jarSize} g).`,
|
||||
refs: [],
|
||||
});
|
||||
}
|
||||
if (prefill.bestBeforeDate && values.bestBeforeDate && values.bestBeforeDate !== prefill.bestBeforeDate) {
|
||||
localNotes.push({
|
||||
code: "BEST_BEFORE_DIFFERS",
|
||||
severity: "HINWEIS",
|
||||
message: `MHD weicht vom Artikel ab (Artikel: ${germanDay(prefill.bestBeforeDate)}).`,
|
||||
refs: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const prefillLoading = prefillQuery.isFetching;
|
||||
const submitDescribedBy =
|
||||
[offlineDescribedBy, prefillLoading ? loadingId : undefined].filter(Boolean).join(" ") || undefined;
|
||||
|
||||
function restoreDraft() {
|
||||
const restored = draft.restore();
|
||||
if (restored) {
|
||||
setValues(restored);
|
||||
const { manual: savedManual, ...savedValues } = restored;
|
||||
// A draft from before the flags were kept: treat both as typed. Anything odd counts as not typed.
|
||||
const flags = savedManual
|
||||
? { jarSize: !!savedManual.jarSize, bestBeforeDate: !!savedManual.bestBeforeDate }
|
||||
: { jarSize: true, bestBeforeDate: true };
|
||||
setValues(savedValues);
|
||||
setManual(flags);
|
||||
setFromDraft(flags);
|
||||
setDirty(true);
|
||||
}
|
||||
}
|
||||
@@ -202,6 +420,15 @@ export default function NewBottlingBatchPage() {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<WaterContentNotes prefill={prefill} localNotes={localNotes} />
|
||||
<PrefillStatus
|
||||
enabled={prefillEnabled}
|
||||
isLoading={prefillQuery.isLoading}
|
||||
isError={prefillQuery.isError}
|
||||
isRetrying={prefillQuery.isFetching}
|
||||
onRetry={() => void prefillQuery.refetch()}
|
||||
showLoading={false}
|
||||
/>
|
||||
{withdrawalCheckFailed ? (
|
||||
<p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||||
@@ -223,6 +450,18 @@ export default function NewBottlingBatchPage() {
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<Field label="Produkt">
|
||||
{({ id }) => (
|
||||
<RecordSelect
|
||||
id={id}
|
||||
value={values.productId}
|
||||
onChange={(value) => set("productId", value)}
|
||||
items={products?.filter((product) => product.kind !== "MANUFACTURED")}
|
||||
getLabel={(product) => product.name}
|
||||
placeholder="Produkt wählen"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Datum" required error={fieldErrors.date}>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
@@ -245,7 +484,7 @@ export default function NewBottlingBatchPage() {
|
||||
label="Glasgröße (g)"
|
||||
required
|
||||
error={fieldErrors.jarSize}
|
||||
hint="Honig pro Glas in Gramm, z. B. 500 oder 250."
|
||||
hint={jarSizeHint}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
@@ -289,7 +528,7 @@ export default function NewBottlingBatchPage() {
|
||||
label="Mindesthaltbarkeitsdatum"
|
||||
required
|
||||
error={fieldErrors.bestBeforeDate}
|
||||
hint="Kommt aufs Etikett (MHD) – bei Honig meist 2 Jahre nach dem Abfüllen."
|
||||
hint={bestBeforeHint}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
@@ -314,20 +553,8 @@ export default function NewBottlingBatchPage() {
|
||||
title="Weitere Angaben"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
fieldCount={2}
|
||||
fieldCount={1}
|
||||
>
|
||||
<Field label="Produkt">
|
||||
{({ id }) => (
|
||||
<RecordSelect
|
||||
id={id}
|
||||
value={values.productId}
|
||||
onChange={(value) => set("productId", value)}
|
||||
items={products?.filter((product) => product.kind !== "MANUFACTURED")}
|
||||
getLabel={(product) => product.name}
|
||||
placeholder="Produkt wählen"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Los-/Chargennummer"
|
||||
hint="Steht aufs Etikett, damit jedes Glas seiner Charge zugeordnet werden kann."
|
||||
@@ -347,25 +574,42 @@ export default function NewBottlingBatchPage() {
|
||||
|
||||
<StickySubmit
|
||||
note={
|
||||
submitError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{submitError}
|
||||
</p>
|
||||
) : null
|
||||
<>
|
||||
<div role="status" aria-live="polite">
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
{prefillLoading ? (
|
||||
<p id={loadingId} className="text-sm text-muted-foreground">
|
||||
Artikelwerte werden geladen …
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div role="alert">
|
||||
{submitError ? (
|
||||
<p className="text-sm font-medium text-destructive">{submitError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
ref={submitButton}
|
||||
type="submit"
|
||||
className="h-12 text-base"
|
||||
disabled={mutation.isPending}
|
||||
disabled={mutation.isPending || isOffline || prefillLoading}
|
||||
aria-describedby={submitDescribedBy}
|
||||
>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Abfüllung anlegen"}
|
||||
{mutation.isPending && confirmation === null ? "Wird angelegt…" : "Abfüllung anlegen"}
|
||||
</Button>
|
||||
</StickySubmit>
|
||||
</form>
|
||||
<WaterContentConfirmDialog
|
||||
waterContent={confirmation}
|
||||
isPending={mutation.isPending}
|
||||
returnFocusTo={submitButton}
|
||||
message={dialogMessage}
|
||||
onConfirm={confirmWaterContent}
|
||||
onCancel={cancelConfirmation}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type RefObject } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { formatWaterContent } from "@/lib/bottling/format";
|
||||
|
||||
interface WaterContentConfirmDialogProps {
|
||||
/** The measured value from the 409; the dialog is open while it is set. */
|
||||
waterContent: number | null;
|
||||
isPending: boolean;
|
||||
/** Why the last confirm did not go through (shown inside the dialog, not behind it). */
|
||||
message: string | null;
|
||||
/** Where the focus goes when the dialog closes: the button that opened the question. */
|
||||
returnFocusTo: RefObject<HTMLElement | null>;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server will not bottle honey over the legal water content without a
|
||||
* yes. Base UI's dialog traps the focus; closing it hands the focus back.
|
||||
*/
|
||||
export function WaterContentConfirmDialog({
|
||||
waterContent,
|
||||
isPending,
|
||||
message,
|
||||
returnFocusTo,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: WaterContentConfirmDialogProps) {
|
||||
// Keep the last value so the text does not go empty while the dialog fades out.
|
||||
const [lastValue, setLastValue] = useState<number | null>(waterContent);
|
||||
if (waterContent !== null && waterContent !== lastValue) setLastValue(waterContent);
|
||||
const shown = waterContent ?? lastValue;
|
||||
return (
|
||||
<Dialog
|
||||
open={waterContent !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isPending) onCancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent finalFocus={returnFocusTo}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Wassergehalt zu hoch</DialogTitle>
|
||||
<DialogDescription>
|
||||
{shown === null
|
||||
? ""
|
||||
: `Wassergehalt ${formatWaterContent(shown)} über der gesetzlichen Grenze. Trotzdem abfüllen?`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div role="alert">
|
||||
{message ? <p className="text-sm font-medium text-destructive">{message}</p> : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" size="touch" disabled={isPending} onClick={onCancel}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" size="touch" disabled={isPending} onClick={onConfirm}>
|
||||
{isPending ? "Wird abgefüllt…" : "Trotzdem abfüllen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { AlertTriangle, Info, ShieldAlert } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { formatWaterContent } from "@/lib/bottling/format";
|
||||
import type { BottlingPrefill } from "@/lib/bottling/types";
|
||||
import type { PlausibilityWarning, WarningSeverity } from "@/lib/warnings/types";
|
||||
|
||||
const SEVERITY_LOOK: Record<WarningSeverity, { label: string; Icon: ComponentType<{ className?: string }> }> = {
|
||||
HINWEIS: { label: "Hinweis", Icon: Info },
|
||||
WARNUNG: { label: "Warnung", Icon: AlertTriangle },
|
||||
BESTAETIGEN: { label: "Bestätigung nötig", Icon: ShieldAlert },
|
||||
};
|
||||
|
||||
/**
|
||||
* The batch's measured water content and what the server notes about it, as
|
||||
* information before saving. The live region stays mounted so a screen
|
||||
* reader hears the text when it arrives.
|
||||
*/
|
||||
export function WaterContentNotes({
|
||||
prefill,
|
||||
localNotes = [],
|
||||
}: {
|
||||
prefill: BottlingPrefill | undefined;
|
||||
/** Findings the form works out itself (entered value vs product), shown as hints. */
|
||||
localNotes?: PlausibilityWarning[];
|
||||
}) {
|
||||
const localCodes = new Set(localNotes.map((note) => note.code));
|
||||
const warnings = [
|
||||
...(prefill?.warnings ?? []).filter((warning) => !localCodes.has(warning.code)),
|
||||
...localNotes,
|
||||
];
|
||||
const hasContent = (prefill !== undefined && prefill.waterContent !== null) || warnings.length > 0;
|
||||
return (
|
||||
<div role="status" aria-live="polite">
|
||||
{hasContent ? (
|
||||
<div className="flex flex-col gap-1.5 rounded-lg bg-muted p-3 text-sm">
|
||||
{prefill !== undefined && prefill.waterContent !== null ? (
|
||||
<p>Wassergehalt der Charge: {formatWaterContent(prefill.waterContent)}</p>
|
||||
) : null}
|
||||
{warnings.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{warnings.map((warning) => {
|
||||
const { label, Icon } = SEVERITY_LOOK[warning.severity];
|
||||
return (
|
||||
<li key={warning.code} className="flex items-start gap-2">
|
||||
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<span className="font-medium">{label}</span>
|
||||
<span>{warning.message}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ describe("ProductDetailPage", () => {
|
||||
description: null,
|
||||
sku: null,
|
||||
packagingSize: null,
|
||||
shelfLifeMonths: null,
|
||||
weight: null,
|
||||
salesUnit: null,
|
||||
status: "ACTIVE",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { EditProductDialog } from "./edit-product-dialog";
|
||||
import { updateProduct } from "@/lib/products/api";
|
||||
import type { Product } from "@/lib/products/types";
|
||||
|
||||
vi.mock("@/lib/products/api", () => ({ updateProduct: vi.fn() }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const PRODUCT: Product = {
|
||||
id: "p-1",
|
||||
name: "Frühtracht",
|
||||
description: null,
|
||||
sku: null,
|
||||
packagingSize: 500,
|
||||
shelfLifeMonths: 24,
|
||||
weight: null,
|
||||
salesUnit: null,
|
||||
status: "ACTIVE",
|
||||
kind: "BOTTLED_HONEY",
|
||||
shopCategory: "HONIG",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
async function openDialog(product: Product = PRODUCT) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<EditProductDialog product={product} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ }));
|
||||
}
|
||||
|
||||
describe("EditProductDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(updateProduct).mockReset();
|
||||
vi.mocked(updateProduct).mockResolvedValue(PRODUCT);
|
||||
});
|
||||
|
||||
it("the product form saves and clears shelfLifeMonths", async () => {
|
||||
await openDialog();
|
||||
const field = screen.getByLabelText(/Haltbarkeit \(Monate\)/);
|
||||
expect(field).toHaveValue(24);
|
||||
|
||||
await userEvent.clear(field);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
await waitFor(() => expect(updateProduct).toHaveBeenCalled());
|
||||
expect(vi.mocked(updateProduct).mock.calls[0][1]).toMatchObject({ shelfLifeMonths: null });
|
||||
});
|
||||
|
||||
it("the product form labels packagingSize as grams and rejects 0 and values above 10000", async () => {
|
||||
await openDialog();
|
||||
const field = screen.getByLabelText(/Gewicht pro Glas \(g\)/);
|
||||
|
||||
for (const bad of ["0", "10001"]) {
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, bad);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(screen.getByText("Bitte ganze Gramm von 1 bis 10000 angeben.")).toBeInTheDocument();
|
||||
}
|
||||
expect(updateProduct).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "250");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
await waitFor(() => expect(updateProduct).toHaveBeenCalled());
|
||||
expect(vi.mocked(updateProduct).mock.calls[0][1]).toMatchObject({
|
||||
packagingSize: 250,
|
||||
shelfLifeMonths: 24,
|
||||
});
|
||||
});
|
||||
|
||||
it("renames a product with a legacy decimal packagingSize without converting it", async () => {
|
||||
await openDialog({ ...PRODUCT, packagingSize: 250.5 });
|
||||
const name = screen.getByLabelText("Name");
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, "Frühtracht neu");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
await waitFor(() => expect(updateProduct).toHaveBeenCalled());
|
||||
expect(vi.mocked(updateProduct).mock.calls[0][1]).toMatchObject({
|
||||
name: "Frühtracht neu",
|
||||
packagingSize: 250.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("starts from the stored product each time it opens", async () => {
|
||||
await openDialog();
|
||||
const name = screen.getByLabelText("Name");
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, "Etwas anderes");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByLabelText("Name")).not.toBeInTheDocument());
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Bearbeiten/ }));
|
||||
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("Frühtracht");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { updateProduct } from "@/lib/products/api";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { packagingSizeProblem, shelfLifeProblem } from "@/lib/products/validation";
|
||||
import { Product, ProductStatus, ShopCategory } from "@/lib/products/types";
|
||||
import {
|
||||
PRODUCT_KIND_LABELS,
|
||||
@@ -33,11 +34,30 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
const [description, setDescription] = useState(product.description ?? "");
|
||||
const [sku, setSku] = useState(product.sku ?? "");
|
||||
const [packagingSize, setPackagingSize] = useState(product.packagingSize?.toString() ?? "");
|
||||
const [shelfLifeMonths, setShelfLifeMonths] = useState(product.shelfLifeMonths?.toString() ?? "");
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [packagingError, setPackagingError] = useState<string | null>(null);
|
||||
const [shelfLifeError, setShelfLifeError] = useState<string | null>(null);
|
||||
const [weight, setWeight] = useState(product.weight?.toString() ?? "");
|
||||
const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? "");
|
||||
const [status, setStatus] = useState<ProductStatus>(product.status);
|
||||
const [shopCategory, setShopCategory] = useState<ShopCategory>(product.shopCategory);
|
||||
|
||||
function resetToProduct() {
|
||||
setName(product.name);
|
||||
setDescription(product.description ?? "");
|
||||
setSku(product.sku ?? "");
|
||||
setPackagingSize(product.packagingSize?.toString() ?? "");
|
||||
setShelfLifeMonths(product.shelfLifeMonths?.toString() ?? "");
|
||||
setWeight(product.weight?.toString() ?? "");
|
||||
setSalesUnit(product.salesUnit ?? "");
|
||||
setStatus(product.status);
|
||||
setShopCategory(product.shopCategory);
|
||||
setNameError(null);
|
||||
setPackagingError(null);
|
||||
setShelfLifeError(null);
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateProduct(product.id, {
|
||||
@@ -45,6 +65,7 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
description: emptyToNull(description),
|
||||
sku: emptyToNull(sku),
|
||||
packagingSize: emptyToNull(packagingSize, Number),
|
||||
shelfLifeMonths: emptyToNull(shelfLifeMonths, Number),
|
||||
weight: emptyToNull(weight, Number),
|
||||
salesUnit: emptyToNull(salesUnit),
|
||||
status,
|
||||
@@ -59,11 +80,36 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
// An unchanged legacy value (e.g. 250.5) must not block a rename; it is never converted.
|
||||
const packagingChanged = packagingSize.trim() !== (product.packagingSize?.toString() ?? "");
|
||||
const packaging = packagingChanged ? packagingSizeProblem(packagingSize) : null;
|
||||
const shelfLife = shelfLifeProblem(shelfLifeMonths);
|
||||
const missingName = name.trim() === "" ? "Bitte einen Namen eingeben." : null;
|
||||
setNameError(missingName);
|
||||
setPackagingError(packaging);
|
||||
setShelfLifeError(shelfLife);
|
||||
const firstInvalid = missingName
|
||||
? "product-edit-name"
|
||||
: packaging
|
||||
? "product-edit-packaging-size"
|
||||
: shelfLife
|
||||
? "product-edit-shelf-life"
|
||||
: null;
|
||||
if (firstInvalid) {
|
||||
document.getElementById(firstInvalid)?.focus();
|
||||
return;
|
||||
}
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) resetToProduct();
|
||||
setOpen(next);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm">
|
||||
@@ -76,15 +122,25 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Produkt bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-name">Name</Label>
|
||||
<Input
|
||||
id="product-edit-name"
|
||||
required
|
||||
aria-invalid={nameError ? true : undefined}
|
||||
aria-describedby={nameError ? "product-edit-name-error" : undefined}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setNameError(null);
|
||||
}}
|
||||
/>
|
||||
{nameError ? (
|
||||
<p id="product-edit-name-error" role="alert" className="text-sm font-medium text-destructive">
|
||||
{nameError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-description">Beschreibung</Label>
|
||||
@@ -103,15 +159,50 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-packaging-size">Gebindegröße</Label>
|
||||
<Label htmlFor="product-edit-packaging-size">Gewicht pro Glas (g)</Label>
|
||||
<Input
|
||||
id="product-edit-packaging-size"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
inputMode="numeric"
|
||||
step={1}
|
||||
min={1}
|
||||
max={10000}
|
||||
aria-invalid={packagingError ? true : undefined}
|
||||
aria-describedby={packagingError ? "product-edit-packaging-size-error" : undefined}
|
||||
value={packagingSize}
|
||||
onChange={(event) => setPackagingSize(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setPackagingSize(event.target.value);
|
||||
setPackagingError(null);
|
||||
}}
|
||||
/>
|
||||
{packagingError ? (
|
||||
<p id="product-edit-packaging-size-error" role="alert" className="text-sm font-medium text-destructive">
|
||||
{packagingError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-shelf-life">Haltbarkeit (Monate)</Label>
|
||||
<Input
|
||||
id="product-edit-shelf-life"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step={1}
|
||||
min={1}
|
||||
max={60}
|
||||
aria-invalid={shelfLifeError ? true : undefined}
|
||||
aria-describedby={shelfLifeError ? "product-edit-shelf-life-error" : undefined}
|
||||
value={shelfLifeMonths}
|
||||
onChange={(event) => {
|
||||
setShelfLifeMonths(event.target.value);
|
||||
setShelfLifeError(null);
|
||||
}}
|
||||
/>
|
||||
{shelfLifeError ? (
|
||||
<p id="product-edit-shelf-life-error" role="alert" className="text-sm font-medium text-destructive">
|
||||
{shelfLifeError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-weight">Gewicht</Label>
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("NewProductPage", () => {
|
||||
renderPage();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Name/), "Frühtracht");
|
||||
await userEvent.type(screen.getByLabelText(/Gebindegröße/), "500");
|
||||
await userEvent.type(screen.getByLabelText(/Gewicht pro Glas/), "500");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /Weitere Angaben/ }),
|
||||
);
|
||||
@@ -59,6 +59,7 @@ describe("NewProductPage", () => {
|
||||
packagingSize: 500,
|
||||
weight: null,
|
||||
salesUnit: null,
|
||||
shelfLifeMonths: null,
|
||||
kind: "BOTTLED_HONEY",
|
||||
shopCategory: "HONIG",
|
||||
});
|
||||
@@ -73,4 +74,45 @@ describe("NewProductPage", () => {
|
||||
expect(screen.getByLabelText(/Name/)).toHaveFocus();
|
||||
expect(createProduct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the product form saves shelfLifeMonths", async () => {
|
||||
vi.mocked(createProduct).mockResolvedValue({ id: "p-1" } as never);
|
||||
renderPage();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Name/), "Frühtracht");
|
||||
await userEvent.type(screen.getByLabelText(/Haltbarkeit \(Monate\)/), "24");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Produkt anlegen" }));
|
||||
|
||||
expect(vi.mocked(createProduct).mock.calls[0][0]).toMatchObject({ shelfLifeMonths: 24 });
|
||||
});
|
||||
|
||||
it("the product form labels packagingSize as grams and rejects 0 and values above 10000", async () => {
|
||||
renderPage();
|
||||
const field = screen.getByLabelText(/Gewicht pro Glas \(g\)/);
|
||||
await userEvent.type(screen.getByLabelText(/Name/), "Frühtracht");
|
||||
|
||||
for (const bad of ["0", "10001", "250.5"]) {
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, bad);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Produkt anlegen" }));
|
||||
expect(screen.getByText("Bitte ganze Gramm von 1 bis 10000 angeben.")).toBeInTheDocument();
|
||||
expect(field).toHaveFocus();
|
||||
}
|
||||
expect(createProduct).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "10000");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Produkt anlegen" }));
|
||||
expect(vi.mocked(createProduct).mock.calls[0][0]).toMatchObject({ packagingSize: 10000 });
|
||||
});
|
||||
|
||||
it("rejects a shelf life outside 1 to 60 months", async () => {
|
||||
renderPage();
|
||||
await userEvent.type(screen.getByLabelText(/Name/), "Frühtracht");
|
||||
await userEvent.type(screen.getByLabelText(/Haltbarkeit \(Monate\)/), "61");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Produkt anlegen" }));
|
||||
|
||||
expect(screen.getByText("Bitte ganze Monate von 1 bis 60 angeben.")).toBeInTheDocument();
|
||||
expect(createProduct).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -166,17 +166,21 @@ export default function NewProductPage() {
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Gebindegröße (g)"
|
||||
hint="Füllmenge in Gramm, z. B. 500"
|
||||
label="Gewicht pro Glas (g)"
|
||||
hint="Honig pro Glas in Gramm, z. B. 500. Die Abfüllung übernimmt den Wert."
|
||||
error={errors.packagingSize}
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
data-field="packagingSize"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min={0}
|
||||
inputMode="numeric"
|
||||
step={1}
|
||||
min={1}
|
||||
max={10000}
|
||||
value={values.packagingSize}
|
||||
onChange={(event) =>
|
||||
set("packagingSize", event.target.value)
|
||||
@@ -184,6 +188,29 @@ export default function NewProductPage() {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Haltbarkeit (Monate)"
|
||||
hint="Daraus rechnet die Abfüllung das MHD. Leer lassen, wenn es keine feste Haltbarkeit gibt."
|
||||
error={errors.shelfLifeMonths}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
data-field="shelfLifeMonths"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step={1}
|
||||
min={1}
|
||||
max={60}
|
||||
value={values.shelfLifeMonths}
|
||||
onChange={(event) =>
|
||||
set("shelfLifeMonths", event.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
|
||||
@@ -4,12 +4,14 @@ import type {
|
||||
ShopCategory,
|
||||
} from "@/lib/products/types";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { packagingSizeProblem, shelfLifeProblem } from "@/lib/products/validation";
|
||||
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
export interface ProductFormValues {
|
||||
name: string;
|
||||
salesUnit: string;
|
||||
packagingSize: string;
|
||||
shelfLifeMonths: string;
|
||||
weight: string;
|
||||
sku: string;
|
||||
description: string;
|
||||
@@ -24,6 +26,7 @@ export function initialProductValues(): ProductFormValues {
|
||||
name: "",
|
||||
salesUnit: "",
|
||||
packagingSize: "",
|
||||
shelfLifeMonths: "",
|
||||
weight: "",
|
||||
sku: "",
|
||||
description: "",
|
||||
@@ -36,6 +39,10 @@ export function initialProductValues(): ProductFormValues {
|
||||
export function validateProduct(values: ProductFormValues): ProductFormErrors {
|
||||
const errors: ProductFormErrors = {};
|
||||
if (!values.name.trim()) errors.name = "Bitte einen Namen eingeben.";
|
||||
const packaging = packagingSizeProblem(values.packagingSize);
|
||||
if (packaging) errors.packagingSize = packaging;
|
||||
const shelfLife = shelfLifeProblem(values.shelfLifeMonths);
|
||||
if (shelfLife) errors.shelfLifeMonths = shelfLife;
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -45,6 +52,7 @@ export function toProductInput(values: ProductFormValues): CreateProductInput {
|
||||
description: emptyToNull(values.description),
|
||||
sku: emptyToNull(values.sku),
|
||||
packagingSize: emptyToNull(values.packagingSize, Number),
|
||||
shelfLifeMonths: emptyToNull(values.shelfLifeMonths, Number),
|
||||
weight: emptyToNull(values.weight, Number),
|
||||
salesUnit: emptyToNull(values.salesUnit),
|
||||
kind: values.kind,
|
||||
|
||||
@@ -23,6 +23,14 @@ const settings: NotificationSettings = {
|
||||
quietUntil: "06:30",
|
||||
telegramCategories: ["TASK_DUE", "DIGEST"],
|
||||
telegramDetail: true,
|
||||
telegram: {
|
||||
botNameSet: true,
|
||||
botUsername: "apicore_bot",
|
||||
linked: false,
|
||||
firstName: null,
|
||||
linkedAt: null,
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
let client: QueryClient;
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useId, useRef, useState } from "react";
|
||||
import { FormEvent, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchNotificationSettings,
|
||||
updateNotificationSettings,
|
||||
} from "@/lib/notification-settings/api";
|
||||
import type { NotificationSettings } from "@/lib/notification-settings/types";
|
||||
import { NOTIFICATION_SETTINGS_QUERY_KEY as QUERY_KEY } from "@/lib/notification-settings/query-key";
|
||||
import type {
|
||||
NotificationSettings,
|
||||
NotificationSettingsPatch,
|
||||
} from "@/lib/notification-settings/types";
|
||||
import {
|
||||
settingsPatch,
|
||||
validateDigestTime,
|
||||
validateQuietHours,
|
||||
} from "@/lib/notification-settings/validation";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { SwitchRow } from "@/components/form/switch-row";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -26,45 +31,6 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { ErrorState, LoadingState } from "@/components/query-state";
|
||||
|
||||
const QUERY_KEY = ["notification-settings"] as const;
|
||||
|
||||
interface SwitchRowProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
function SwitchRow({ label, description, checked, onChange }: SwitchRowProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-labelledby={`${id}-label`}
|
||||
aria-describedby={description ? `${id}-description` : undefined}
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="mt-1 size-4 accent-[var(--primary)]"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span id={`${id}-label`} className="font-medium">
|
||||
{label}
|
||||
</span>
|
||||
{description ? (
|
||||
<span
|
||||
id={`${id}-description`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
digestTime?: string;
|
||||
quiet?: string;
|
||||
@@ -84,7 +50,7 @@ function NotificationSettingsForm({
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (patch: Partial<NotificationSettings>) =>
|
||||
mutationFn: (patch: NotificationSettingsPatch) =>
|
||||
updateNotificationSettings(patch),
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(QUERY_KEY, saved);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UserRound } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { stepUpErrorText } from "@/lib/auth/step-up-error";
|
||||
import {
|
||||
changePassword,
|
||||
deleteAvatar,
|
||||
@@ -28,6 +29,7 @@ import { MfaSection } from "./mfa-section";
|
||||
import { DangerZoneSection } from "./danger-zone-section";
|
||||
import { MotionSection } from "./motion-section";
|
||||
import { NotificationSettingsCard } from "./notification-settings-card";
|
||||
import { TelegramLinkCard } from "./telegram-link-card";
|
||||
|
||||
function splitName(fullName: string): { firstName: string; lastName: string } {
|
||||
const [firstName, ...rest] = fullName.trim().split(/\s+/);
|
||||
@@ -226,7 +228,7 @@ function PasswordSection() {
|
||||
description: "Andere angemeldete Geräte wurden abgemeldet.",
|
||||
});
|
||||
},
|
||||
onError: () => setError("Aktuelles Passwort ist falsch."),
|
||||
onError: (cause) => setError(stepUpErrorText(cause, "Aktuelles Passwort ist falsch.")),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -304,6 +306,7 @@ export default function ProfilePage() {
|
||||
<PasswordSection />
|
||||
<MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} />
|
||||
<NotificationSettingsCard />
|
||||
<TelegramLinkCard />
|
||||
<MotionSection />
|
||||
<DangerZoneSection />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TelegramLinkCard } from "./telegram-link-card";
|
||||
import {
|
||||
fetchNotificationSettings,
|
||||
updateNotificationSettings,
|
||||
} from "@/lib/notification-settings/api";
|
||||
import { createTelegramLinkToken, unlinkTelegram } from "@/lib/telegram/api";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
import type { NotificationSettings } from "@/lib/notification-settings/types";
|
||||
|
||||
vi.mock("@/lib/notification-settings/api", () => ({
|
||||
fetchNotificationSettings: vi.fn(),
|
||||
updateNotificationSettings: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/telegram/api", () => ({
|
||||
createTelegramLinkToken: vi.fn(),
|
||||
unlinkTelegram: vi.fn(),
|
||||
}));
|
||||
let mfaEnabled = false;
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ user: { mfaEnabled } }),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const notLinked: NotificationSettings = {
|
||||
taskDueEnabled: true,
|
||||
digestEnabled: true,
|
||||
digestTime: "06:30",
|
||||
quietFrom: null,
|
||||
quietUntil: null,
|
||||
telegramCategories: ["TASK_DUE", "DIGEST"],
|
||||
telegramDetail: true,
|
||||
telegram: {
|
||||
botNameSet: true,
|
||||
botUsername: "apicore_bot",
|
||||
linked: false,
|
||||
firstName: null,
|
||||
linkedAt: null,
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const linked: NotificationSettings = {
|
||||
...notLinked,
|
||||
telegram: {
|
||||
...notLinked.telegram,
|
||||
linked: true,
|
||||
firstName: "Nick",
|
||||
linkedAt: "2026-10-14T10:03:00.000Z",
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const token = {
|
||||
deepLink: "https://t.me/apicore_bot?start=abc",
|
||||
// 12:10 in Berlin (CEST).
|
||||
expiresAt: "2026-10-14T10:10:00.000Z",
|
||||
};
|
||||
|
||||
function renderCard() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TelegramLinkCard />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function connect(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(await screen.findByRole("button", { name: "Telegram verbinden" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" }));
|
||||
}
|
||||
|
||||
describe("TelegramLinkCard", () => {
|
||||
beforeEach(() => {
|
||||
mfaEnabled = false;
|
||||
vi.mocked(fetchNotificationSettings).mockReset().mockResolvedValue(notLinked);
|
||||
vi.mocked(updateNotificationSettings).mockReset().mockResolvedValue(notLinked);
|
||||
vi.mocked(createTelegramLinkToken).mockReset().mockResolvedValue(token);
|
||||
vi.mocked(unlinkTelegram).mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
});
|
||||
|
||||
it("asks for the password before creating the link", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
await connect(user);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createTelegramLinkToken).toHaveBeenCalledWith({ password: "geheim" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for the TOTP code too when MFA is on", async () => {
|
||||
mfaEnabled = true;
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
await user.click(await screen.findByRole("button", { name: "Telegram verbinden" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
|
||||
await user.type(within(dialog).getByLabelText("Code aus der Authenticator-App"), "123456");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createTelegramLinkToken).toHaveBeenCalledWith({
|
||||
password: "geheim",
|
||||
mfaCode: "123456",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows „In Telegram öffnen“ with the expiry time and later „Verbunden mit „Nick““", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(new Date("2026-10-14T10:00:00.000Z"));
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
renderCard();
|
||||
await connect(user);
|
||||
|
||||
const open = await screen.findByRole("link", { name: /In Telegram öffnen/ });
|
||||
expect(open).toHaveAttribute("href", token.deepLink);
|
||||
expect(open).toHaveAttribute("target", "_blank");
|
||||
expect(open).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(screen.getByText("Gültig bis 12:10")).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Nicht verbunden");
|
||||
|
||||
vi.mocked(fetchNotificationSettings).mockResolvedValue(linked);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Verbunden mit „Nick“ seit 14.10.2026"),
|
||||
);
|
||||
expect(screen.queryByRole("link", { name: /In Telegram öffnen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves focus to the open-in-Telegram link once the link exists", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(new Date("2026-10-14T10:00:00.000Z"));
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
renderCard();
|
||||
await connect(user);
|
||||
|
||||
const open = await screen.findByRole("link", { name: /In Telegram öffnen/ });
|
||||
await waitFor(() => expect(open).toHaveFocus());
|
||||
});
|
||||
|
||||
it("refuses a link that does not point to Telegram and says so", async () => {
|
||||
vi.mocked(createTelegramLinkToken).mockResolvedValue({
|
||||
...token,
|
||||
deepLink: "https://evil.example/?start=abc",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
await connect(user);
|
||||
|
||||
expect(await screen.findByText(/Der Telegram-Link ist ungültig/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /In Telegram öffnen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Trennen with the offline reason", async () => {
|
||||
vi.mocked(fetchNotificationSettings).mockResolvedValue(linked);
|
||||
renderCard();
|
||||
const trennen = await screen.findByRole("button", { name: "Trennen" });
|
||||
expect(trennen).not.toHaveAttribute("aria-describedby");
|
||||
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Trennen" })).toBeDisabled());
|
||||
const described = screen.getByRole("button", { name: "Trennen" }).getAttribute("aria-describedby");
|
||||
expect(described).toBeTruthy();
|
||||
expect(document.getElementById(described as string)).toHaveTextContent("Nur mit Verbindung");
|
||||
});
|
||||
|
||||
it("disables the category chips while a save runs", async () => {
|
||||
vi.mocked(fetchNotificationSettings).mockResolvedValue(linked);
|
||||
vi.mocked(updateNotificationSettings).mockReturnValue(new Promise(() => {}));
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
|
||||
await user.click(await screen.findByRole("checkbox", { name: "System" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Vorschläge" })).toBeDisabled());
|
||||
});
|
||||
|
||||
it("„Trennen“ asks and unlinks", async () => {
|
||||
vi.mocked(fetchNotificationSettings).mockResolvedValue(linked);
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Trennen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(unlinkTelegram).not.toHaveBeenCalled();
|
||||
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
|
||||
|
||||
await waitFor(() => expect(unlinkTelegram).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("shows no old link after linking and then Trennen", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(new Date("2026-10-14T10:00:00.000Z"));
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
renderCard();
|
||||
await connect(user);
|
||||
await screen.findByRole("link", { name: /In Telegram öffnen/ });
|
||||
|
||||
vi.mocked(fetchNotificationSettings).mockResolvedValue(linked);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("link", { name: /In Telegram öffnen/ })).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
vi.mocked(fetchNotificationSettings).mockResolvedValue(notLinked);
|
||||
await user.click(await screen.findByRole("button", { name: "Trennen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
|
||||
|
||||
await screen.findByRole("button", { name: "Telegram verbinden" });
|
||||
expect(screen.queryByRole("link", { name: /In Telegram öffnen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("after Trennen focus lands on Telegram verbinden", async () => {
|
||||
vi.mocked(fetchNotificationSettings).mockResolvedValueOnce(linked).mockResolvedValue(notLinked);
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Trennen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
|
||||
|
||||
const connectButton = await screen.findByRole("button", { name: "Telegram verbinden" });
|
||||
await waitFor(() => expect(connectButton).toHaveFocus());
|
||||
});
|
||||
|
||||
it("without a bot shows „Telegram ist nicht eingerichtet.“ and no button", async () => {
|
||||
vi.mocked(fetchNotificationSettings).mockResolvedValue({
|
||||
...notLinked,
|
||||
telegram: { ...notLinked.telegram, botNameSet: false, botUsername: null },
|
||||
});
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText("Telegram ist nicht eingerichtet.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves the categories and „Mit Details“ separately when connected", async () => {
|
||||
vi.mocked(fetchNotificationSettings).mockResolvedValue(linked);
|
||||
vi.mocked(updateNotificationSettings).mockResolvedValue({
|
||||
...linked,
|
||||
telegramCategories: ["TASK_DUE", "DIGEST", "SYSTEM"],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
|
||||
await user.click(await screen.findByRole("checkbox", { name: "System" }));
|
||||
await waitFor(() =>
|
||||
expect(updateNotificationSettings).toHaveBeenCalledWith({
|
||||
telegramCategories: ["TASK_DUE", "DIGEST", "SYSTEM"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("switch", { name: "Mit Details" }));
|
||||
await waitFor(() =>
|
||||
expect(updateNotificationSettings).toHaveBeenCalledWith({ telegramDetail: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { ExternalLink, Send } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import {
|
||||
fetchNotificationSettings,
|
||||
updateNotificationSettings,
|
||||
} from "@/lib/notification-settings/api";
|
||||
import { NOTIFICATION_SETTINGS_QUERY_KEY as QUERY_KEY } from "@/lib/notification-settings/query-key";
|
||||
import type {
|
||||
NotificationSettings,
|
||||
NotificationSettingsPatch,
|
||||
} from "@/lib/notification-settings/types";
|
||||
import {
|
||||
NOTIFICATION_CATEGORIES,
|
||||
NOTIFICATION_CATEGORY_LABELS,
|
||||
} from "@/lib/notifications/labels";
|
||||
import type { NotificationCategory } from "@/lib/notifications/types";
|
||||
import { saveErrorText } from "@/lib/honey-batches/errors";
|
||||
import { createTelegramLinkToken, unlinkTelegram } from "@/lib/telegram/api";
|
||||
import { formatBerlinDate, formatBerlinTime } from "@/lib/telegram/format";
|
||||
import type { StepUpProof, TelegramLinkToken } from "@/lib/telegram/types";
|
||||
import { StepUpDialog } from "@/components/auth/step-up-dialog";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { SwitchRow } from "@/components/form/switch-row";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { ErrorState } from "@/components/query-state";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
/** While a link is open, the status is reloaded this often to notice "/start". */
|
||||
export const LINK_STATUS_POLL_MS = 5000;
|
||||
|
||||
/** Only a link to Telegram itself is ever shown as the way out of the app. */
|
||||
const TELEGRAM_LINK_PREFIX = "https://t.me/";
|
||||
const INVALID_LINK_TEXT = "Der Telegram-Link ist ungültig. Bitte später noch einmal versuchen.";
|
||||
|
||||
const PRIVACY_NOTE =
|
||||
"Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.";
|
||||
|
||||
function isOpenLink(link: TelegramLinkToken | null): link is TelegramLinkToken {
|
||||
return link !== null && Date.parse(link.expiresAt) > Date.now();
|
||||
}
|
||||
|
||||
function statusText(settings: NotificationSettings | undefined): string {
|
||||
if (!settings) return "Status wird geladen…";
|
||||
const { telegram } = settings;
|
||||
if (!telegram.botNameSet) return "Telegram ist nicht eingerichtet.";
|
||||
if (!telegram.linked) return "Nicht verbunden";
|
||||
const name = telegram.firstName ? `„${telegram.firstName}“` : "Telegram";
|
||||
const since = telegram.linkedAt ? ` seit ${formatBerlinDate(telegram.linkedAt)}` : "";
|
||||
return `Verbunden mit ${name}${since}`;
|
||||
}
|
||||
|
||||
interface DeliverySettingsProps {
|
||||
settings: NotificationSettings;
|
||||
isOffline: boolean;
|
||||
/** Id of the „Nur mit Verbindung“ hint the card renders once. */
|
||||
hintId: string;
|
||||
}
|
||||
|
||||
/** What goes to Telegram. Saved on its own, like the other settings. */
|
||||
function DeliverySettings({ settings, isOffline, hintId }: DeliverySettingsProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (patch: NotificationSettingsPatch) => updateNotificationSettings(patch),
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(QUERY_KEY, saved);
|
||||
setError(null);
|
||||
},
|
||||
onError: (cause) => setError(saveErrorText(cause, "Die Einstellung wurde nicht gespeichert.")),
|
||||
});
|
||||
|
||||
function toggleCategory(category: NotificationCategory, checked: boolean) {
|
||||
if (mutation.isPending || isOffline) return;
|
||||
const selected = new Set(settings.telegramCategories);
|
||||
if (checked) selected.add(category);
|
||||
else selected.delete(category);
|
||||
mutation.mutate({
|
||||
telegramCategories: NOTIFICATION_CATEGORIES.filter((entry) => selected.has(entry)),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm font-medium">Das kommt per Telegram</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{NOTIFICATION_CATEGORIES.map((category) => (
|
||||
<ToggleChip
|
||||
key={category}
|
||||
label={NOTIFICATION_CATEGORY_LABELS[category]}
|
||||
checked={settings.telegramCategories.includes(category)}
|
||||
onChange={(checked) => toggleCategory(category, checked)}
|
||||
describedBy={isOffline ? hintId : undefined}
|
||||
disabled={mutation.isPending || isOffline}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<SwitchRow
|
||||
label="Mit Details"
|
||||
description={PRIVACY_NOTE}
|
||||
checked={settings.telegramDetail}
|
||||
disabled={mutation.isPending || isOffline}
|
||||
onChange={(checked) => mutation.mutate({ telegramDetail: checked })}
|
||||
/>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OpenLinkProps {
|
||||
link: TelegramLinkToken;
|
||||
anchorRef: RefObject<HTMLAnchorElement | null>;
|
||||
}
|
||||
|
||||
/** The one-time link as the big primary action; it holds a secret, so it only lives here. */
|
||||
function OpenLink({ link, anchorRef }: OpenLinkProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<a
|
||||
ref={anchorRef}
|
||||
href={link.deepLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonVariants({ size: "touch", className: "w-full sm:w-auto" })}
|
||||
>
|
||||
<ExternalLink aria-hidden />
|
||||
In Telegram öffnen
|
||||
</a>
|
||||
<p className="text-sm text-muted-foreground">Gültig bis {formatBerlinTime(link.expiresAt)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TelegramLinkCard() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [link, setLink] = useState<TelegramLinkToken | null>(null);
|
||||
const [isStepUpOpen, setIsStepUpOpen] = useState(false);
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
const anchorRef = useRef<HTMLAnchorElement>(null);
|
||||
const connectRef = useRef<HTMLButtonElement>(null);
|
||||
const focusConnectNext = useRef(false);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: fetchNotificationSettings,
|
||||
// Only while a link can still be used: then "/start" in Telegram shows up here.
|
||||
refetchInterval: (current) =>
|
||||
isOpenLink(link) && !current.state.data?.telegram.linked ? LINK_STATUS_POLL_MS : false,
|
||||
});
|
||||
|
||||
// The link stops being shown at its expiry.
|
||||
useEffect(() => {
|
||||
if (!link) return;
|
||||
const timer = setTimeout(() => setLink(null), Math.max(Date.parse(link.expiresAt) - Date.now(), 0));
|
||||
return () => clearTimeout(timer);
|
||||
}, [link]);
|
||||
|
||||
const unlink = useMutation({
|
||||
mutationFn: unlinkTelegram,
|
||||
onSuccess: async () => {
|
||||
setLink(null);
|
||||
toast.success("Telegram getrennt");
|
||||
focusConnectNext.current = true;
|
||||
await queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
onError: (cause) => toast.error(saveErrorText(cause, "Telegram konnte nicht getrennt werden.")),
|
||||
});
|
||||
|
||||
async function createLink(proof: StepUpProof): Promise<void> {
|
||||
// Not a React Query mutation: its result would stay in the cache.
|
||||
const created = await createTelegramLinkToken(proof);
|
||||
if (!created.deepLink.startsWith(TELEGRAM_LINK_PREFIX)) {
|
||||
setLinkError(INVALID_LINK_TEXT);
|
||||
return;
|
||||
}
|
||||
setLinkError(null);
|
||||
setLink(created);
|
||||
}
|
||||
|
||||
const settings = query.data;
|
||||
const botNameSet = settings?.telegram.botNameSet ?? false;
|
||||
const isLinked = settings?.telegram.linked ?? false;
|
||||
const openLink = !isLinked && isOpenLink(link) ? link : null;
|
||||
const canConnect = Boolean(settings) && botNameSet && !isLinked && !openLink;
|
||||
|
||||
// Connected (e.g. via /start): the one-time link is spent.
|
||||
// Adjusted while rendering (the documented alternative to an effect that sets state).
|
||||
const settingsLinked = Boolean(query.data?.telegram.linked);
|
||||
const [wasLinked, setWasLinked] = useState(false);
|
||||
if (settingsLinked !== wasLinked) {
|
||||
setWasLinked(settingsLinked);
|
||||
if (settingsLinked) setLink(null);
|
||||
}
|
||||
|
||||
// After „Trennen“ the person lands on the way back in, not on a vanished button.
|
||||
useEffect(() => {
|
||||
if (canConnect && focusConnectNext.current) {
|
||||
focusConnectNext.current = false;
|
||||
connectRef.current?.focus();
|
||||
}
|
||||
}, [canConnect]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Telegram</CardTitle>
|
||||
<CardDescription>Erinnerungen auch aufs Handy, wenn die App zu ist.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<p role="status" className="flex items-center gap-2 text-sm font-medium">
|
||||
<Send className="size-4 shrink-0" aria-hidden />
|
||||
{statusText(settings)}
|
||||
</p>
|
||||
{query.isError ? (
|
||||
<ErrorState
|
||||
what="Telegram-Einstellungen"
|
||||
onRetry={() => void query.refetch()}
|
||||
isRetrying={query.isFetching}
|
||||
/>
|
||||
) : null}
|
||||
{linkError ? (
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
{linkError}
|
||||
</p>
|
||||
) : null}
|
||||
{openLink ? <OpenLink link={openLink} anchorRef={anchorRef} /> : null}
|
||||
{canConnect ? (
|
||||
<Button
|
||||
ref={connectRef}
|
||||
type="button"
|
||||
size="touch"
|
||||
className="self-start"
|
||||
disabled={isOffline}
|
||||
aria-describedby={describedBy}
|
||||
onClick={() => setIsStepUpOpen(true)}
|
||||
>
|
||||
Telegram verbinden
|
||||
</Button>
|
||||
) : null}
|
||||
{settings && botNameSet && isLinked ? (
|
||||
<>
|
||||
<DeliverySettings settings={settings} isOffline={isOffline} hintId={hintId} />
|
||||
<ConfirmDialog
|
||||
triggerLabel="Trennen"
|
||||
triggerSize="touch"
|
||||
triggerClassName="self-start"
|
||||
triggerDisabled={isOffline || unlink.isPending}
|
||||
triggerDescribedBy={describedBy}
|
||||
title="Telegram trennen?"
|
||||
description="Du bekommst dann keine Nachrichten mehr per Telegram. Verbinden geht jederzeit wieder."
|
||||
confirmLabel="Trennen"
|
||||
isPending={unlink.isPending}
|
||||
onConfirm={() => unlink.mutate()}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{botNameSet ? <OnlineOnlyHint id={hintId} /> : null}
|
||||
<StepUpDialog
|
||||
open={isStepUpOpen}
|
||||
onOpenChange={setIsStepUpOpen}
|
||||
title="Telegram verbinden?"
|
||||
description="Zur Sicherheit fragen wir noch einmal nach Ihrem Passwort. Danach erscheint ein Link, der 10 Minuten gilt."
|
||||
confirmLabel="Link erzeugen"
|
||||
needsMfa={Boolean(user?.mfaEnabled)}
|
||||
onConfirm={createLink}
|
||||
finalFocusRef={anchorRef}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
import { toast } from "sonner";
|
||||
import BackupPage from "./page";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import {
|
||||
exportBackup,
|
||||
getBackupSchedule,
|
||||
@@ -175,12 +176,58 @@ describe("BackupPage", () => {
|
||||
fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), {
|
||||
target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] },
|
||||
});
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Backup importieren" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
|
||||
}
|
||||
|
||||
it("the backup import still asks for the password through StepUpDialog", async () => {
|
||||
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
|
||||
vi.mocked(importBackup).mockResolvedValue({ imported: { apiary: 1 }, skipped: [], sealed: true });
|
||||
renderWithClient(<BackupPage />);
|
||||
expect(await screen.findByText("Aktiv")).toBeInTheDocument();
|
||||
// No loose password field on the page any more.
|
||||
expect(screen.queryByLabelText("Ihr Passwort")).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), {
|
||||
target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] },
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Backup importieren" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByRole("button", { name: "Einspielen" })).toBeDisabled();
|
||||
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(importBackup).toHaveBeenCalledWith(expect.any(File), {
|
||||
password: "geheim",
|
||||
mfaCode: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows why an import failed inside the dialog and keeps the file selected", async () => {
|
||||
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
|
||||
vi.mocked(importBackup)
|
||||
.mockRejectedValueOnce(new ApiError(401, "Das Passwort ist falsch."))
|
||||
.mockResolvedValue({ imported: { apiary: 1 }, skipped: [], sealed: true });
|
||||
renderWithClient(<BackupPage />);
|
||||
await importFile();
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await vi.waitFor(() =>
|
||||
expect(within(dialog).getByRole("alert")).toHaveTextContent("Das Passwort ist falsch."),
|
||||
);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
|
||||
// Still open, file still chosen: a second try needs only the password.
|
||||
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "richtig");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
|
||||
await vi.waitFor(() => expect(importBackup).toHaveBeenCalledTimes(2));
|
||||
expect(vi.mocked(importBackup).mock.calls[1]?.[0]).toBeInstanceOf(File);
|
||||
});
|
||||
|
||||
it("lists what an old, unencrypted backup did not restore and keeps that visible", async () => {
|
||||
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
|
||||
vi.mocked(importBackup).mockResolvedValue({
|
||||
|
||||
@@ -12,17 +12,18 @@ import {
|
||||
runBackupNow,
|
||||
updateBackupSettings,
|
||||
} from "@/lib/backup/api";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { BackupJobRunStatus, BackupScheduleStatus, BackupSettings } from "@/lib/backup/types";
|
||||
import { unsealedImportWarning } from "@/lib/backup/import-summary";
|
||||
import { importCsv } from "@/lib/import/api";
|
||||
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { StepUpDialog } from "@/components/auth/step-up-dialog";
|
||||
import type { StepUpProof } from "@/lib/telegram/types";
|
||||
import { ErrorState } from "@/components/query-state";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -79,10 +80,8 @@ function ImportLimitWarning({ backup, what }: { backup: OversizedBackup; what: s
|
||||
function BackupSection() {
|
||||
const { user } = useAuth();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [mfaCode, setMfaCode] = useState("");
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
const needsMfa = Boolean(user?.mfaEnabled);
|
||||
const proofComplete = password.length > 0 && (!needsMfa || /^\d{6}$/.test(mfaCode));
|
||||
const [oversizedExport, setOversizedExport] = useState<OversizedBackup | null>(null);
|
||||
// Kept on screen - the list of what was left out is too long for a toast alone.
|
||||
const [importNotice, setImportNotice] = useState<string | null>(null);
|
||||
@@ -109,11 +108,11 @@ function BackupSection() {
|
||||
});
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
mutationFn: (proof: StepUpProof) => {
|
||||
if (!file) {
|
||||
throw new Error("Keine Datei ausgewählt");
|
||||
}
|
||||
return importBackup(file, { password, mfaCode: needsMfa ? mfaCode : undefined });
|
||||
return importBackup(file, { password: proof.password, mfaCode: proof.mfaCode });
|
||||
},
|
||||
onSuccess: (summary) => {
|
||||
const total = Object.values(summary.imported).reduce((sum, count) => sum + count, 0);
|
||||
@@ -124,15 +123,7 @@ function BackupSection() {
|
||||
toast.warning(notice);
|
||||
}
|
||||
setFile(null);
|
||||
setPassword("");
|
||||
setMfaCode("");
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Backup konnte nicht eingespielt werden",
|
||||
),
|
||||
});
|
||||
|
||||
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
@@ -174,39 +165,28 @@ function BackupSection() {
|
||||
className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium"
|
||||
/>
|
||||
<p className="text-muted-foreground">
|
||||
Einspielen überschreibt Konten und Daten. Zur Bestätigung Ihr Passwort
|
||||
{needsMfa ? " und den aktuellen Code aus der Authenticator-App" : ""} eingeben.
|
||||
Einspielen überschreibt Konten und Daten. Zur Bestätigung fragen wir Ihr Passwort
|
||||
{needsMfa ? " und den aktuellen Code aus der Authenticator-App" : ""}.
|
||||
</p>
|
||||
<Label htmlFor="backup-password">Ihr Passwort</Label>
|
||||
<Input
|
||||
id="backup-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
{needsMfa && (
|
||||
<>
|
||||
<Label htmlFor="backup-mfa">Code aus der Authenticator-App</Label>
|
||||
<Input
|
||||
id="backup-mfa"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="\d{6}"
|
||||
maxLength={6}
|
||||
value={mfaCode}
|
||||
onChange={(event) => setMfaCode(event.target.value.replace(/\D/g, ""))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
triggerLabel="Backup importieren"
|
||||
triggerDisabled={!file || !proofComplete || importMutation.isPending}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!file || importMutation.isPending}
|
||||
onClick={() => setIsImportDialogOpen(true)}
|
||||
>
|
||||
Backup importieren
|
||||
</Button>
|
||||
<StepUpDialog
|
||||
open={isImportDialogOpen}
|
||||
onOpenChange={setIsImportDialogOpen}
|
||||
title="Backup einspielen?"
|
||||
description="Datensätze aus der Datei werden angelegt oder mit bestehenden gleicher ID überschrieben. Bereits vorhandene Daten, die in der Datei nicht enthalten sind, bleiben unverändert. Das kann bei einer großen Datei einen Moment dauern."
|
||||
confirmLabel="Einspielen"
|
||||
isPending={importMutation.isPending}
|
||||
onConfirm={() => importMutation.mutate()}
|
||||
pendingLabel="Wird eingespielt…"
|
||||
needsMfa={needsMfa}
|
||||
onConfirm={async (proof) => {
|
||||
await importMutation.mutateAsync(proof);
|
||||
}}
|
||||
/>
|
||||
{importNotice ? (
|
||||
<p
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
retryWebhookDelivery,
|
||||
} from "@/lib/system/api";
|
||||
import { JOB_NAMES, type SystemOverview } from "@/lib/system/types";
|
||||
import { fetchNotificationDeliveries } from "@/lib/telegram/api";
|
||||
|
||||
vi.mock("@/lib/system/api", () => ({
|
||||
fetchSystemOverview: vi.fn(),
|
||||
@@ -21,6 +22,11 @@ vi.mock("@/lib/system/api", () => ({
|
||||
fetchFailedWebhookDeliveries: vi.fn(),
|
||||
retryWebhookDelivery: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/telegram/api", () => ({
|
||||
fetchNotificationDeliveries: vi.fn(),
|
||||
retryNotificationDelivery: vi.fn(),
|
||||
retryFailedNotificationDeliveries: vi.fn(),
|
||||
}));
|
||||
let permissions: string[] = [];
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ user: { permissions } }),
|
||||
@@ -93,10 +99,33 @@ describe("SystemPage", () => {
|
||||
vi.mocked(fetchFailedWebhookDeliveries).mockResolvedValue([]);
|
||||
vi.mocked(retryDomainEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(retryWebhookDelivery).mockResolvedValue(undefined);
|
||||
vi.mocked(fetchNotificationDeliveries).mockResolvedValue({
|
||||
telegram: {
|
||||
botNameSet: true,
|
||||
botUsername: "apicore_bot",
|
||||
receiver: { leaseOwner: "pod-1", lastPollAgeSeconds: 5, lastError: null },
|
||||
sender: { pending: 0, oldestPendingAgeSeconds: null, failed24h: 0, lastErrors: [] },
|
||||
},
|
||||
deliveries: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("shows the Telegram card only with system:manage (the endpoint needs it)", async () => {
|
||||
renderPage();
|
||||
expect(await screen.findByText("Telegram")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show or load the Telegram card with system:read only", async () => {
|
||||
permissions = ["system:read"];
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("Offen: 3");
|
||||
expect(screen.queryByText("Telegram")).not.toBeInTheDocument();
|
||||
expect(fetchNotificationDeliveries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists every job with its last run and status label", async () => {
|
||||
renderPage();
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { TelegramCard } from "./telegram-card";
|
||||
|
||||
const SYSTEM_QUERY_KEY = ["system"] as const;
|
||||
const OVERVIEW_QUERY_KEY = [...SYSTEM_QUERY_KEY, "overview"] as const;
|
||||
@@ -302,6 +303,8 @@ export default function SystemPage() {
|
||||
<FailedDeliveries canManage={canManage} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* The deliveries endpoint needs system:manage; a reader would only get a 403. */}
|
||||
{canManage ? <TelegramCard /> : null}
|
||||
</>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { TelegramCard } from "./telegram-card";
|
||||
import {
|
||||
fetchNotificationDeliveries,
|
||||
retryFailedNotificationDeliveries,
|
||||
retryNotificationDelivery,
|
||||
} from "@/lib/telegram/api";
|
||||
import type { NotificationDeliveriesOverview } from "@/lib/telegram/types";
|
||||
|
||||
vi.mock("@/lib/telegram/api", () => ({
|
||||
fetchNotificationDeliveries: vi.fn(),
|
||||
retryNotificationDelivery: vi.fn(),
|
||||
retryFailedNotificationDeliveries: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
function overview(
|
||||
sender: Partial<NotificationDeliveriesOverview["telegram"]["sender"]> = {},
|
||||
receiver: Partial<NotificationDeliveriesOverview["telegram"]["receiver"]> = {},
|
||||
): NotificationDeliveriesOverview {
|
||||
return {
|
||||
telegram: {
|
||||
botNameSet: true,
|
||||
botUsername: "apicore_bot",
|
||||
receiver: { leaseOwner: "pod-1", lastPollAgeSeconds: 12, lastError: null, ...receiver },
|
||||
sender: {
|
||||
pending: 2,
|
||||
oldestPendingAgeSeconds: 90,
|
||||
failed24h: 1,
|
||||
lastErrors: [{ statusCode: 502, error: "Telegram nicht erreichbar", createdAt: "2026-10-14T08:00:00.000Z" }],
|
||||
...sender,
|
||||
},
|
||||
},
|
||||
deliveries: [
|
||||
{
|
||||
id: "d1",
|
||||
status: "FAILED",
|
||||
category: "TASK_DUE",
|
||||
templateCode: null,
|
||||
attempts: 8,
|
||||
availableAt: "2026-10-14T07:00:00.000Z",
|
||||
lastStatusCode: 502,
|
||||
lastError: "Telegram nicht erreichbar",
|
||||
createdAt: "2026-10-14T07:00:00.000Z",
|
||||
deliveredAt: null,
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
status: "DELIVERED",
|
||||
category: "DIGEST",
|
||||
templateCode: null,
|
||||
attempts: 1,
|
||||
availableAt: "2026-10-14T05:00:00.000Z",
|
||||
lastStatusCode: null,
|
||||
lastError: null,
|
||||
createdAt: "2026-10-14T05:00:00.000Z",
|
||||
deliveredAt: "2026-10-14T05:00:02.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function renderCard() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TelegramCard />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TelegramCard", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchNotificationDeliveries).mockReset().mockResolvedValue(overview());
|
||||
vi.mocked(retryNotificationDelivery).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(retryFailedNotificationDeliveries)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ retried: 1 });
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("shows the bot, the receiver and the sender without warning while the queue keeps up", async () => {
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText("Bot eingerichtet: @apicore_bot")).toBeInTheDocument();
|
||||
expect(screen.getByText("Empfang läuft")).toBeInTheDocument();
|
||||
expect(screen.getByText("Letzter Abruf: vor wenigen Sekunden")).toBeInTheDocument();
|
||||
expect(screen.getByText("Offen: 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fehlgeschlagen (24 Std.): 1")).toBeInTheDocument();
|
||||
expect(screen.getByText(/HTTP 502 · Telegram nicht erreichbar/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says that sending runs only in the worker and shows an unset bot", async () => {
|
||||
vi.mocked(fetchNotificationDeliveries).mockResolvedValue({
|
||||
...overview(),
|
||||
telegram: { ...overview().telegram, botNameSet: false, botUsername: null },
|
||||
});
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText("Bot nicht eingerichtet")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Der Versand läuft nur im Worker, wenn das Geheimnis vorhanden ist\./),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns when the oldest delivery waits longer than 10 minutes", async () => {
|
||||
vi.mocked(fetchNotificationDeliveries).mockResolvedValue(
|
||||
overview({ oldestPendingAgeSeconds: 601 }),
|
||||
);
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Eine Telegram-Nachricht wartet länger als 10 Minuten.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not warn at exactly 10 minutes", async () => {
|
||||
vi.mocked(fetchNotificationDeliveries).mockResolvedValue(
|
||||
overview({ oldestPendingAgeSeconds: 600 }),
|
||||
);
|
||||
renderCard();
|
||||
|
||||
await screen.findByText("Offen: 2");
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the last receiver error as given", async () => {
|
||||
vi.mocked(fetchNotificationDeliveries).mockResolvedValue(
|
||||
overview({}, { lastError: "Telegram-Nachricht übersprungen", leaseOwner: null }),
|
||||
);
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText("Kein Empfänger aktiv")).toBeInTheDocument();
|
||||
expect(screen.getByText("Telegram-Nachricht übersprungen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("retries one failed delivery and all failed ones", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
|
||||
const row = (await screen.findByText("Aufgaben fällig")).closest("li") as HTMLElement;
|
||||
await user.click(within(row).getByRole("button", { name: "Erneut versuchen" }));
|
||||
await waitFor(() => expect(retryNotificationDelivery).toHaveBeenCalledWith("d1"));
|
||||
expect(toast.success).toHaveBeenCalledWith("Wird erneut versucht");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?");
|
||||
expect(dialog).toHaveTextContent("Mindestens 1 sind fehlgeschlagen.");
|
||||
expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst.");
|
||||
expect(retryFailedNotificationDeliveries).not.toHaveBeenCalled();
|
||||
await user.click(within(dialog).getByRole("button", { name: "Erneut senden" }));
|
||||
await waitFor(() => expect(retryFailedNotificationDeliveries).toHaveBeenCalled());
|
||||
expect(toast.success).toHaveBeenCalledWith("1 Zustellung wird erneut versucht");
|
||||
});
|
||||
|
||||
it("names the count and the limit of 100 when more failed", async () => {
|
||||
vi.mocked(fetchNotificationDeliveries).mockResolvedValue(overview({ failed24h: 150 }));
|
||||
const user = userEvent.setup();
|
||||
renderCard();
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?");
|
||||
expect(dialog).toHaveTextContent("Mindestens 150 sind fehlgeschlagen.");
|
||||
expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst.");
|
||||
});
|
||||
|
||||
it("warns when the last poll is older than two minutes, even with a lease", async () => {
|
||||
vi.mocked(fetchNotificationDeliveries).mockResolvedValue(
|
||||
overview({}, { leaseOwner: "pod-1", lastPollAgeSeconds: 300 }),
|
||||
);
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText("Kein Abruf seit über 2 Minuten")).toHaveAttribute(
|
||||
"role",
|
||||
"status",
|
||||
);
|
||||
expect(screen.getByText("Letzter Abruf vor 5 Min.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Letzter Abruf vor 5 Min.")).not.toHaveAttribute("role");
|
||||
});
|
||||
|
||||
it("offers no retry for deliveries that did not fail", async () => {
|
||||
renderCard();
|
||||
|
||||
const row = (await screen.findByText("Morgenübersicht")).closest("li") as HTMLElement;
|
||||
expect(within(row).queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CircleCheck, CircleX } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchNotificationDeliveries,
|
||||
retryFailedNotificationDeliveries,
|
||||
retryNotificationDelivery,
|
||||
} from "@/lib/telegram/api";
|
||||
import {
|
||||
NOTIFICATION_DELIVERY_STATUS_LABELS,
|
||||
retriedLabel,
|
||||
} from "@/lib/telegram/labels";
|
||||
import type {
|
||||
NotificationDeliveryView,
|
||||
TelegramStatusView,
|
||||
} from "@/lib/telegram/types";
|
||||
import { NOTIFICATION_CATEGORY_LABELS } from "@/lib/notifications/labels";
|
||||
import { QUEUE_STALE_WARNING_SECONDS, attemptsLabel, formatAge } from "@/lib/system/labels";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
|
||||
/** The receiver polls every ~25 s; a longer gap means it is not working, lease or not. */
|
||||
const RECEIVER_STALE_SECONDS = 120;
|
||||
/** The backend re-queues at most this many failed deliveries per call, newest first. */
|
||||
const RETRY_ALL_LIMIT = 100;
|
||||
|
||||
const DELIVERIES_QUERY_KEY = ["system", "notification-deliveries"] as const;
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString("de-DE");
|
||||
}
|
||||
|
||||
function deliveryTitle(delivery: NotificationDeliveryView): string {
|
||||
if (delivery.category) return NOTIFICATION_CATEGORY_LABELS[delivery.category];
|
||||
return delivery.templateCode ? "Antwort des Bots" : "Telegram-Nachricht";
|
||||
}
|
||||
|
||||
const RETRY_ALL_TITLE = "Fehlgeschlagene Nachrichten erneut senden?";
|
||||
|
||||
// The API gives no total of all FAILED rows, so the count is only a lower bound.
|
||||
function retryAllDescription(count: number): string {
|
||||
const lines = ["Die fehlgeschlagenen Nachrichten kommen wieder in die Warteschlange."];
|
||||
if (count > 0) lines.push(`Mindestens ${count} sind fehlgeschlagen.`);
|
||||
lines.push(`Es werden höchstens ${RETRY_ALL_LIMIT} gesendet, neueste zuerst.`);
|
||||
return lines.join(" ");
|
||||
}
|
||||
|
||||
function BotSection({ telegram }: { telegram: TelegramStatusView }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<p className="font-medium">
|
||||
{telegram.botNameSet
|
||||
? `Bot eingerichtet: @${telegram.botUsername ?? ""}`
|
||||
: "Bot nicht eingerichtet"}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Die API kennt nur den Namen des Bots. Der Versand läuft nur im Worker, wenn das Geheimnis
|
||||
vorhanden ist.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceiverSection({ receiver }: { receiver: TelegramStatusView["receiver"] }) {
|
||||
const isRunning = receiver.leaseOwner !== null;
|
||||
const StatusIcon = isRunning ? CircleCheck : CircleX;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<h3 className="font-medium">Empfang</h3>
|
||||
<p className="flex items-center gap-2">
|
||||
<StatusIcon className="size-4 shrink-0" aria-hidden />
|
||||
{isRunning ? "Empfang läuft" : "Kein Empfänger aktiv"}
|
||||
</p>
|
||||
{receiver.lastPollAgeSeconds !== null &&
|
||||
receiver.lastPollAgeSeconds > RECEIVER_STALE_SECONDS ? (
|
||||
<div className="flex flex-col gap-0.5 font-medium text-destructive">
|
||||
<p role="status" className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-4 shrink-0" aria-hidden />
|
||||
Kein Abruf seit über 2 Minuten
|
||||
</p>
|
||||
<p className="font-normal">Letzter Abruf {formatAge(receiver.lastPollAgeSeconds)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{receiver.lastPollAgeSeconds === null
|
||||
? "Noch kein Abruf"
|
||||
: `Letzter Abruf: ${formatAge(receiver.lastPollAgeSeconds)}`}
|
||||
</p>
|
||||
)}
|
||||
{receiver.lastError ? <p className="break-words text-destructive">{receiver.lastError}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StaleDeliveryWarning() {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" aria-hidden />
|
||||
<p className="font-medium">Eine Telegram-Nachricht wartet länger als 10 Minuten.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SenderSection({ sender }: { sender: TelegramStatusView["sender"] }) {
|
||||
const isStale =
|
||||
sender.oldestPendingAgeSeconds !== null &&
|
||||
sender.oldestPendingAgeSeconds > QUEUE_STALE_WARNING_SECONDS;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<h3 className="font-medium">Versand</h3>
|
||||
{isStale ? <StaleDeliveryWarning /> : null}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p>Offen: {sender.pending}</p>
|
||||
{sender.oldestPendingAgeSeconds !== null ? (
|
||||
<p className="text-muted-foreground">
|
||||
Ältestes offenes: {formatAge(sender.oldestPendingAgeSeconds)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground">Fehlgeschlagen (24 Std.): {sender.failed24h}</p>
|
||||
</div>
|
||||
{sender.lastErrors.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{sender.lastErrors.map((entry) => (
|
||||
<li key={`${entry.createdAt}-${entry.error}`} className="break-words text-destructive">
|
||||
{entry.statusCode ? `HTTP ${entry.statusCode} · ` : ""}
|
||||
{entry.error} · {formatDateTime(entry.createdAt)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeliveryRowProps {
|
||||
delivery: NotificationDeliveryView;
|
||||
isRetrying: boolean;
|
||||
isOffline: boolean;
|
||||
describedBy: string | undefined;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function DeliveryRow({ delivery, isRetrying, isOffline, describedBy, onRetry }: DeliveryRowProps) {
|
||||
const isFailed = delivery.status === "FAILED";
|
||||
return (
|
||||
<li className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-3 sm:flex-row sm:items-start">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{deliveryTitle(delivery)}</span>
|
||||
<Badge variant={isFailed ? "destructive" : "secondary"}>
|
||||
{NOTIFICATION_DELIVERY_STATUS_LABELS[delivery.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDateTime(delivery.createdAt)} · {attemptsLabel(delivery.attempts)}
|
||||
{delivery.lastStatusCode ? ` · HTTP ${delivery.lastStatusCode}` : ""}
|
||||
</span>
|
||||
{delivery.lastError ? (
|
||||
<p className="break-words text-sm text-destructive">{delivery.lastError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{isFailed ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
disabled={isRetrying || isOffline}
|
||||
aria-describedby={describedBy}
|
||||
onClick={onRetry}
|
||||
>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram on the System page. The endpoint needs `system:manage`, so the
|
||||
* page mounts this card only for people who have it - every button here is
|
||||
* therefore allowed.
|
||||
*/
|
||||
export function TelegramCard() {
|
||||
const queryClient = useQueryClient();
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
const query = useQuery({
|
||||
queryKey: DELIVERIES_QUERY_KEY,
|
||||
queryFn: () => fetchNotificationDeliveries(),
|
||||
});
|
||||
|
||||
const refetch = () => queryClient.invalidateQueries({ queryKey: DELIVERIES_QUERY_KEY });
|
||||
|
||||
const retryOne = useMutation({
|
||||
mutationFn: (id: string) => retryNotificationDelivery(id),
|
||||
onSuccess: () => {
|
||||
toast.success("Wird erneut versucht");
|
||||
return refetch();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "Erneuter Versuch fehlgeschlagen")),
|
||||
});
|
||||
|
||||
const retryAll = useMutation({
|
||||
mutationFn: retryFailedNotificationDeliveries,
|
||||
onSuccess: ({ retried }) => {
|
||||
toast.success(retriedLabel(retried));
|
||||
return refetch();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "Erneuter Versuch fehlgeschlagen")),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Telegram</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<QueryState query={query} what="Telegram-Status">
|
||||
{({ telegram, deliveries }) => {
|
||||
const failedCount = Math.max(
|
||||
telegram.sender.failed24h,
|
||||
deliveries.filter((delivery) => delivery.status === "FAILED").length,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<BotSection telegram={telegram} />
|
||||
<ReceiverSection receiver={telegram.receiver} />
|
||||
<SenderSection sender={telegram.sender} />
|
||||
{failedCount > 0 ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Alle fehlgeschlagenen erneut versuchen"
|
||||
triggerSize="touch"
|
||||
triggerClassName="self-start"
|
||||
triggerDisabled={retryAll.isPending || isOffline}
|
||||
triggerDescribedBy={describedBy}
|
||||
title={RETRY_ALL_TITLE}
|
||||
description={retryAllDescription(failedCount)}
|
||||
confirmLabel="Erneut senden"
|
||||
destructive={false}
|
||||
isPending={retryAll.isPending}
|
||||
onConfirm={() => retryAll.mutate()}
|
||||
/>
|
||||
) : null}
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
{deliveries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Noch keine Zustellungen.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{deliveries.map((delivery) => (
|
||||
<DeliveryRow
|
||||
key={delivery.id}
|
||||
delivery={delivery}
|
||||
isRetrying={retryOne.isPending && retryOne.variables === delivery.id}
|
||||
isOffline={isOffline}
|
||||
describedBy={describedBy}
|
||||
onRetry={() => retryOne.mutate(delivery.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user