"feat: Added debug server and related components for improved development experience"

This commit is contained in:
2025-05-23 07:24:51 +02:00
parent d457a8d86b
commit 9f6219832c
189 changed files with 35730 additions and 133 deletions

View File

@@ -0,0 +1,132 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import { abortPrintJob } from "@/server/actions/printJobs";
import { TriangleAlertIcon } from "lucide-react";
import { useState } from "react";
const formSchema = z.object({
abortReason: z
.string()
.min(1, {
message: "Bitte gebe einen Grund für den Abbruch an.",
})
.max(255, {
message: "Der Grund darf maximal 255 Zeichen lang sein.",
}),
});
interface CancelFormProps {
jobId: string;
}
export function CancelForm(props: CancelFormProps) {
const { jobId } = props;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
abortReason: "",
},
});
const { toast } = useToast();
const [open, setOpen] = useState(false);
async function onSubmit(values: z.infer<typeof formSchema>) {
toast({
description: "Druckauftrag wird abgebrochen...",
});
try {
const result = await abortPrintJob(jobId, values.abortReason);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
setOpen(false);
toast({
description: "Druckauftrag wurde abgebrochen.",
});
} catch (error) {
if (error instanceof Error) {
toast({
description: error.message,
variant: "destructive",
});
} else {
toast({
description: "Ein unbekannter Fehler ist aufgetreten.",
variant: "destructive",
});
}
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant={"ghost"}
className="text-red-500 hover:text-red-600 flex-grow gap-2 items-center flex justify-start"
>
<TriangleAlertIcon className="w-4 h-4" />
<span>Druckauftrag abbrechen</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Druckauftrag abbrechen?</DialogTitle>
<DialogDescription>
Du bist dabei, den Druckauftrag abzubrechen. Bitte beachte, dass ein abgebrochener Druckauftrag nicht wieder
aufgenommen werden kann und der Drucker sich automatisch abschaltet.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="abortReason"
render={({ field }) => (
<FormItem>
<FormLabel>Grund für den Abbruch</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Bitte teile uns den Grund für den Abbruch des Druckauftrags mit. Wenn der Drucker eine Fehlermeldung
anzeigt, gib bitte nur diese Fehlermeldung an.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-between">
<DialogClose asChild>
<Button variant={"secondary"}>Nein</Button>
</DialogClose>
<Button variant={"destructive"} type="submit">
Ja, Druck abbrechen
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/use-toast";
import { updatePrintComments } from "@/server/actions/printJobs";
import { useDebouncedCallback } from "use-debounce";
interface EditCommentsProps {
defaultValue: string | null;
jobId: string;
disabled?: boolean;
}
export function EditComments(props: EditCommentsProps) {
const { defaultValue, jobId, disabled } = props;
const { toast } = useToast();
const debounced = useDebouncedCallback(async (value) => {
try {
const result = await updatePrintComments(jobId, value);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
toast({
description: "Anmerkungen wurden gespeichert.",
});
} catch (error) {
if (error instanceof Error) {
toast({
description: error.message,
variant: "destructive",
});
} else {
toast({
description: "Ein unbekannter Fehler ist aufgetreten.",
variant: "destructive",
});
}
}
}, 1000);
return (
<div className="flex flex-col gap-2">
<Label>Anmerkungen</Label>
<Textarea
placeholder="Anmerkungen"
disabled={disabled}
defaultValue={defaultValue ?? ""}
onChange={(e) => debounced(e.target.value)}
/>
</div>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import { extendPrintJob } from "@/server/actions/printJobs";
import { CircleFadingPlusIcon } from "lucide-react";
import { useState } from "react";
import { useSWRConfig } from "swr";
const formSchema = z.object({
minutes: z.coerce.number().int().max(59, {
message: "Die Minuten müssen zwischen 0 und 59 liegen.",
}),
hours: z.coerce.number().int().max(24, {
message: "Die Stunden müssen zwischen 0 und 24 liegen.",
}),
});
interface ExtendFormProps {
jobId: string;
}
export function ExtendForm(props: ExtendFormProps) {
const { jobId } = props;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
minutes: 0,
hours: 0,
},
});
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutate } = useSWRConfig();
async function onSubmit(values: z.infer<typeof formSchema>) {
toast({
description: "Druckauftrag wird verlängert...",
});
try {
const result = await extendPrintJob(jobId, values.minutes, values.hours);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
setOpen(false);
form.reset();
mutate(`/api/job/${jobId}/remaining-time`); // Refresh the countdown
toast({
description: "Druckauftrag wurde verlängert.",
});
} catch (error) {
if (error instanceof Error) {
toast({
description: error.message,
variant: "destructive",
});
} else {
toast({
description: "Ein unbekannter Fehler ist aufgetreten.",
variant: "destructive",
});
}
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant={"ghost"} className="flex-grow gap-2 items-center flex justify-start">
<CircleFadingPlusIcon className="w-4 h-4" />
<span>Druckauftrag verlängern</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Druckauftrag verlängern</DialogTitle>
<DialogDescription>
Braucht dein Druck mehr Zeit als erwartet? Füge weitere Stunden oder Minuten zur Druckzeit hinzu.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<p className="text-sm px-4 py-2 text-yellow-700 bg-yellow-500/20 rounded-md">
<span className="font-medium">Wichtig:</span> Bitte verlängere die Druckzeit nur, wenn es sich um
denselben Druck handelt. Wenn es ein anderer Druck ist, brich bitte den aktuellen Druckauftrag ab und
starte einen neuen.
</p>
<div className="flex flex-row gap-2">
<FormField
control={form.control}
name="hours"
render={({ field }) => (
<FormItem className="w-1/2">
<FormLabel>Stunden</FormLabel>
<FormControl>
<Input placeholder="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="minutes"
render={({ field }) => (
<FormItem className="w-1/2">
<FormLabel>Minuten</FormLabel>
<FormControl>
<Input placeholder="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-between">
<DialogClose asChild>
<Button variant={"secondary"}>Abbrechen</Button>
</DialogClose>
<Button variant={"default"} type="submit">
Verlängern
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { AlertDialogHeader } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/components/ui/use-toast";
import { earlyFinishPrintJob } from "@/server/actions/printJobs";
import { CircleCheckBigIcon } from "lucide-react";
interface FinishFormProps {
jobId: string;
}
export function FinishForm(props: FinishFormProps) {
const { jobId } = props;
const { toast } = useToast();
async function onClick() {
toast({
description: "Druckauftrag wird abgeschlossen...",
});
try {
const result = await earlyFinishPrintJob(jobId);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
toast({
description: "Druckauftrag wurde abgeschlossen.",
});
} catch (error) {
if (error instanceof Error) {
toast({
description: error.message,
variant: "destructive",
});
} else {
toast({
description: "Ein unbekannter Fehler ist aufgetreten.",
variant: "destructive",
});
}
}
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant={"ghost"} className="flex-grow gap-2 items-center flex justify-start">
<CircleCheckBigIcon className="w-4 h-4" />
<span>Druckauftrag abschließen</span>
</Button>
</DialogTrigger>
<DialogContent>
<AlertDialogHeader>
<DialogTitle>Druckauftrag abschließen?</DialogTitle>
<DialogDescription>
Du bist dabei, den Druckauftrag als abgeschlossen zu markieren. Dies führt dazu, dass der Drucker
automatisch herunterfährt.
</DialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-4">
<p className="text-sm text-red-500 font-medium bg-red-500/20 px-4 py-2 rounded-md">
Bitte bestätige nur, wenn der Druckauftrag tatsächlich erfolgreich abgeschlossen wurde.
</p>
<div className="flex flex-row justify-between">
<DialogClose asChild>
<Button variant={"secondary"}>Abbrechen</Button>
</DialogClose>
<DialogClose asChild onClick={onClick}>
<Button variant={"default"}>Bestätigen</Button>
</DialogClose>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,123 @@
import { CancelForm } from "@/app/job/[jobId]/cancel-form";
import { EditComments } from "@/app/job/[jobId]/edit-comments";
import { ExtendForm } from "@/app/job/[jobId]/extend-form";
import { FinishForm } from "@/app/job/[jobId]/finish-form";
import { Countdown } from "@/components/printer-card/countdown";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { validateRequest } from "@/server/auth";
import { UserRole } from "@/server/auth/permissions";
import { db } from "@/server/db";
import { printJobs } from "@/server/db/schema";
import { eq } from "drizzle-orm";
import { ArchiveIcon } from "lucide-react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Druckauftrag",
};
interface JobDetailsPageProps {
params: {
jobId: string;
};
}
export default async function JobDetailsPage(props: JobDetailsPageProps) {
const { jobId } = props.params;
const { user } = await validateRequest();
const jobDetails = await db.query.printJobs.findFirst({
where: eq(printJobs.id, jobId),
with: {
user: true,
printer: true,
},
});
if (!jobDetails) {
return <div>Druckauftrag wurde nicht gefunden.</div>;
}
const jobIsOnGoing = new Date(jobDetails.startAt).getTime() + jobDetails.durationInMinutes * 60 * 1000 > Date.now();
const jobIsAborted = jobDetails.aborted;
const userOwnsJob = jobDetails.userId === user?.id;
const userIsAdmin = user?.role === UserRole.ADMIN;
const userMayEditJob = userOwnsJob || userIsAdmin;
return (
<div className="flex flex-col gap-4">
<h1 className="text-3xl font-semibold">
Druckauftrag vom{" "}
{new Date(jobDetails.startAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "medium",
})}
</h1>
{!jobIsOnGoing || jobIsAborted ? (
<Alert className="bg-yellow-200 border-yellow-500 text-yellow-700 shadow-sm">
<ArchiveIcon className="h-4 w-4" />
<AlertTitle>Hinweis</AlertTitle>
<AlertDescription>
Dieser Druckauftrag wurde bereits abgeschlossen und kann nicht mehr bearbeitet werden.
</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col lg:flex-row gap-4">
<Card className="w-full">
<CardContent className="p-4 flex flex-col gap-4">
<div className="flex flex-row justify-between">
<div>
<h2 className="font-semibold">Ansprechpartner</h2>
<p className="text-sm">{jobDetails.user.displayName}</p>
<p className="text-sm">{jobDetails.user.email}</p>
</div>
<div className="text-right">
{jobIsAborted && (
<>
<h2 className="font-semibold text-red-500">Abbruchsgrund</h2>
<p className="text-sm text-red-500">{jobDetails.abortReason}</p>
</>
)}
{jobIsOnGoing && (
<>
<h2 className="font-semibold">Verbleibende Zeit</h2>
<p className="text-sm">
<Countdown jobId={jobDetails.id} />
</p>
</>
)}
</div>
</div>
<EditComments
defaultValue={jobDetails.comments}
jobId={jobDetails.id}
disabled={!userMayEditJob || jobIsAborted || !jobIsOnGoing}
/>
</CardContent>
</Card>
{userMayEditJob && jobIsOnGoing && (
<Card className="w-full lg:w-96 ml-auto">
<CardHeader>
<CardTitle>Aktionen</CardTitle>
</CardHeader>
<CardContent>
<div className="flex w-full flex-col -ml-4 -mt-2">
<FinishForm jobId={jobDetails.id} />
<ExtendForm jobId={jobDetails.id} />
<CancelForm jobId={jobDetails.id} />
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}
/**
* durationInMinutes: integer("durationInMinutes").notNull(),
comments: text("comments"),
aborted: integer("aborted", { mode: "boolean" }).notNull().default(false),
abortReason: text("abortReason"),
*/