chore: update reservation platform to newest codebase
This commit is contained in:
32
packages/reservation-platform/src/app/admin/about/page.tsx
Normal file
32
packages/reservation-platform/src/app/admin/about/page.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Über MYP",
|
||||
};
|
||||
|
||||
export default async function AdminPage() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Über MYP</CardTitle>
|
||||
<CardDescription>
|
||||
<i className="italic">MYP — Manage Your Printer</i>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="gap-y-2 flex flex-col">
|
||||
<p className="max-w-[80ch]">
|
||||
<strong>MYP</strong> ist eine Webanwendung zur Reservierung von 3D-Druckern. Sie wurde im Rahmen des
|
||||
Abschlussprojektes der Fachinformatiker Ausbildung für Daten- und Prozessanalyse für die Technische
|
||||
Berufsausbildung des Mercedes-Benz Werkes Berlin-Marienfelde entwickelt.
|
||||
</p>
|
||||
<p>
|
||||
© 2024{" "}
|
||||
<a href="https://linkedin.com/in/torben-haack" target="_blank" rel="noreferrer">
|
||||
Torben Haack
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/utils/styles";
|
||||
import { FileIcon, HeartIcon, LayoutDashboardIcon, PrinterIcon, UsersIcon, WrenchIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
interface AdminSite {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
const adminSites: AdminSite[] = [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/admin",
|
||||
icon: <LayoutDashboardIcon className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
name: "Benutzer",
|
||||
path: "/admin/users",
|
||||
icon: <UsersIcon className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
name: "Drucker",
|
||||
path: "/admin/printers",
|
||||
icon: <PrinterIcon className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
name: "Druckaufträge",
|
||||
path: "/admin/jobs",
|
||||
icon: <FileIcon className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
name: "Einstellungen",
|
||||
path: "/admin/settings",
|
||||
icon: <WrenchIcon className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
name: "Über MYP",
|
||||
path: "/admin/about",
|
||||
icon: <HeartIcon className="w-4 h-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ul className="w-full">
|
||||
{adminSites.map((site) => (
|
||||
<li key={site.path}>
|
||||
<Link
|
||||
href={site.path}
|
||||
className={cn("flex items-center gap-2 p-2 rounded hover:bg-muted", {
|
||||
"font-semibold": pathname === site.path,
|
||||
})}
|
||||
>
|
||||
{site.icon}
|
||||
<span>{site.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart } from "@tremor/react";
|
||||
|
||||
interface AbortReasonsBarChartProps {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: temporary fix
|
||||
data: any[];
|
||||
}
|
||||
|
||||
export function AbortReasonsBarChart(props: AbortReasonsBarChartProps) {
|
||||
const { data } = props;
|
||||
|
||||
const dataFormatter = (number: number) => Intl.NumberFormat("de-DE").format(number).toString();
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
className="mt-6"
|
||||
data={data}
|
||||
index="name"
|
||||
categories={["Anzahl"]}
|
||||
colors={["blue"]}
|
||||
valueFormatter={dataFormatter}
|
||||
yAxisWidth={48}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { DonutChart, Legend } from "@tremor/react";
|
||||
|
||||
const dataFormatter = (number: number) => Intl.NumberFormat("de-DE").format(number).toString();
|
||||
|
||||
interface LoadFactorChartProps {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: temp. fix
|
||||
data: any[];
|
||||
}
|
||||
export function LoadFactorChart(props: LoadFactorChartProps) {
|
||||
const { data } = props;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<DonutChart data={data} variant="donut" colors={["green", "yellow"]} valueFormatter={dataFormatter} />
|
||||
<Legend categories={["Frei", "Belegt"]} colors={["green", "yellow"]} className="max-w-xs" />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { DonutChart, Legend } from "@tremor/react";
|
||||
|
||||
const dataFormatter = (number: number) => Intl.NumberFormat("de-DE").format(number).toString();
|
||||
|
||||
interface PrintJobsDonutProps {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: temp. fix
|
||||
data: any[];
|
||||
}
|
||||
export function PrintJobsDonut(props: PrintJobsDonutProps) {
|
||||
const { data } = props;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<DonutChart data={data} variant="donut" colors={["green", "red", "yellow"]} valueFormatter={dataFormatter} />
|
||||
<Legend
|
||||
categories={["Abgeschlossen", "Abgebrochen", "Ausstehend"]}
|
||||
colors={["green", "red", "yellow"]}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
35
packages/reservation-platform/src/app/admin/jobs/page.tsx
Normal file
35
packages/reservation-platform/src/app/admin/jobs/page.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { columns } from "@/app/my/jobs/columns";
|
||||
import { JobsTable } from "@/app/my/jobs/data-table";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { db } from "@/server/db";
|
||||
import { printJobs } from "@/server/db/schema";
|
||||
import { desc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Alle Druckaufträge",
|
||||
};
|
||||
|
||||
export default async function AdminJobsPage() {
|
||||
const allJobs = await db.query.printJobs.findMany({
|
||||
orderBy: [desc(printJobs.startAt)],
|
||||
with: {
|
||||
user: true,
|
||||
printer: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>Druckaufträge</CardTitle>
|
||||
<CardDescription>Alle Druckaufträge</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JobsTable columns={columns} data={allJobs} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
32
packages/reservation-platform/src/app/admin/layout.tsx
Normal file
32
packages/reservation-platform/src/app/admin/layout.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { AdminSidebar } from "@/app/admin/admin-sidebar";
|
||||
import { validateRequest } from "@/server/auth";
|
||||
import { UserRole } from "@/server/auth/permissions";
|
||||
import { guard, is_not } from "@/utils/heimdall";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function AdminLayout(props: AdminLayoutProps) {
|
||||
const { children } = props;
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, is_not, UserRole.ADMIN)) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-4">
|
||||
<div className="mx-auto grid w-full gap-2">
|
||||
<h1 className="text-3xl font-semibold">Admin</h1>
|
||||
</div>
|
||||
<div className="mx-auto grid w-full items-start gap-4 md:gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]">
|
||||
<nav className="grid gap-4 text-sm">
|
||||
<AdminSidebar />
|
||||
</nav>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
128
packages/reservation-platform/src/app/admin/page.tsx
Normal file
128
packages/reservation-platform/src/app/admin/page.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { AbortReasonsBarChart } from "@/app/admin/charts/abort-reasons";
|
||||
import { LoadFactorChart } from "@/app/admin/charts/load-factor";
|
||||
import { PrintJobsDonut } from "@/app/admin/charts/printjobs-donut";
|
||||
import { DataCard } from "@/components/data-card";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { db } from "@/server/db";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Admin Dashboard",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const allPrintJobs = await db.query.printJobs.findMany({
|
||||
with: {
|
||||
printer: true,
|
||||
},
|
||||
});
|
||||
|
||||
const totalAmountOfPrintJobs = allPrintJobs.length;
|
||||
|
||||
const now = new Date();
|
||||
const completedPrintJobs = allPrintJobs.filter((job) => {
|
||||
if (job.aborted) return false;
|
||||
const endAt = new Date(job.startAt).getTime() + job.durationInMinutes * 1000 * 60;
|
||||
return endAt < now.getTime();
|
||||
}).length;
|
||||
const abortedPrintJobs = allPrintJobs.filter((job) => job.aborted).length;
|
||||
const pendingPrintJobs = totalAmountOfPrintJobs - completedPrintJobs - abortedPrintJobs;
|
||||
|
||||
const abortedPrintJobsReasons = Object.entries(
|
||||
allPrintJobs.reduce((accumulator: Record<string, number>, job) => {
|
||||
if (job.aborted && job.abortReason) {
|
||||
if (!accumulator[job.abortReason]) {
|
||||
accumulator[job.abortReason] = 1;
|
||||
} else {
|
||||
accumulator[job.abortReason]++;
|
||||
}
|
||||
}
|
||||
return accumulator;
|
||||
}, {}),
|
||||
).map(([name, count]) => ({ name, Anzahl: count }));
|
||||
|
||||
const mostAbortedPrinter = allPrintJobs.reduce((prev, current) => (prev.aborted > current.aborted ? prev : current));
|
||||
|
||||
const mostUsedPrinter = allPrintJobs.reduce((prev, current) =>
|
||||
prev.durationInMinutes > current.durationInMinutes ? prev : current,
|
||||
);
|
||||
|
||||
const allPrinters = await db.query.printers.findMany();
|
||||
|
||||
const freePrinters = allPrinters.filter((printer) => {
|
||||
const jobs = allPrintJobs.filter((job) => job.printerId === printer.id);
|
||||
const now = new Date();
|
||||
const inUse = jobs.some((job) => {
|
||||
const endAt = new Date(job.startAt).getTime() + job.durationInMinutes * 1000 * 60;
|
||||
return endAt > now.getTime();
|
||||
});
|
||||
return !inUse;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs defaultValue={"@general"} className="flex flex-col gap-4 items-start">
|
||||
<TabsList className="bg-neutral-100 w-full py-6">
|
||||
<TabsTrigger value="@general">Allgemein</TabsTrigger>
|
||||
{allPrinters.map((printer) => (
|
||||
<TabsTrigger key={printer.id} value={printer.id}>
|
||||
{printer.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent value="@general" className="w-full">
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-4">
|
||||
<DataCard title="Drucker mit meisten Reservierungen" value={mostUsedPrinter.printer.name} icon="Printer" />
|
||||
<DataCard title="Drucker mit meisten Abbrüchen" value={mostAbortedPrinter.printer.name} icon="Printer" />
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Druckaufträge</CardTitle>
|
||||
<CardDescription>nach Status</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PrintJobsDonut
|
||||
data={[
|
||||
{ name: "Abgeschlossen", value: completedPrintJobs },
|
||||
{ name: "Abgebrochen", value: abortedPrintJobs },
|
||||
{ name: "Ausstehend", value: pendingPrintJobs },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full ">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Auslastung: <span>{((1 - freePrinters.length / allPrinters.length) * 100).toFixed(2)}%</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LoadFactorChart
|
||||
data={[
|
||||
{ name: "Frei", value: freePrinters.length },
|
||||
{ name: "Belegt", value: allPrinters.length - freePrinters.length },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Abgebrochene Druckaufträge nach Abbruchgrund</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AbortReasonsBarChart data={abortedPrintJobsReasons} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{allPrinters.map((printer) => (
|
||||
<TabsContent key={printer.id} value={printer.id}>
|
||||
{printer.description}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
import type { printers } from "@/server/db/schema";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { ArrowUpDown, MoreHorizontal, PencilIcon } from "lucide-react";
|
||||
|
||||
import { EditPrinterDialogContent, EditPrinterDialogTrigger } from "@/app/admin/printers/dialogs/edit-printer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { type PrinterStatus, translatePrinterStatus } from "@/utils/printers";
|
||||
import { useState } from "react";
|
||||
|
||||
// This type is used to define the shape of our data.
|
||||
// You can use a Zod schema here if you want.
|
||||
|
||||
export const columns: ColumnDef<InferSelectModel<typeof printers>>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||
ID
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Beschreibung",
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status");
|
||||
const translated = translatePrinterStatus(status as PrinterStatus);
|
||||
|
||||
return translated;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const printer = row.original;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Menu öffnen</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Aktionen</DropdownMenuLabel>
|
||||
<DropdownMenuItem asChild>ABC</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<EditPrinterDialogTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
<span>Bearbeiten</span>
|
||||
</div>
|
||||
</EditPrinterDialogTrigger>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<EditPrinterDialogContent setOpen={setOpen} printer={printer} />
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { SlidersHorizontalIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
placeholder="Name filtern..."
|
||||
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => table.getColumn("name")?.setFilterValue(event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="ml-auto flex items-center gap-2">
|
||||
<SlidersHorizontalIcon className="h-4 w-4" />
|
||||
<span>Spalten</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
Keine Ergebnisse gefunden.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||
Nächste Seite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { PrinterForm } from "@/app/admin/printers/form";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { useState } from "react";
|
||||
|
||||
interface CreatePrinterDialogProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CreatePrinterDialog(props: CreatePrinterDialogProps) {
|
||||
const { children } = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Drucker erstellen</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PrinterForm setOpen={setOpen} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { deletePrinter } from "@/server/actions/printers";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
|
||||
interface DeletePrinterDialogProps {
|
||||
printerId: string;
|
||||
setOpen: (state: boolean) => void;
|
||||
}
|
||||
export function DeletePrinterDialog(props: DeletePrinterDialogProps) {
|
||||
const { printerId, setOpen } = props;
|
||||
const { toast } = useToast();
|
||||
|
||||
async function onSubmit() {
|
||||
toast({
|
||||
description: "Drucker wird gelöscht...",
|
||||
});
|
||||
try {
|
||||
await deletePrinter(printerId);
|
||||
toast({
|
||||
description: "Drucker wurde gelöscht.",
|
||||
});
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: "Ein unbekannter Fehler ist aufgetreten.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="gap-2 flex items-center">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
<span>Drucker löschen</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Bist Du dir sicher?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Diese Aktion kann nicht rückgängig gemacht werden. Der Drucker und die damit verbundenen Daten werden
|
||||
unwiderruflich gelöscht.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction className="bg-red-500" onClick={onSubmit}>
|
||||
Ja, löschen
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import { PrinterForm } from "@/app/admin/printers/form";
|
||||
import { DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import type { InferResultType } from "@/utils/drizzle";
|
||||
|
||||
interface EditPrinterDialogTriggerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EditPrinterDialogTrigger(props: EditPrinterDialogTriggerProps) {
|
||||
const { children } = props;
|
||||
|
||||
return <DialogTrigger asChild>{children}</DialogTrigger>;
|
||||
}
|
||||
|
||||
interface EditPrinterDialogContentProps {
|
||||
printer: InferResultType<"printers">;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
export function EditPrinterDialogContent(props: EditPrinterDialogContentProps) {
|
||||
const { printer, setOpen } = props;
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Drucker bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PrinterForm setOpen={setOpen} printer={printer} />
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
192
packages/reservation-platform/src/app/admin/printers/form.tsx
Normal file
192
packages/reservation-platform/src/app/admin/printers/form.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
import { DeletePrinterDialog } from "@/app/admin/printers/dialogs/delete-printer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogClose } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { createPrinter, updatePrinter } from "@/server/actions/printers";
|
||||
import type { InferResultType } from "@/utils/drizzle";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SaveIcon, XCircleIcon } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Der Name muss mindestens 2 Zeichen lang sein.",
|
||||
})
|
||||
.max(50),
|
||||
description: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Die Beschreibung muss mindestens 2 Zeichen lang sein.",
|
||||
})
|
||||
.max(50),
|
||||
status: z.coerce.number().int().min(0).max(1),
|
||||
});
|
||||
|
||||
interface PrinterFormProps {
|
||||
printer?: InferResultType<"printers">;
|
||||
setOpen: (state: boolean) => void;
|
||||
}
|
||||
|
||||
export function PrinterForm(props: PrinterFormProps) {
|
||||
const { printer, setOpen } = props;
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: printer?.name ?? "",
|
||||
description: printer?.description ?? "",
|
||||
status: printer?.status ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Define a submit handler.
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
// TODO: create or update
|
||||
if (printer) {
|
||||
toast({
|
||||
description: "Drucker wird aktualisiert...",
|
||||
});
|
||||
|
||||
// Update
|
||||
try {
|
||||
await updatePrinter(printer.id, {
|
||||
description: values.description,
|
||||
name: values.name,
|
||||
status: values.status,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
description: "Drucker wurde aktualisiert.",
|
||||
variant: "default",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: "Ein unbekannter Fehler ist aufgetreten.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
description: "Drucker wird erstellt...",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
// Create
|
||||
try {
|
||||
await createPrinter({
|
||||
description: values.description,
|
||||
name: values.name,
|
||||
status: values.status,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
description: "Drucker wurde erstellt.",
|
||||
variant: "default",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: "Ein unbekannter Fehler ist aufgetreten.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Anycubic Kobra 2 Pro" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Bitte gib einen eindeutigen Namen für den Drucker ein.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Beschreibung</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="80x80x80 Druckfläche, langsam" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Füge eine kurze Beschreibung des Druckers hinzu.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value.toString()}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a verified email to display" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={"0"}>Verfügbar</SelectItem>
|
||||
<SelectItem value={"1"}>Außer Betrieb</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Wähle den aktuellen Status des Druckers.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
{printer && <DeletePrinterDialog setOpen={setOpen} printerId={printer?.id} />}
|
||||
{!printer && (
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary" className="gap-2 flex items-center">
|
||||
<XCircleIcon className="w-4 h-4" />
|
||||
<span>Abbrechen</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
)}
|
||||
<Button type="submit" className="gap-2 flex items-center">
|
||||
<SaveIcon className="w-4 h-4" />
|
||||
<span>Speichern</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import { columns } from "@/app/admin/printers/columns";
|
||||
import { DataTable } from "@/app/admin/printers/data-table";
|
||||
import { CreatePrinterDialog } from "@/app/admin/printers/dialogs/create-printer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { db } from "@/server/db";
|
||||
import { PlusCircleIcon } from "lucide-react";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const data = await db.query.printers.findMany();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>Druckerverwaltung</CardTitle>
|
||||
<CardDescription>Suche, Bearbeite, Lösche und Erstelle Drucker</CardDescription>
|
||||
</div>
|
||||
<CreatePrinterDialog>
|
||||
<Button variant={"default"} className="flex gap-2 items-center">
|
||||
<PlusCircleIcon className="w-4 h-4" />
|
||||
<span>Drucker erstellen</span>
|
||||
</Button>
|
||||
</CreatePrinterDialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable columns={columns} data={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
export async function GET() {
|
||||
return new Response(fs.readFileSync("./db/sqlite.db"));
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Systemeinstellungen",
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Einstellungen</CardTitle>
|
||||
<CardDescription>Systemeinstellungen</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-8 items-center">
|
||||
<p>Datenbank herunterladen</p>
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/settings/download" target="_blank">
|
||||
Herunterladen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
137
packages/reservation-platform/src/app/admin/users/columns.tsx
Normal file
137
packages/reservation-platform/src/app/admin/users/columns.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { type UserRole, translateUserRole } from "@/server/auth/permissions";
|
||||
import type { users } from "@/server/db/schema";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
MailIcon,
|
||||
MessageCircleIcon,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
EditUserDialogContent,
|
||||
EditUserDialogRoot,
|
||||
EditUserDialogTrigger,
|
||||
} from "@/app/admin/users/dialog";
|
||||
|
||||
// This type is used to define the shape of our data.
|
||||
// You can use a Zod schema here if you want.
|
||||
export type User = {
|
||||
id: string;
|
||||
github_id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export const columns: ColumnDef<InferSelectModel<typeof users>>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
ID
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "github_id",
|
||||
header: "GitHub ID",
|
||||
},
|
||||
{
|
||||
accessorKey: "username",
|
||||
header: "Username",
|
||||
},
|
||||
{
|
||||
accessorKey: "displayName",
|
||||
header: "Name",
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "E-Mail",
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Rolle",
|
||||
cell: ({ row }) => {
|
||||
const role = row.getValue("role");
|
||||
const translated = translateUserRole(role as UserRole);
|
||||
|
||||
return translated;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
|
||||
return (
|
||||
<EditUserDialogRoot>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Menu öffnen</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Aktionen</DropdownMenuLabel>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
href={generateTeamsChatURL(user.email)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<MessageCircleIcon className="w-4 h-4" />
|
||||
<span>Teams-Chat öffnen</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
href={generateEMailURL(user.email)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<MailIcon className="w-4 h-4" />
|
||||
<span>E-Mail schicken</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<EditUserDialogTrigger />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<EditUserDialogContent user={user as User} />
|
||||
</EditUserDialogRoot>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function generateTeamsChatURL(email: string) {
|
||||
return `https://teams.microsoft.com/l/chat/0/0?users=${email}`;
|
||||
}
|
||||
|
||||
function generateEMailURL(email: string) {
|
||||
return `mailto:${email}`;
|
||||
}
|
135
packages/reservation-platform/src/app/admin/users/data-table.tsx
Normal file
135
packages/reservation-platform/src/app/admin/users/data-table.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { SlidersHorizontalIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
placeholder="E-Mails filtern..."
|
||||
value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => table.getColumn("email")?.setFilterValue(event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="ml-auto flex items-center gap-2">
|
||||
<SlidersHorizontalIcon className="h-4 w-4" />
|
||||
<span>Spalten</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
Keine Ergebnisse gefunden.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||
Nächste Seite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
56
packages/reservation-platform/src/app/admin/users/dialog.tsx
Normal file
56
packages/reservation-platform/src/app/admin/users/dialog.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import type { User } from "@/app/admin/users/columns";
|
||||
import { ProfileForm } from "@/app/admin/users/form";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { PencilIcon } from "lucide-react";
|
||||
|
||||
interface EditUserDialogRootProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EditUserDialogRoot(props: EditUserDialogRootProps) {
|
||||
const { children } = props;
|
||||
|
||||
return <Dialog>{children}</Dialog>;
|
||||
}
|
||||
|
||||
export function EditUserDialogTrigger() {
|
||||
return (
|
||||
<DialogTrigger className="flex gap-2 items-center">
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
<span>Benutzer bearbeiten</span>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditUserDialogContentProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function EditUserDialogContent(props: EditUserDialogContentProps) {
|
||||
const { user } = props;
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Benutzer bearbeiten</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>Hinweis:</strong> In den seltensten Fällen sollten die Daten
|
||||
eines Benutzers geändert werden. Dies kann zu unerwarteten Problemen
|
||||
führen.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ProfileForm user={user} />
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
212
packages/reservation-platform/src/app/admin/users/form.tsx
Normal file
212
packages/reservation-platform/src/app/admin/users/form.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import type { User } from "@/app/admin/users/columns";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogClose } from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { deleteUser, updateUser } from "@/server/actions/users";
|
||||
import type { UserRole } from "@/server/auth/permissions";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SaveIcon, TrashIcon } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
export const formSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Der Benutzername muss mindestens 2 Zeichen lang sein.",
|
||||
})
|
||||
.max(50),
|
||||
displayName: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Der Anzeigename muss mindestens 2 Zeichen lang sein.",
|
||||
})
|
||||
.max(50),
|
||||
email: z.string().email(),
|
||||
role: z.enum(["admin", "user", "guest"]),
|
||||
});
|
||||
|
||||
interface ProfileFormProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function ProfileForm(props: ProfileFormProps) {
|
||||
const { user } = props;
|
||||
const { toast } = useToast();
|
||||
|
||||
// 1. Define your form.
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
role: user.role as UserRole,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Define a submit handler.
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
toast({ description: "Benutzerprofil wird aktualisiert..." });
|
||||
|
||||
await updateUser(user.id, values);
|
||||
|
||||
toast({ description: "Benutzerprofil wurde aktualisiert." });
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Benutzername</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="MAXMUS" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Nur in Ausnahmefällen sollte der Benutzername geändert werden.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="displayName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Anzeigename</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Max Mustermann" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Der Anzeigename darf frei verändert werden.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>E-Mail Adresse</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="max.mustermann@mercedes-benz.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Nur in Ausnahmefällen sollte die E-Mail Adresse geändert werden.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Benutzerrolle</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a verified email to display" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Administrator</SelectItem>
|
||||
<SelectItem value="user">Benutzer</SelectItem>
|
||||
<SelectItem value="guest">Gast</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Die Benutzerrolle bestimmt die Berechtigungen des Benutzers.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="gap-2 flex items-center"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
<span>Benutzer löschen</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Bist du dir sicher?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Diese Aktion kann nicht rückgängig gemacht werden. Das
|
||||
Benutzerprofil und die damit verbundenen Daten werden
|
||||
unwiderruflich gelöscht.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-500"
|
||||
onClick={() => {
|
||||
toast({ description: "Benutzerprofil wird gelöscht..." });
|
||||
deleteUser(user.id);
|
||||
toast({ description: "Benutzerprofil wurde gelöscht." });
|
||||
}}
|
||||
>
|
||||
Ja, löschen
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<DialogClose asChild>
|
||||
<Button type="submit" className="gap-2 flex items-center">
|
||||
<SaveIcon className="w-4 h-4" />
|
||||
<span>Speichern</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
26
packages/reservation-platform/src/app/admin/users/page.tsx
Normal file
26
packages/reservation-platform/src/app/admin/users/page.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { columns } from "@/app/admin/users/columns";
|
||||
import { DataTable } from "@/app/admin/users/data-table";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { db } from "@/server/db";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Alle Benutzer",
|
||||
};
|
||||
|
||||
export default async function AdminPage() {
|
||||
const data = await db.query.users.findMany();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Benutzerverwaltung</CardTitle>
|
||||
<CardDescription>Suche, Bearbeite und Lösche Benutzer</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable columns={columns} data={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import NextAuth from "next-auth";
|
||||
|
||||
import { authOptions } from "@/server/auth";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const handler = NextAuth(authOptions);
|
||||
export { handler as GET, handler as POST };
|
@ -0,0 +1,34 @@
|
||||
import { db } from "@/server/db";
|
||||
import { printJobs } from "@/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
interface RemainingTimeRouteProps {
|
||||
params: {
|
||||
jobId: string;
|
||||
};
|
||||
}
|
||||
export async function GET(request: Request, { params }: RemainingTimeRouteProps) {
|
||||
// Get the job details
|
||||
const jobDetails = await db.query.printJobs.findFirst({
|
||||
where: eq(printJobs.id, params.jobId),
|
||||
});
|
||||
|
||||
// Check if the job exists
|
||||
if (!jobDetails) {
|
||||
return Response.json({
|
||||
id: params.jobId,
|
||||
error: "Job not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate the remaining time
|
||||
const startAt = new Date(jobDetails.startAt).getTime();
|
||||
const endAt = startAt + jobDetails.durationInMinutes * 60 * 1000;
|
||||
const remainingTime = Math.max(0, endAt - Date.now());
|
||||
|
||||
// Return the remaining time
|
||||
return Response.json({
|
||||
id: params.jobId,
|
||||
remainingTime,
|
||||
});
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { getPrinters } from "@/server/actions/printers";
|
||||
|
||||
export async function GET() {
|
||||
const printers = await getPrinters();
|
||||
|
||||
return Response.json(printers);
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
import { lucia } from "@/server/auth";
|
||||
import { type GitHubUserResult, github } from "@/server/auth/oauth";
|
||||
import { db } from "@/server/db";
|
||||
import { users } from "@/server/db/schema";
|
||||
import { OAuth2RequestError } from "arctic";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { generateIdFromEntropySize } from "lucia";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const storedState = cookies().get("github_oauth_state")?.value ?? null;
|
||||
if (!code || !state || !storedState || state !== storedState) {
|
||||
return new Response(null, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await github.validateAuthorizationCode(code);
|
||||
const githubUserResponse = await fetch("https://git.i.mercedes-benz.com/api/v3/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
const githubUser: GitHubUserResult = await githubUserResponse.json();
|
||||
|
||||
// Replace this with your own DB client.
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.github_id, githubUser.id),
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
const session = await lucia.createSession(existingUser.id, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: "/",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const userId = generateIdFromEntropySize(10); // 16 characters long
|
||||
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
github_id: githubUser.id,
|
||||
username: githubUser.login,
|
||||
displayName: githubUser.name,
|
||||
email: githubUser.email,
|
||||
});
|
||||
|
||||
const session = await lucia.createSession(userId, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: "/",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
// the specific error message depends on the provider
|
||||
if (e instanceof OAuth2RequestError) {
|
||||
// invalid code
|
||||
return new Response(null, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
19
packages/reservation-platform/src/app/auth/login/route.ts
Normal file
19
packages/reservation-platform/src/app/auth/login/route.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { github } from "@/server/auth/oauth";
|
||||
import { generateState } from "arctic";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const state = generateState();
|
||||
const url = await github.createAuthorizationURL(state);
|
||||
const ONE_HOUR = 60 * 60;
|
||||
|
||||
cookies().set("github_oauth_state", state, {
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: true,
|
||||
maxAge: ONE_HOUR,
|
||||
sameSite: "lax",
|
||||
});
|
||||
|
||||
return Response.redirect(url);
|
||||
}
|
BIN
packages/reservation-platform/src/app/favicon.ico
Normal file
BIN
packages/reservation-platform/src/app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
77
packages/reservation-platform/src/app/globals.css
Normal file
77
packages/reservation-platform/src/app/globals.css
Normal file
@ -0,0 +1,77 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
|
||||
--muted: 0 0% 90.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
@ -0,0 +1,126 @@
|
||||
"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 {
|
||||
await abortPrintJob(jobId, values.abortReason);
|
||||
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>
|
||||
);
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
"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 {
|
||||
await updatePrintComments(jobId, value);
|
||||
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>
|
||||
);
|
||||
}
|
@ -0,0 +1,144 @@
|
||||
"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 {
|
||||
await extendPrintJob(jobId, values.minutes, values.hours);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
"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 {
|
||||
await earlyFinishPrintJob(jobId);
|
||||
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
packages/reservation-platform/src/app/job/[jobId]/page.tsx
Normal file
123
packages/reservation-platform/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>Job not found</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"),
|
||||
*/
|
@ -1,42 +1,41 @@
|
||||
import { Metadata } from "next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Header } from "@/components/header";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { cn } from "@/utils/styles";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import "@/styles/globals.css";
|
||||
import "@/app/globals.css";
|
||||
import { Inter as FontSans } from "next/font/google";
|
||||
import Header from "@/components/Header";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | MYP",
|
||||
default: "MYP",
|
||||
},
|
||||
description:
|
||||
"MYP (Manage your Printer) ist eine 3D-Drucker Reservierungsplattform entwickelt für die TBA Berlin (W040).",
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
};
|
||||
|
||||
const fontSans = FontSans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "MYP",
|
||||
template: "%s | MYP",
|
||||
},
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head />
|
||||
<body
|
||||
className={cn(
|
||||
"min-h-screen bg-background font-sans antialiased",
|
||||
fontSans.variable,
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
<main className="flex justify-center px-96 py-8">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
export default function RootLayout(props: RootLayoutProps) {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<html lang="de" suppressHydrationWarning>
|
||||
<head />
|
||||
<body className={cn("min-h-dvh bg-muted font-sans antialiased", fontSans.variable)}>
|
||||
<Header />
|
||||
<main className="flex-grow max-w-screen-2xl w-full mx-auto flex flex-col p-8 gap-4 text-foreground">
|
||||
{children}
|
||||
</main>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
141
packages/reservation-platform/src/app/my/jobs/columns.tsx
Normal file
141
packages/reservation-platform/src/app/my/jobs/columns.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import type { InferResultType } from "@/utils/drizzle";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { BadgeCheckIcon, EyeIcon, HourglassIcon, MoreHorizontal, OctagonXIcon, ShareIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import type { printers } from "@/server/db/schema";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
|
||||
export const columns: ColumnDef<
|
||||
InferResultType<
|
||||
"printJobs",
|
||||
{
|
||||
printer: true;
|
||||
}
|
||||
>
|
||||
>[] = [
|
||||
{
|
||||
accessorKey: "printer",
|
||||
header: "Drucker",
|
||||
cell: ({ row }) => {
|
||||
const printer: InferSelectModel<typeof printers> = row.getValue("printer");
|
||||
return printer.name;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "startAt",
|
||||
header: "Startzeitpunkt",
|
||||
cell: ({ row }) => {
|
||||
const startAt = new Date(row.original.startAt);
|
||||
|
||||
return `${startAt.toLocaleDateString("de-DE", {
|
||||
dateStyle: "medium",
|
||||
})} ${startAt.toLocaleTimeString("de-DE")}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "durationInMinutes",
|
||||
header: "Dauer (Minuten)",
|
||||
},
|
||||
{
|
||||
accessorKey: "comments",
|
||||
header: "Anmerkungen",
|
||||
cell: ({ row }) => {
|
||||
const comments = row.original.comments;
|
||||
|
||||
if (comments) {
|
||||
return <span className="text-sm">{comments.slice(0, 50)}</span>;
|
||||
}
|
||||
|
||||
return <span className="text-muted-foreground text-sm">Keine Anmerkungen</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const aborted = row.original.aborted;
|
||||
|
||||
if (aborted) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<OctagonXIcon className="w-4 h-4 text-red-500" /> <span className="text-red-600">Abgebrochen</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const startAt = new Date(row.original.startAt).getTime();
|
||||
const endAt = startAt + row.original.durationInMinutes * 60 * 1000;
|
||||
|
||||
if (Date.now() < endAt) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<HourglassIcon className="w-4 h-4 text-yellow-500" />
|
||||
<span className="text-yellow-600">Läuft...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<BadgeCheckIcon className="w-4 h-4 text-green-500" />
|
||||
<span className="text-green-600">Abgeschlossen</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const job = row.original;
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Menu öffnen</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Aktionen</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
const baseUrl = new URL(window.location.href);
|
||||
baseUrl.pathname = `/job/${job.id}`;
|
||||
navigator.clipboard.writeText(baseUrl.toString());
|
||||
toast({
|
||||
description: "URL zum Druckauftrag in die Zwischenablage kopiert.",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ShareIcon className="w-4 h-4" />
|
||||
<span>Teilen</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Link href={`/job/${job.id}`} className="flex items-center gap-2">
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
<span>Details anzeigen</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
73
packages/reservation-platform/src/app/my/jobs/data-table.tsx
Normal file
73
packages/reservation-platform/src/app/my/jobs/data-table.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function JobsTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
Keine Ergebnisse gefunden
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4 select-none">
|
||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||
Vorherige Seite
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||
Nächste Seite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
47
packages/reservation-platform/src/app/my/profile/page.tsx
Normal file
47
packages/reservation-platform/src/app/my/profile/page.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { validateRequest } from "@/server/auth";
|
||||
import { UserRole, translateUserRole } from "@/server/auth/permissions";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dein Profil",
|
||||
};
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (!user) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const badgeVariant = {
|
||||
[UserRole.ADMIN]: "destructive" as const,
|
||||
[UserRole.USER]: "default" as const,
|
||||
[UserRole.GUEST]: "secondary" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>{user?.displayName}</CardTitle>
|
||||
<CardDescription>
|
||||
{user?.username} — {user?.email}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={badgeVariant[user?.role]}>{translateUserRole(user?.role)}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>
|
||||
Deine Daten wurden vom <abbr>GitHub Enterprise Server</abbr> importiert und können hier nur angezeigt werden.
|
||||
</p>
|
||||
<p>
|
||||
Solltest Du Änderungen oder eine Löschung deiner Daten von unserem Dienst beantragen wollen, so wende dich
|
||||
bitte an einen Administrator.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -1,48 +1,66 @@
|
||||
import { Metadata } from "next";
|
||||
import { DollarSign } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import DashboardCard from "@/components/DashboardCard";
|
||||
import { columns } from "@/app/my/jobs/columns";
|
||||
import { JobsTable } from "@/app/my/jobs/data-table";
|
||||
import { DynamicPrinterCards } from "@/components/dynamic-printer-cards";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { validateRequest } from "@/server/auth";
|
||||
import { db } from "@/server/db";
|
||||
import { printJobs } from "@/server/db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Startseite | MYP",
|
||||
title: "Dashboard | MYP",
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col justify-evenly gap-8 lg:flex-row">
|
||||
<DashboardCard
|
||||
title="Druckstunden"
|
||||
data="200"
|
||||
icon="Clock"
|
||||
trend="none"
|
||||
/>
|
||||
<DashboardCard
|
||||
title="Lieblingsdrucker"
|
||||
data="200"
|
||||
icon="Heart"
|
||||
trend="none"
|
||||
/>
|
||||
<DashboardCard
|
||||
title="Am häfigsten gedruckt am"
|
||||
data="200"
|
||||
icon="CalendarDays"
|
||||
trend="none"
|
||||
/>
|
||||
<DashboardCard
|
||||
title="Erfolgreiche Drucke"
|
||||
data="200"
|
||||
icon="PackageCheck"
|
||||
trend="none"
|
||||
/>
|
||||
<DashboardCard
|
||||
title="Gemeldete Fehler"
|
||||
data="200"
|
||||
icon="ShieldAlert"
|
||||
trend="none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default async function HomePage() {
|
||||
const { user } = await validateRequest();
|
||||
const userIsLoggedIn = Boolean(user);
|
||||
|
||||
const printers = await db.query.printers.findMany({
|
||||
with: {
|
||||
printJobs: {
|
||||
limit: 1,
|
||||
orderBy: (printJobs, { desc }) => [desc(printJobs.startAt)],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: temp. fix for jobs
|
||||
let jobs: any[] = [];
|
||||
if (userIsLoggedIn) {
|
||||
jobs = await db.query.printJobs.findMany({
|
||||
// biome-ignore lint/style/noNonNullAssertion: User exists if userIsLoggedIn is true
|
||||
where: eq(printJobs.userId, user!.id),
|
||||
orderBy: [desc(printJobs.startAt)],
|
||||
with: {
|
||||
printer: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* NEEDS TO BE FIXED FOR A NEW / EMPTY USER {isLoggedIn && <PersonalizedCards />} */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Druckerbelegung</CardTitle>
|
||||
<CardDescription>({printers.length} Verfügbar)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<DynamicPrinterCards user={user} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{userIsLoggedIn && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Druckaufträge</CardTitle>
|
||||
<CardDescription>Deine aktuellen Druckaufträge</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JobsTable columns={columns} data={jobs} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogClose } from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { createPrintJob } from "@/server/actions/printJobs";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CalendarPlusIcon, XCircleIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { If, Then } from "react-if";
|
||||
import { z } from "zod";
|
||||
|
||||
export const formSchema = z.object({
|
||||
hours: z.coerce.number().int().min(0).max(96, {
|
||||
message: "Die Stunden müssen zwischen 0 und 96 liegen.",
|
||||
}),
|
||||
minutes: z.coerce.number().int().min(0).max(59, {
|
||||
message: "Die Minuten müssen zwischen 0 und 59 liegen.",
|
||||
}),
|
||||
comments: z.string().optional(),
|
||||
});
|
||||
|
||||
interface PrinterReserveFormProps {
|
||||
userId: string;
|
||||
printerId: string;
|
||||
isDialog?: boolean;
|
||||
}
|
||||
|
||||
export function PrinterReserveForm(props: PrinterReserveFormProps) {
|
||||
const { userId, printerId, isDialog } = props;
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
comments: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
if (values.hours === 0 && values.minutes === 0) {
|
||||
form.setError("hours", {
|
||||
message: "",
|
||||
});
|
||||
form.setError("minutes", {
|
||||
message:
|
||||
"Die Dauer des Druckauftrags muss mindestens 1 Minute betragen.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const jobId = await createPrintJob({
|
||||
durationInMinutes: values.hours * 60 + values.minutes,
|
||||
comments: values.comments,
|
||||
userId: userId,
|
||||
printerId: printerId,
|
||||
});
|
||||
|
||||
router.push(`/job/${jobId}`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast({ variant: "destructive", description: error.message });
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
description: "Ein unbekannter Fehler ist aufgetreten.",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
toast({ description: "Druckauftrag wurde erfolgreich erstellt." });
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<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>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="comments"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Anmerkungen</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
In dieses Feld kannst du Anmerkungen zu deinem Druckauftrag
|
||||
hinzufügen. Sie können beispielsweise Informationen über das
|
||||
Druckmaterial, die Druckqualität oder die Farbe enthalten.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<If condition={isDialog}>
|
||||
<Then>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
className="gap-2 flex items-center"
|
||||
>
|
||||
<XCircleIcon className="w-4 h-4" />
|
||||
<span>Abbrechen</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</Then>
|
||||
</If>
|
||||
<Button type="submit" className="gap-2 flex items-center">
|
||||
<CalendarPlusIcon className="w-4 h-4" />
|
||||
<span>Reservieren</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
import { PrinterReserveForm } from "@/app/printer/[printerId]/reserve/form";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { validateRequest } from "@/server/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Drucker reservieren",
|
||||
};
|
||||
|
||||
interface PrinterReservePageProps {
|
||||
params: {
|
||||
printerId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PrinterReservePage(props: PrinterReservePageProps) {
|
||||
const { user } = await validateRequest();
|
||||
const { printerId } = props.params;
|
||||
|
||||
if (!user) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Drucker reservieren</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PrinterReserveForm userId={user?.id} printerId={printerId} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
import { icons } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface DashboardCardProps {
|
||||
title: string;
|
||||
icon: keyof typeof icons;
|
||||
data: string;
|
||||
trend: string;
|
||||
}
|
||||
|
||||
export default function DashboardCard(props: DashboardCardProps) {
|
||||
const { title, icon, data, trend } = props;
|
||||
|
||||
const LucideIcon = icons[icon];
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-8 space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<LucideIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{data}</div>
|
||||
<p className="text-xs text-muted-foreground">{trend}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { VaultIcon } from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
|
||||
interface HeaderProps {}
|
||||
export default function Header(props: HeaderProps) {
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between bg-neutral-900 px-96 text-neutral-50">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex select-none items-center gap-2 text-lg font-semibold"
|
||||
>
|
||||
<VaultIcon className="h-6 w-6" />
|
||||
<div className="font-mono">MYP</div>
|
||||
<span className="sr-only">Manage your Printer</span>
|
||||
</Link>
|
||||
<Avatar className="select-none">
|
||||
<AvatarFallback className="border-2 border-neutral-700 bg-neutral-800 text-blue-50">
|
||||
CN
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</header>
|
||||
);
|
||||
}
|
38
packages/reservation-platform/src/components/data-card.tsx
Normal file
38
packages/reservation-platform/src/components/data-card.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { icons } from "lucide-react";
|
||||
|
||||
interface GenericIconProps {
|
||||
name: keyof typeof icons;
|
||||
className: string;
|
||||
}
|
||||
|
||||
function GenericIcon(props: GenericIconProps) {
|
||||
const { name, className } = props;
|
||||
const LucideIcon = icons[name];
|
||||
|
||||
return <LucideIcon className={className} />;
|
||||
}
|
||||
|
||||
interface DataCardProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
value: string | number;
|
||||
icon: keyof typeof icons;
|
||||
}
|
||||
|
||||
export function DataCard(props: DataCardProps) {
|
||||
const { title, description, value, icon } = props;
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<GenericIcon name={icon} className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<p className="text-xs text-muted-foreground"> </p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { PrinterCard } from "@/components/printer-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { InferResultType } from "@/utils/drizzle";
|
||||
import { fetcher } from "@/utils/fetch";
|
||||
import type { RegisteredDatabaseUserAttributes } from "lucia";
|
||||
import useSWR from "swr";
|
||||
|
||||
interface DynamicPrinterCardsProps {
|
||||
user: RegisteredDatabaseUserAttributes | null;
|
||||
}
|
||||
|
||||
export function DynamicPrinterCards(props: DynamicPrinterCardsProps) {
|
||||
const { user } = props;
|
||||
const { data, error, isLoading } = useSWR("/api/printers", fetcher, {
|
||||
refreshInterval: 1000 * 15,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Ein Fehler ist aufgetreten.</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
{new Array(6).fill(null).map((_, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
<Skeleton key={index} className="w-auto h-36 animate-pulse" />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return data.map((printer: InferResultType<"printers", { printJobs: true }>) => {
|
||||
return <PrinterCard key={printer.id} printer={printer} user={user} />;
|
||||
});
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
import { HeaderNavigation } from "@/components/header/navigation";
|
||||
import { LogoutButton } from "@/components/logout-button";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { validateRequest } from "@/server/auth";
|
||||
import { UserRole, hasRole } from "@/server/auth/permissions";
|
||||
import { ScanFaceIcon, StickerIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { If, Then } from "react-if";
|
||||
|
||||
function getInitials(name: string | undefined) {
|
||||
if (!name) return "";
|
||||
|
||||
const parts = name.split(" ");
|
||||
if (parts.length === 1) return parts[0].slice(0, 2);
|
||||
|
||||
return parts[0].charAt(0) + parts[parts.length - 1].charAt(0);
|
||||
}
|
||||
|
||||
export async function Header() {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-neutral-900 border-b-4 border-neutral-600 text-white select-none shadow-md">
|
||||
<div className="px-8 h-full max-w-screen-2xl w-full mx-auto flex items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-8">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<StickerIcon size={20} />
|
||||
<h1 className="text-lg font-mono">MYP</h1>
|
||||
</Link>
|
||||
<HeaderNavigation />
|
||||
</div>
|
||||
|
||||
{user != null && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Avatar>
|
||||
<AvatarFallback className="bg-neutral-700">
|
||||
<span className="font-semibold">{getInitials(user?.displayName)}</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Mein Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/my/profile/" className="flex items-center gap-2">
|
||||
<UserIcon className="w-4 h-4" />
|
||||
<span>Mein Profil</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<If condition={hasRole(user, UserRole.ADMIN)}>
|
||||
<Then>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin/" className="flex items-center gap-2">
|
||||
<WrenchIcon className="w-4 h-4" />
|
||||
<span>Adminbereich</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</Then>
|
||||
</If>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogoutButton />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{user == null && (
|
||||
<Button variant={"ghost"} className="gap-2 flex items-center" asChild>
|
||||
<Link href="/auth/login">
|
||||
<ScanFaceIcon className="w-4 h-4" />
|
||||
<span>Anmelden</span>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
import { cn } from "@/utils/styles";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
interface Site {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function HeaderNavigation() {
|
||||
const pathname = usePathname();
|
||||
const sites: Site[] = [
|
||||
{
|
||||
name: "Mein Dashboard",
|
||||
path: "/",
|
||||
},
|
||||
/* {
|
||||
name: "Meine Druckaufträge",
|
||||
path: "/my/jobs",
|
||||
}, */
|
||||
{
|
||||
name: "Mein Profil",
|
||||
path: "/my/profile",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="font-medium text-sm flex items-center gap-4 flex-row">
|
||||
{sites.map((site) => (
|
||||
<Link
|
||||
key={site.path}
|
||||
href={site.path}
|
||||
className={cn("transition-colors hover:text-neutral-50", {
|
||||
"text-neutral-50": pathname === site.path,
|
||||
"text-neutral-500": pathname !== site.path,
|
||||
})}
|
||||
>
|
||||
{site.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { logout } from "@/server/actions/authentication/logout";
|
||||
import { LogOutIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function LogoutButton() {
|
||||
return (
|
||||
<Link href="/" onClick={() => logout()} className="flex items-center gap-2">
|
||||
<LogOutIcon className="w-4 h-4" />
|
||||
<span>Abmelden</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
import { DataCard } from "@/components/data-card";
|
||||
import { validateRequest } from "@/server/auth";
|
||||
import { db } from "@/server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export default async function PersonalizedCards() {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allPrintJobs = await db.query.printJobs.findMany({
|
||||
with: {
|
||||
printer: true,
|
||||
},
|
||||
where: (printJobs) => eq(printJobs.userId, user.id),
|
||||
});
|
||||
|
||||
const totalPrintingMinutes = allPrintJobs
|
||||
.filter((job) => !job.aborted)
|
||||
.reduce((acc, curr) => acc + curr.durationInMinutes, 0);
|
||||
const averagePrintingHoursPerWeek = totalPrintingMinutes / 60 / 52;
|
||||
|
||||
const mostUsedPrinters = allPrintJobs
|
||||
.map((job) => job.printer.name)
|
||||
.reduce((acc, curr) => {
|
||||
acc[curr] = (acc[curr] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const mostUsedPrinter = Object.keys(mostUsedPrinters).reduce((a, b) =>
|
||||
mostUsedPrinters[a] > mostUsedPrinters[b] ? a : b,
|
||||
);
|
||||
|
||||
const printerSuccessRate = (allPrintJobs.filter((job) => job.aborted).length / allPrintJobs.length) * 100;
|
||||
|
||||
const mostUsedWeekday = allPrintJobs
|
||||
.map((job) => job.startAt.getDay())
|
||||
.reduce((acc, curr) => {
|
||||
acc[curr] = (acc[curr] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const mostUsedWeekdayIndex = Object.keys(mostUsedWeekday).reduce((a, b) =>
|
||||
mostUsedWeekday[a] > mostUsedWeekday[b] ? a : b,
|
||||
);
|
||||
|
||||
const mostUsedWeekdayName = new Intl.DateTimeFormat("de-DE", {
|
||||
weekday: "long",
|
||||
}).format(new Date(0, 0, Number.parseInt(mostUsedWeekdayIndex)));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<DataCard
|
||||
icon="Clock10"
|
||||
title="Druckstunden"
|
||||
description="insgesamt"
|
||||
value={`${(totalPrintingMinutes / 60).toFixed(2)}h`}
|
||||
/>
|
||||
<DataCard
|
||||
icon="Calendar"
|
||||
title="Aktivster Tag"
|
||||
description="(nach Anzahl der Aufträgen)"
|
||||
value={mostUsedWeekdayName}
|
||||
/>
|
||||
<DataCard icon="Heart" title="Lieblingsdrucker" description="" value={mostUsedPrinter} />
|
||||
<DataCard icon="Check" title="Druckerfolgsquote" description="" value={`${printerSuccessRate.toFixed(2)}%`} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PrinterStatus, translatePrinterStatus } from "@/utils/printers";
|
||||
import { cn } from "@/utils/styles";
|
||||
|
||||
interface PrinterAvailabilityBadgeProps {
|
||||
status: PrinterStatus;
|
||||
}
|
||||
|
||||
export function PrinterAvailabilityBadge(props: PrinterAvailabilityBadgeProps) {
|
||||
const { status } = props;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn("pointer-events-none select-none", {
|
||||
"bg-green-500 hover:bg-green-500 animate-pulse":
|
||||
status === PrinterStatus.IDLE,
|
||||
"bg-red-500 hover:bg-red-500 opacity-50":
|
||||
status === PrinterStatus.OUT_OF_ORDER,
|
||||
"bg-orange-500 hover:bg-orange-500": status === PrinterStatus.RESERVED,
|
||||
})}
|
||||
>
|
||||
{translatePrinterStatus(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
import { revalidate } from "@/server/actions/timer";
|
||||
import { fetcher } from "@/utils/fetch";
|
||||
import useSWR from "swr";
|
||||
|
||||
interface CountdownProps {
|
||||
jobId: string;
|
||||
}
|
||||
export function Countdown(props: CountdownProps) {
|
||||
const { jobId } = props;
|
||||
const { data, error, isLoading } = useSWR(`/api/job/${jobId}/remaining-time`, fetcher, {
|
||||
refreshInterval: 1000 * 30,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <span className="text-red-500">Ein Fehler ist aufgetreten.</span>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <>...</>;
|
||||
}
|
||||
|
||||
console.log(data);
|
||||
|
||||
const days = Math.floor(data.remainingTime / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((data.remainingTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((data.remainingTime % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((data.remainingTime % (1000 * 60)) / 1000);
|
||||
|
||||
if (days <= 0 && hours <= 0 && minutes <= 0 && seconds <= 0) {
|
||||
revalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="tabular-nums" suppressHydrationWarning>
|
||||
{days > 0 && <>{`${days}`.padStart(2, "0")}d </>}
|
||||
{hours === 0 && minutes === 0 ? (
|
||||
<>{`${seconds}`.padStart(2, "0")}s</>
|
||||
) : (
|
||||
<>
|
||||
{`${hours}`.padStart(2, "0")}h {`${minutes}`.padStart(2, "0")}min
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { PrinterReserveForm } from "@/app/printer/[printerId]/reserve/form";
|
||||
import { Countdown } from "@/components/printer-card/countdown";
|
||||
import { AlertDialogHeader } from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { UserRole, hasRole } from "@/server/auth/permissions";
|
||||
import type { InferResultType } from "@/utils/drizzle";
|
||||
import { PrinterStatus, derivePrinterStatus, translatePrinterStatus } from "@/utils/printers";
|
||||
import { cn } from "@/utils/styles";
|
||||
import type { RegisteredDatabaseUserAttributes } from "lucia";
|
||||
import { CalendarPlusIcon, ChevronRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Else, If, Then } from "react-if";
|
||||
|
||||
interface PrinterCardProps {
|
||||
printer: InferResultType<"printers", { printJobs: true }>;
|
||||
user?: RegisteredDatabaseUserAttributes | null;
|
||||
}
|
||||
|
||||
export function PrinterCard(props: PrinterCardProps) {
|
||||
const { printer, user } = props;
|
||||
const status = derivePrinterStatus(printer);
|
||||
|
||||
const userIsLoggedIn = Boolean(user);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn("w-auto h-36", {
|
||||
"opacity-50 select-none cursor-not-allowed": status === PrinterStatus.OUT_OF_ORDER,
|
||||
})}
|
||||
>
|
||||
<CardHeader className="flex flex-row justify-between">
|
||||
<div>
|
||||
<CardTitle>{printer.name}</CardTitle>
|
||||
<CardDescription>{printer.description}</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
className={cn({
|
||||
"bg-green-500 hover:bg-green-400": status === PrinterStatus.IDLE,
|
||||
"bg-red-500 hover:bg-red-500": status === PrinterStatus.OUT_OF_ORDER,
|
||||
"bg-yellow-500 hover:bg-yellow-400": status === PrinterStatus.RESERVED,
|
||||
})}
|
||||
>
|
||||
{status === PrinterStatus.RESERVED && <Countdown jobId={printer.printJobs[0].id} />}
|
||||
<If condition={status === PrinterStatus.RESERVED}>
|
||||
<Else>{translatePrinterStatus(status)}</Else>
|
||||
</If>
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-end">
|
||||
<If condition={status === PrinterStatus.IDLE && userIsLoggedIn && !hasRole(user, UserRole.GUEST)}>
|
||||
<Then>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={"default"} className="flex items-center gap-2 w-full">
|
||||
<CalendarPlusIcon className="w-4 h-4" />
|
||||
<span>Reservieren</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<AlertDialogHeader>
|
||||
<DialogTitle>{printer.name} reservieren</DialogTitle>
|
||||
<DialogDescription>Gebe die geschätzte Druckdauer an.</DialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<PrinterReserveForm isDialog={true} printerId={printer.id} userId={user?.id ?? ""} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Then>
|
||||
</If>
|
||||
{status === PrinterStatus.RESERVED && (
|
||||
<Button asChild variant={"secondary"}>
|
||||
<Link href={`/job/${printer.printJobs[0].id}`} className="flex items-center gap-2 w-full">
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
<span>Details anzeigen</span>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
141
packages/reservation-platform/src/components/ui/alert-dialog.tsx
Normal file
141
packages/reservation-platform/src/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
59
packages/reservation-platform/src/components/ui/alert.tsx
Normal file
59
packages/reservation-platform/src/components/ui/alert.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
|
36
packages/reservation-platform/src/components/ui/badge.tsx
Normal file
36
packages/reservation-platform/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
115
packages/reservation-platform/src/components/ui/breadcrumb.tsx
Normal file
115
packages/reservation-platform/src/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import * as React from "react"
|
||||
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||
Breadcrumb.displayName = "Breadcrumb"
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbList.displayName = "BreadcrumbList"
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon />}
|
||||
</li>
|
||||
)
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<DotsHorizontalIcon className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
@ -2,28 +2,29 @@ import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -35,10 +35,7 @@ const CardTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
@ -1,30 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
122
packages/reservation-platform/src/components/ui/dialog.tsx
Normal file
122
packages/reservation-platform/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
@ -2,9 +2,13 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
DotFilledIcon,
|
||||
} from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
@ -34,7 +38,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
@ -65,7 +69,8 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -107,7 +112,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@ -130,7 +135,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
<DotFilledIcon className="h-4 w-4 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
|
176
packages/reservation-platform/src/components/ui/form.tsx
Normal file
176
packages/reservation-platform/src/components/ui/form.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
26
packages/reservation-platform/src/components/ui/label.tsx
Normal file
26
packages/reservation-platform/src/components/ui/label.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
164
packages/reservation-platform/src/components/ui/select.tsx
Normal file
164
packages/reservation-platform/src/components/ui/select.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
CaretSortIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "@radix-ui/react-icons"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<CaretSortIcon className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
@ -1,140 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
15
packages/reservation-platform/src/components/ui/skeleton.tsx
Normal file
15
packages/reservation-platform/src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
31
packages/reservation-platform/src/components/ui/sonner.tsx
Normal file
31
packages/reservation-platform/src/components/ui/sonner.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
120
packages/reservation-platform/src/components/ui/table.tsx
Normal file
120
packages/reservation-platform/src/components/ui/table.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
55
packages/reservation-platform/src/components/ui/tabs.tsx
Normal file
55
packages/reservation-platform/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
24
packages/reservation-platform/src/components/ui/textarea.tsx
Normal file
24
packages/reservation-platform/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
129
packages/reservation-platform/src/components/ui/toast.tsx
Normal file
129
packages/reservation-platform/src/components/ui/toast.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/utils/styles"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
35
packages/reservation-platform/src/components/ui/toaster.tsx
Normal file
35
packages/reservation-platform/src/components/ui/toaster.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
194
packages/reservation-platform/src/components/ui/use-toast.ts
Normal file
194
packages/reservation-platform/src/components/ui/use-toast.ts
Normal file
@ -0,0 +1,194 @@
|
||||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
@ -1,60 +0,0 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
DATABASE_URL: z.string().url(),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
NEXTAUTH_SECRET:
|
||||
process.env.NODE_ENV === "production"
|
||||
? z.string()
|
||||
: z.string().optional(),
|
||||
NEXTAUTH_URL: z.preprocess(
|
||||
// This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
|
||||
// Since NextAuth.js automatically uses the VERCEL_URL if present.
|
||||
(str) => process.env.VERCEL_URL ?? str,
|
||||
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
|
||||
process.env.VERCEL ? z.string() : z.string().url()
|
||||
),
|
||||
GITHUB_CLIENT_ID: z.string(),
|
||||
GITHUB_CLIENT_SECRET: z.string(),
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify your client-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
||||
* `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
||||
},
|
||||
|
||||
/**
|
||||
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
|
||||
GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
||||
* useful for Docker builds.
|
||||
*/
|
||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||
/**
|
||||
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
|
||||
* `SOME_VAR=''` will throw an error.
|
||||
*/
|
||||
emptyStringAsUndefined: true,
|
||||
});
|
@ -1,6 +0,0 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export { default } from "next-auth/middleware"
|
||||
|
@ -0,0 +1,25 @@
|
||||
"use server";
|
||||
|
||||
import { lucia, validateRequest } from "@/server/auth";
|
||||
import { AuthenticationError } from "@/utils/errors";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function logout(path?: string) {
|
||||
const { session } = await validateRequest();
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
try {
|
||||
await lucia.invalidateSession(session.id);
|
||||
} catch (error) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const sessionCookie = lucia.createBlankSessionCookie();
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
|
||||
revalidatePath(path ?? "/");
|
||||
}
|
269
packages/reservation-platform/src/server/actions/printJobs.ts
Normal file
269
packages/reservation-platform/src/server/actions/printJobs.ts
Normal file
@ -0,0 +1,269 @@
|
||||
"use server";
|
||||
|
||||
import { validateRequest } from "@/server/auth";
|
||||
import { UserRole } from "@/server/auth/permissions";
|
||||
import { db } from "@/server/db";
|
||||
import { printJobs, users } from "@/server/db/schema";
|
||||
import { PermissionError } from "@/utils/errors";
|
||||
import { IS, guard } from "@/utils/guard";
|
||||
import { type InferInsertModel, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function createPrintJob(printJob: InferInsertModel<typeof printJobs>) {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, IS, UserRole.GUEST)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
where: eq(users.id, user!.id),
|
||||
});
|
||||
|
||||
if (guard(dbUser, IS, UserRole.GUEST)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.insert(printJobs).values(printJob).returning({
|
||||
jobId: printJobs.id,
|
||||
});
|
||||
|
||||
return result[0].jobId;
|
||||
} catch (error) {
|
||||
throw new Error("Druckauftrag konnte nicht hinzugefügt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
/* async function updatePrintJob(jobId: string, printJob: InferInsertModel<typeof printJobs>) {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, is, UserRole.GUEST)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
where: eq(users.id, user!.id),
|
||||
});
|
||||
|
||||
if (guard(dbUser, is, UserRole.GUEST)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
await db.update(printJobs).set(printJob).where(eq(printJobs.id, jobId));
|
||||
} */
|
||||
|
||||
export async function abortPrintJob(jobId: string, reason: string) {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, IS, UserRole.GUEST)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
where: eq(users.id, user!.id),
|
||||
});
|
||||
|
||||
if (guard(dbUser, IS, UserRole.GUEST)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
// Get the print job
|
||||
const printJob = await db.query.printJobs.findFirst({
|
||||
where: eq(printJobs.id, jobId),
|
||||
});
|
||||
|
||||
if (!printJob) {
|
||||
throw new Error("Druckauftrag nicht gefunden");
|
||||
}
|
||||
|
||||
// Check if the print job is already aborted or completed
|
||||
if (printJob.aborted) {
|
||||
throw new Error("Druckauftrag wurde bereits abgebrochen");
|
||||
}
|
||||
|
||||
if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) {
|
||||
throw new Error("Druckauftrag ist bereits abgeschlossen");
|
||||
}
|
||||
|
||||
// Check if user is the owner of the print job
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
if (printJob.userId !== dbUser!.id || dbUser!.role !== UserRole.ADMIN) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
// Get duration in minutes since startAt
|
||||
const duration = Math.floor((Date.now() - new Date(printJob.startAt).getTime()) / 1000 / 60);
|
||||
|
||||
await db
|
||||
.update(printJobs)
|
||||
.set({
|
||||
aborted: true,
|
||||
abortReason: reason,
|
||||
durationInMinutes: duration,
|
||||
comments: `${printJob.comments}\n\n---${dbUser?.username}: Druckauftrag abgebrochen`,
|
||||
})
|
||||
.where(eq(printJobs.id, jobId));
|
||||
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function earlyFinishPrintJob(jobId: string) {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, IS, UserRole.GUEST)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
where: eq(users.id, user!.id),
|
||||
});
|
||||
|
||||
if (guard(dbUser, IS, UserRole.GUEST)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
// Get the print job
|
||||
const printJob = await db.query.printJobs.findFirst({
|
||||
where: eq(printJobs.id, jobId),
|
||||
});
|
||||
|
||||
if (!printJob) {
|
||||
throw new Error("Druckauftrag nicht gefunden");
|
||||
}
|
||||
|
||||
// Check if the print job is already aborted or completed
|
||||
if (printJob.aborted) {
|
||||
throw new Error("Druckauftrag wurde bereits abgebrochen");
|
||||
}
|
||||
|
||||
if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) {
|
||||
throw new Error("Druckauftrag ist bereits abgeschlossen");
|
||||
}
|
||||
|
||||
// Check if user is the owner of the print job
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
if (printJob.userId !== dbUser!.id || dbUser!.role !== UserRole.ADMIN) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
// Get duration in minutes since startAt
|
||||
const duration = Math.floor((Date.now() - new Date(printJob.startAt).getTime()) / 1000 / 60);
|
||||
|
||||
await db
|
||||
.update(printJobs)
|
||||
.set({
|
||||
durationInMinutes: duration,
|
||||
comments: `${printJob.comments}\n\n---${dbUser?.username}: Druckauftrag vorzeitig abgeschlossen`,
|
||||
})
|
||||
.where(eq(printJobs.id, jobId));
|
||||
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function extendPrintJob(jobId: string, minutes: number, hours: number) {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, IS, UserRole.GUEST)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
where: eq(users.id, user!.id),
|
||||
});
|
||||
|
||||
if (guard(dbUser, IS, UserRole.GUEST)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
// Get the print job
|
||||
const printJob = await db.query.printJobs.findFirst({
|
||||
where: eq(printJobs.id, jobId),
|
||||
});
|
||||
|
||||
if (!printJob) {
|
||||
throw new Error("Druckauftrag nicht gefunden");
|
||||
}
|
||||
|
||||
// Check if the print job is already aborted or completed
|
||||
if (printJob.aborted) {
|
||||
throw new Error("Druckauftrag wurde bereits abgebrochen");
|
||||
}
|
||||
|
||||
if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) {
|
||||
throw new Error("Druckauftrag ist bereits abgeschlossen");
|
||||
}
|
||||
|
||||
// Check if user is the owner of the print job
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
if (printJob.userId !== dbUser!.id || dbUser!.role !== UserRole.ADMIN) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const duration = minutes + hours * 60;
|
||||
|
||||
await db
|
||||
.update(printJobs)
|
||||
.set({
|
||||
durationInMinutes: printJob.durationInMinutes + duration,
|
||||
comments: `${printJob.comments}\n\n---${dbUser?.username}: Verlängert um ${hours} Stunden und ${minutes} Minuten`,
|
||||
})
|
||||
.where(eq(printJobs.id, jobId));
|
||||
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function updatePrintComments(jobId: string, comments: string) {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, IS, UserRole.GUEST)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
where: eq(users.id, user!.id),
|
||||
});
|
||||
|
||||
if (guard(dbUser, IS, UserRole.GUEST)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
// Get the print job
|
||||
const printJob = await db.query.printJobs.findFirst({
|
||||
where: eq(printJobs.id, jobId),
|
||||
});
|
||||
|
||||
if (!printJob) {
|
||||
throw new Error("Druckauftrag nicht gefunden");
|
||||
}
|
||||
|
||||
// Check if the print job is already aborted or completed
|
||||
if (printJob.aborted) {
|
||||
throw new Error("Druckauftrag wurde bereits abgebrochen");
|
||||
}
|
||||
|
||||
if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) {
|
||||
throw new Error("Druckauftrag ist bereits abgeschlossen");
|
||||
}
|
||||
|
||||
// Check if user is the owner of the print job
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
if (printJob.userId !== dbUser!.id || dbUser!.role !== UserRole.ADMIN) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
await db
|
||||
.update(printJobs)
|
||||
.set({
|
||||
comments,
|
||||
})
|
||||
.where(eq(printJobs.id, jobId));
|
||||
|
||||
revalidatePath("/");
|
||||
}
|
107
packages/reservation-platform/src/server/actions/printers.ts
Normal file
107
packages/reservation-platform/src/server/actions/printers.ts
Normal file
@ -0,0 +1,107 @@
|
||||
"use server";
|
||||
|
||||
import { validateRequest } from "@/server/auth";
|
||||
import { UserRole } from "@/server/auth/permissions";
|
||||
import { db } from "@/server/db";
|
||||
import { printers, users } from "@/server/db/schema";
|
||||
import { PermissionError } from "@/utils/errors";
|
||||
import { IS_NOT, guard } from "@/utils/guard";
|
||||
import { type InferInsertModel, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function createPrinter(printer: InferInsertModel<typeof printers>) {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
where: eq(users.id, user!.id),
|
||||
});
|
||||
|
||||
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
if (!printer) {
|
||||
throw new Error("Druckerdaten sind erforderlich.");
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(printers).values(printer);
|
||||
} catch (error) {
|
||||
throw new Error("Drucker konnte nicht hinzugefügt werden.");
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function updatePrinter(id: string, data: InferInsertModel<typeof printers>) {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
where: eq(users.id, user!.id),
|
||||
});
|
||||
|
||||
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("Druckerdaten sind erforderlich.");
|
||||
}
|
||||
|
||||
try {
|
||||
await db.update(printers).set(data).where(eq(printers.id, id));
|
||||
} catch (error) {
|
||||
throw new Error("Drucker konnte nicht aktualisiert werden.");
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function deletePrinter(id: string) {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
where: eq(users.id, user!.id),
|
||||
});
|
||||
|
||||
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(printers).where(eq(printers.id, id));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
throw new Error("Ein unbekannter Fehler ist aufgetreten.");
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function getPrinters() {
|
||||
return await db.query.printers.findMany({
|
||||
with: {
|
||||
printJobs: {
|
||||
limit: 1,
|
||||
orderBy: (printJobs, { desc }) => [desc(printJobs.startAt)],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidate() {
|
||||
revalidatePath("/");
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
"use server";
|
||||
|
||||
import { validateRequest } from "@/server/auth";
|
||||
import { UserRole } from "@/server/auth/permissions";
|
||||
import { db } from "@/server/db";
|
||||
import { users } from "@/server/db/schema";
|
||||
import { PermissionError } from "@/utils/errors";
|
||||
import { IS, IS_NOT, guard } from "@/utils/guard";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
/**
|
||||
* Deletes a user from the database
|
||||
* @param userId User ID to delete
|
||||
* @param path Path to revalidate
|
||||
*/
|
||||
export async function deleteUser(userId: string, path?: string) {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
where: eq(users.id, user!.id),
|
||||
});
|
||||
|
||||
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
throw new Error("Benutzer nicht gefunden");
|
||||
}
|
||||
|
||||
if (guard(targetUser, IS, UserRole.ADMIN)) {
|
||||
throw new Error("Kann keinen Admin löschen");
|
||||
}
|
||||
|
||||
await db.delete(users).where(eq(users.id, userId));
|
||||
|
||||
revalidatePath(path ?? "/admin/users");
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import type { formSchema } from "@/app/admin/users/form";
|
||||
import { validateRequest } from "@/server/auth";
|
||||
import { UserRole } from "@/server/auth/permissions";
|
||||
import { db } from "@/server/db";
|
||||
import { users } from "@/server/db/schema";
|
||||
import { PermissionError } from "@/utils/errors";
|
||||
import { IS_NOT, guard } from "@/utils/guard";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { z } from "zod";
|
||||
|
||||
/**
|
||||
* Updates a user in the database
|
||||
* @param userId User ID to update
|
||||
* @param data Updated user data
|
||||
* @param path Path to revalidate
|
||||
*/
|
||||
export async function updateUser(userId: string, data: z.infer<typeof formSchema>, path?: string) {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
where: eq(users.id, user!.id),
|
||||
});
|
||||
|
||||
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
await db.update(users).set(data).where(eq(users.id, userId));
|
||||
|
||||
revalidatePath(path ?? "/admin/users");
|
||||
}
|
72
packages/reservation-platform/src/server/actions/users.ts
Normal file
72
packages/reservation-platform/src/server/actions/users.ts
Normal file
@ -0,0 +1,72 @@
|
||||
"use server";
|
||||
|
||||
import type { formSchema } from "@/app/admin/users/form";
|
||||
import { validateRequest } from "@/server/auth";
|
||||
import { UserRole } from "@/server/auth/permissions";
|
||||
import { db } from "@/server/db";
|
||||
import { users } from "@/server/db/schema";
|
||||
import { PermissionError } from "@/utils/errors";
|
||||
import { IS, IS_NOT, guard } from "@/utils/guard";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { z } from "zod";
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export async function updateUser(userId: string, data: z.infer<typeof formSchema>) {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
where: eq(users.id, user!.id),
|
||||
});
|
||||
|
||||
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
await db.update(users).set(data).where(eq(users.id, userId));
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export async function deleteUser(userId: string) {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
|
||||
where: eq(users.id, user!.id),
|
||||
});
|
||||
|
||||
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
|
||||
throw new PermissionError();
|
||||
}
|
||||
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
throw new Error("Benutzer nicht gefunden");
|
||||
}
|
||||
|
||||
if (guard(targetUser, IS, UserRole.ADMIN)) {
|
||||
throw new Error("Kann keinen Admin löschen");
|
||||
}
|
||||
|
||||
await db.delete(users).where(eq(users.id, userId));
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||
import {
|
||||
getServerSession,
|
||||
type DefaultSession,
|
||||
type NextAuthOptions,
|
||||
} from "next-auth";
|
||||
import { type Adapter } from "next-auth/adapters";
|
||||
import GitHubProvider from "next-auth/providers/github";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { db } from "@/server/db";
|
||||
|
||||
/**
|
||||
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
|
||||
* object and keep type safety.
|
||||
*
|
||||
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
|
||||
*/
|
||||
declare module "next-auth" {
|
||||
interface Session extends DefaultSession {
|
||||
user: {
|
||||
id: string;
|
||||
// ...other properties
|
||||
// role: UserRole;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
// interface User {
|
||||
// // ...other properties
|
||||
// // role: UserRole;
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
|
||||
*
|
||||
* @see https://next-auth.js.org/configuration/options
|
||||
*/
|
||||
export const authOptions: NextAuthOptions = {
|
||||
callbacks: {
|
||||
session: ({ session, user }) => ({
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: user.id,
|
||||
},
|
||||
}),
|
||||
},
|
||||
adapter: PrismaAdapter(db) as Adapter,
|
||||
providers: [
|
||||
GitHubProvider({
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||
authorization: {
|
||||
url: "https://git.i.mercedes-benz.com/login/oauth/authorize",
|
||||
params: {
|
||||
scope: "read:user user:email"
|
||||
}
|
||||
},
|
||||
token: "https://git.i.mercedes-benz.com/login/oauth/access_token",
|
||||
userinfo: {
|
||||
url: "https://git.i.mercedes-benz.com/api/v3/user",
|
||||
}
|
||||
}),
|
||||
/**
|
||||
* ...add more providers here.
|
||||
*
|
||||
* Most other providers require a bit more work than the GITHUB provider. For example, the
|
||||
* GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
|
||||
* model. Refer to the NextAuth.js docs for the provider you want to use. Example:
|
||||
*
|
||||
* @see https://next-auth.js.org/providers/github
|
||||
*/
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file.
|
||||
*
|
||||
* @see https://next-auth.js.org/configuration/nextjs
|
||||
*/
|
||||
export const getServerAuthSession = () => getServerSession(authOptions);
|
72
packages/reservation-platform/src/server/auth/index.ts
Normal file
72
packages/reservation-platform/src/server/auth/index.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import type { UserRole } from "@/server/auth/permissions";
|
||||
import { db } from "@/server/db";
|
||||
import { sessions, users } from "@/server/db/schema";
|
||||
import { env } from "@/utils/env";
|
||||
import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle";
|
||||
import { Lucia, type RegisteredDatabaseUserAttributes, type Session } from "lucia";
|
||||
import { cookies } from "next/headers";
|
||||
import { cache } from "react";
|
||||
|
||||
const adapter = new DrizzleSQLiteAdapter(db, sessions, users);
|
||||
|
||||
export const lucia = new Lucia(adapter, {
|
||||
sessionCookie: {
|
||||
expires: false,
|
||||
attributes: {
|
||||
secure: env.RUNTIME_ENVIRONMENT === "prod",
|
||||
},
|
||||
},
|
||||
getUserAttributes: (attributes) => {
|
||||
return {
|
||||
id: attributes.id,
|
||||
username: attributes.username,
|
||||
displayName: attributes.displayName,
|
||||
email: attributes.email,
|
||||
role: attributes.role,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const validateRequest = cache(
|
||||
async (): Promise<{ user: RegisteredDatabaseUserAttributes; session: Session } | { user: null; session: null }> => {
|
||||
const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;
|
||||
if (!sessionId) {
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await lucia.validateSession(sessionId);
|
||||
// next.js throws when you attempt to set cookie when rendering page
|
||||
try {
|
||||
if (result.session?.fresh) {
|
||||
const sessionCookie = lucia.createSessionCookie(result.session.id);
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
}
|
||||
if (!result.session) {
|
||||
const sessionCookie = lucia.createBlankSessionCookie();
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return result as {
|
||||
user: RegisteredDatabaseUserAttributes;
|
||||
session: Session;
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
declare module "lucia" {
|
||||
interface Register {
|
||||
Lucia: typeof Lucia;
|
||||
DatabaseUserAttributes: {
|
||||
id: string;
|
||||
github_id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
};
|
||||
}
|
||||
}
|
13
packages/reservation-platform/src/server/auth/oauth.ts
Normal file
13
packages/reservation-platform/src/server/auth/oauth.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { env } from "@/utils/env";
|
||||
import { GitHub } from "arctic";
|
||||
|
||||
export const github = new GitHub(env.OAUTH.CLIENT_ID, env.OAUTH.CLIENT_SECRET, {
|
||||
enterpriseDomain: "https://git.i.mercedes-benz.com",
|
||||
});
|
||||
|
||||
export interface GitHubUserResult {
|
||||
id: number;
|
||||
login: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
28
packages/reservation-platform/src/server/auth/permissions.ts
Normal file
28
packages/reservation-platform/src/server/auth/permissions.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { RegisteredDatabaseUserAttributes } from "lucia";
|
||||
|
||||
export enum UserRole {
|
||||
ADMIN = "admin",
|
||||
USER = "user",
|
||||
GUEST = "guest",
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export function hasRole(user: RegisteredDatabaseUserAttributes | null | undefined, role: UserRole) {
|
||||
return user?.role === role;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export function translateUserRole(role: UserRole) {
|
||||
switch (role) {
|
||||
case UserRole.ADMIN:
|
||||
return "Administrator";
|
||||
case UserRole.USER:
|
||||
return "Benutzer";
|
||||
case UserRole.GUEST:
|
||||
return "Gast";
|
||||
}
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { env } from "@/env";
|
||||
|
||||
const createPrismaClient = () =>
|
||||
new PrismaClient({
|
||||
log:
|
||||
env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
|
||||
});
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: ReturnType<typeof createPrismaClient> | undefined;
|
||||
};
|
||||
|
||||
export const db = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
if (env.NODE_ENV !== "production") globalForPrisma.prisma = db;
|
7
packages/reservation-platform/src/server/db/index.ts
Normal file
7
packages/reservation-platform/src/server/db/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { env } from "@/utils/env";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import * as schema from "@/server/db/schema";
|
||||
|
||||
const sqlite = new Database(env.DB_PATH);
|
||||
export const db = drizzle(sqlite, { schema });
|
4
packages/reservation-platform/src/server/db/migrate.ts
Normal file
4
packages/reservation-platform/src/server/db/migrate.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { db } from "@/server/db";
|
||||
|
||||
migrate(db, { migrationsFolder: "./drizzle" });
|
78
packages/reservation-platform/src/server/db/schema.ts
Normal file
78
packages/reservation-platform/src/server/db/schema.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// MYP tables
|
||||
export const printers = sqliteTable("printer", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
name: text("name").notNull(),
|
||||
description: text("description").notNull(),
|
||||
status: integer("status").notNull().default(0),
|
||||
});
|
||||
export const printerRelations = relations(printers, ({ many }) => ({
|
||||
printJobs: many(printJobs),
|
||||
}));
|
||||
|
||||
export const printJobs = sqliteTable("printJob", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
printerId: text("printerId")
|
||||
.notNull()
|
||||
.references(() => printers.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
startAt: integer("startAt", { mode: "timestamp_ms" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
durationInMinutes: integer("durationInMinutes").notNull(),
|
||||
comments: text("comments"),
|
||||
aborted: integer("aborted", { mode: "boolean" }).notNull().default(false),
|
||||
abortReason: text("abortReason"),
|
||||
});
|
||||
export const printJobRelations = relations(printJobs, ({ one }) => ({
|
||||
printer: one(printers, {
|
||||
fields: [printJobs.printerId],
|
||||
references: [printers.id],
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [printJobs.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// Auth Tables
|
||||
export const users = sqliteTable("user", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
github_id: integer("github_id").notNull(),
|
||||
username: text("name"),
|
||||
displayName: text("displayName"),
|
||||
email: text("email").notNull(),
|
||||
role: text("role").default("guest"),
|
||||
});
|
||||
export const userRelations = relations(users, ({ many }) => ({
|
||||
printJobs: many(printJobs),
|
||||
sessions: many(sessions),
|
||||
}));
|
||||
|
||||
export const sessions = sqliteTable("session", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
});
|
||||
export const sessionRelations = relations(sessions, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [sessions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
@ -1,76 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
29
packages/reservation-platform/src/utils/drizzle.ts
Normal file
29
packages/reservation-platform/src/utils/drizzle.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type * as schema from "@/server/db/schema";
|
||||
import type { BuildQueryResult, DBQueryConfig, ExtractTablesWithRelations } from "drizzle-orm";
|
||||
|
||||
type Schema = typeof schema;
|
||||
type TSchema = ExtractTablesWithRelations<Schema>;
|
||||
|
||||
/**
|
||||
* Infer the relation type of a table.
|
||||
*/
|
||||
export type IncludeRelation<TableName extends keyof TSchema> = DBQueryConfig<
|
||||
"one" | "many",
|
||||
boolean,
|
||||
TSchema,
|
||||
TSchema[TableName]
|
||||
>["with"];
|
||||
|
||||
/**
|
||||
* Infer the result type of a query with optional relations.
|
||||
*/
|
||||
export type InferResultType<
|
||||
TableName extends keyof TSchema,
|
||||
With extends IncludeRelation<TableName> | undefined = undefined,
|
||||
> = BuildQueryResult<
|
||||
TSchema,
|
||||
TSchema[TableName],
|
||||
{
|
||||
with: With;
|
||||
}
|
||||
>;
|
13
packages/reservation-platform/src/utils/env.ts
Normal file
13
packages/reservation-platform/src/utils/env.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Environment variables
|
||||
*/
|
||||
export const env = {
|
||||
RUNTIME_ENVIRONMENT: z.enum(["prod", "dev"]).parse(process.env.RUNTIME_ENVIRONMENT),
|
||||
DB_PATH: "db/sqlite.db", // As drizzle-kit currently can't load env variables, use a hardcoded value
|
||||
OAUTH: {
|
||||
CLIENT_ID: z.string().parse(process.env.OAUTH_CLIENT_ID),
|
||||
CLIENT_SECRET: z.string().parse(process.env.OAUTH_CLIENT_SECRET),
|
||||
},
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user