This commit is contained in:
xCyanGrizzly
2026-02-18 14:26:36 +01:00
commit 3a5726e82b
167 changed files with 104081 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal, Pencil, Archive, Trash2 } from "lucide-react";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { StatusBadge } from "@/components/shared/status-badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export interface LocationRow {
id: string;
name: string;
description: string | null;
archived: boolean;
createdAt: Date;
_count: { filaments: number; resins: number; paints: number };
}
interface LocationColumnsProps {
onEdit: (location: LocationRow) => void;
onArchive: (id: string) => void;
onDelete: (id: string) => void;
}
export function getLocationColumns({
onEdit,
onArchive,
onDelete,
}: LocationColumnsProps): ColumnDef<LocationRow, unknown>[] {
return [
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="font-medium">{row.original.name}</span>
{row.original.archived && <StatusBadge variant="archived" />}
</div>
),
enableHiding: false,
},
{
accessorKey: "description",
header: ({ column }) => <DataTableColumnHeader column={column} title="Description" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground truncate max-w-[300px] block">
{row.original.description || "\u2014"}
</span>
),
},
{
id: "items",
header: "Items",
cell: ({ row }) => {
const c = row.original._count;
const total = c.filaments + c.resins + c.paints;
return (
<span className="text-sm text-muted-foreground">{total}</span>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Created" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{new Date(row.original.createdAt).toLocaleDateString()}
</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onArchive(row.original.id)}>
<Archive className="mr-2 h-3.5 w-3.5" />
{row.original.archived ? "Unarchive" : "Archive"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(row.original.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
enableHiding: false,
},
];
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { locationSchema, type LocationInput } from "@/schemas/location.schema";
import { createLocation, updateLocation } from "../actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
interface LocationFormProps {
location?: { id: string; name: string; description: string | null };
onSuccess: () => void;
}
export function LocationForm({ location, onSuccess }: LocationFormProps) {
const [isPending, startTransition] = useTransition();
const isEditing = !!location;
const form = useForm<LocationInput>({
resolver: zodResolver(locationSchema),
defaultValues: {
name: location?.name ?? "",
description: location?.description ?? "",
},
});
function onSubmit(values: LocationInput) {
startTransition(async () => {
const result = isEditing
? await updateLocation(location!.id, values)
: await createLocation(values);
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(isEditing ? "Location updated" : "Location created");
form.reset();
onSuccess();
});
}
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="Location name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="Optional description" rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { LocationForm } from "./location-form";
interface LocationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
location?: { id: string; name: string; description: string | null };
}
export function LocationModal({ open, onOpenChange, location }: LocationModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{location ? "Edit Location" : "Add Location"}</DialogTitle>
<DialogDescription>
{location
? "Update the location details below."
: "Add a new storage location."}
</DialogDescription>
</DialogHeader>
<LocationForm location={location} onSuccess={() => onOpenChange(false)} />
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Plus, Search } from "lucide-react";
import { toast } from "sonner";
import { useDataTable } from "@/hooks/use-data-table";
import { getLocationColumns, type LocationRow } from "./location-columns";
import { LocationModal } from "./location-modal";
import { deleteLocation, archiveLocation } from "../actions";
import { DataTable } from "@/components/shared/data-table";
import { DataTablePagination } from "@/components/shared/data-table-pagination";
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
import { DeleteDialog } from "@/components/shared/delete-dialog";
import { PageHeader } from "@/components/shared/page-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface LocationTableProps {
data: LocationRow[];
pageCount: number;
totalCount: number;
}
export function LocationTable({ data, pageCount, totalCount }: LocationTableProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [modalOpen, setModalOpen] = useState(false);
const [editLocation, setEditLocation] = useState<LocationRow | undefined>();
const [deleteId, setDeleteId] = useState<string | null>(null);
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
const updateSearch = (value: string) => {
setSearchValue(value);
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("search", value);
params.set("page", "1");
} else {
params.delete("search");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
const columns = getLocationColumns({
onEdit: (location) => {
setEditLocation(location);
setModalOpen(true);
},
onArchive: (id) => {
startTransition(async () => {
const result = await archiveLocation(id);
if (result.success) toast.success("Location updated");
else toast.error(result.error);
});
},
onDelete: (id) => setDeleteId(id),
});
const { table } = useDataTable({ data, columns, pageCount });
const handleDelete = () => {
if (!deleteId) return;
startTransition(async () => {
const result = await deleteLocation(deleteId);
if (result.success) {
toast.success("Location deleted");
setDeleteId(null);
} else {
toast.error(result.error);
}
});
};
return (
<div className="space-y-4">
<PageHeader title="Locations" description="Manage your storage locations">
<Button
onClick={() => {
setEditLocation(undefined);
setModalOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add Location
</Button>
</PageHeader>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search locations..."
value={searchValue}
onChange={(e) => updateSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
<DataTableViewOptions table={table} />
</div>
<DataTable table={table} emptyMessage="No locations found. Add your first location!" />
<DataTablePagination table={table} totalCount={totalCount} />
<LocationModal
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) setEditLocation(undefined);
}}
location={editLocation}
/>
<DeleteDialog
open={!!deleteId}
onOpenChange={(open) => !open && setDeleteId(null)}
title="Delete Location"
description="This will permanently delete this location. Items stored here will be unlinked."
onConfirm={handleDelete}
isLoading={isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { locationSchema } from "@/schemas/location.schema";
import { revalidatePath } from "next/cache";
import type { ActionResult } from "@/types/api.types";
export async function createLocation(input: unknown): Promise<ActionResult<{ id: string }>> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = locationSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Validation failed" };
}
try {
const location = await prisma.location.create({
data: {
...parsed.data,
description: parsed.data.description || null,
userId: session.user.id,
},
});
revalidatePath("/locations");
return { success: true, data: { id: location.id } };
} catch {
return { success: false, error: "Failed to create location" };
}
}
export async function updateLocation(id: string, input: unknown): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = locationSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Validation failed" };
}
const existing = await prisma.location.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.location.update({
where: { id },
data: {
...parsed.data,
description: parsed.data.description || null,
},
});
revalidatePath("/locations");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to update location" };
}
}
export async function deleteLocation(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.location.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.location.delete({ where: { id } });
revalidatePath("/locations");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to delete location" };
}
}
export async function archiveLocation(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.location.findFirst({ where: { id, userId: session.user.id } });
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.location.update({
where: { id },
data: { archived: !existing.archived },
});
revalidatePath("/locations");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to archive location" };
}
}

View File

@@ -0,0 +1,30 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function LocationsLoading() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-8 w-40" />
<Skeleton className="mt-1 h-4 w-60" />
</div>
<Skeleton className="h-9 w-32" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-9 w-24" />
</div>
<div className="rounded-md border">
<div className="h-10 border-b bg-muted/50" />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 border-b px-4 py-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getLocations } from "@/data/location.queries";
import { LocationTable } from "./_components/location-table";
interface LocationsPageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function LocationsPage({ searchParams }: LocationsPageProps) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const params = await searchParams;
const { data, pageCount, totalCount } = await getLocations(session.user.id, {
page: typeof params.page === "string" ? params.page : "1",
perPage: typeof params.perPage === "string" ? params.perPage : "20",
sort: typeof params.sort === "string" ? params.sort : undefined,
order: typeof params.order === "string" ? (params.order as "asc" | "desc") : undefined,
search: typeof params.search === "string" ? params.search : undefined,
});
return (
<LocationTable
data={JSON.parse(JSON.stringify(data))}
pageCount={pageCount}
totalCount={totalCount}
/>
);
}