"feat: Added debug server and related components for improved development experience"
This commit is contained in:
132
frontend/src/app/job/[jobId]/cancel-form.tsx
Normal file
132
frontend/src/app/job/[jobId]/cancel-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/app/job/[jobId]/edit-comments.tsx
Normal file
56
frontend/src/app/job/[jobId]/edit-comments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
frontend/src/app/job/[jobId]/extend-form.tsx
Normal file
151
frontend/src/app/job/[jobId]/extend-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
frontend/src/app/job/[jobId]/finish-form.tsx
Normal file
87
frontend/src/app/job/[jobId]/finish-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
frontend/src/app/job/[jobId]/page.tsx
Normal file
123
frontend/src/app/job/[jobId]/page.tsx
Normal 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"),
|
||||
*/
|
||||
Reference in New Issue
Block a user