Преглед изворни кода

feat(db): add layout configuration models and enable database connection

- Added User, LayoutConfiguration, LayoutSection, and LayoutSectionField models
- Enabled DATABASE_URL in .env for PostgreSQL connection
- Updated Prisma to 6.12.0 and added @prisma/extension-accelerate
- Added tsx dev dependency for TypeScript execution
- Added navigation link for layout configurations in header
vtugulan пре 6 месеци
родитељ
комит
3d1f510cd2

+ 1 - 1
.env

@@ -1,5 +1,5 @@
 # PostgreSQL Database Configuration
-#DATABASE_URL="postgresql://postgres:postgres@localhost:5432/vtorio?schema=public"
+DATABASE_URL="postgresql://postgres:postgres@localhost:5432/vtorio?schema=public"
 
 # Project Root Path (for legacy file system operations)
 ROOT_PATH="c:/Source/gogs/vtorio"

+ 133 - 0
app/actions/layout-configurations.ts

@@ -0,0 +1,133 @@
+"use server";
+
+import { prisma } from "@/lib/prisma";
+
+// Layout Configuration Actions
+export async function getLayoutConfigurations() {
+  try {
+    const configurations = await prisma.layoutConfiguration.findMany({
+      include: {
+        sections: {
+          include: {
+            fields: true,
+          },
+        },
+      },
+      orderBy: {
+        createdAt: "desc",
+      },
+    });
+    return { success: true, data: configurations };
+  } catch (error) {
+    console.error("Error fetching layout configurations:", error);
+    return { success: false, error: "Failed to fetch layout configurations" };
+  }
+}
+
+export async function getLayoutConfiguration(id: number) {
+  try {
+    const configuration = await prisma.layoutConfiguration.findUnique({
+      where: { id },
+      include: {
+        sections: {
+          include: {
+            fields: true,
+          },
+          orderBy: {
+            createdAt: "asc",
+          },
+        },
+      },
+    });
+    
+    if (!configuration) {
+      return { success: false, error: "Layout configuration not found" };
+    }
+    
+    return { success: true, data: configuration };
+  } catch (error) {
+    console.error("Error fetching layout configuration:", error);
+    return { success: false, error: "Failed to fetch layout configuration" };
+  }
+}
+
+export async function getLayoutSections(configurationId: number) {
+  try {
+    const sections = await prisma.layoutSection.findMany({
+      where: { configurationId },
+      include: {
+        fields: {
+          orderBy: {
+            importColumnOrderNumber: "asc",
+          },
+        },
+      },
+      orderBy: {
+        createdAt: "asc",
+      },
+    });
+    return { success: true, data: sections };
+  } catch (error) {
+    console.error("Error fetching layout sections:", error);
+    return { success: false, error: "Failed to fetch layout sections" };
+  }
+}
+
+export async function getLayoutSectionFields(sectionId: number) {
+  try {
+    const fields = await prisma.layoutSectionField.findMany({
+      where: { layoutSectionId: sectionId },
+      orderBy: {
+        importColumnOrderNumber: "asc",
+      },
+    });
+    return { success: true, data: fields };
+  } catch (error) {
+    console.error("Error fetching layout section fields:", error);
+    return { success: false, error: "Failed to fetch layout section fields" };
+  }
+}
+
+// Create new layout configuration
+export async function createLayoutConfiguration(name: string) {
+  try {
+    const configuration = await prisma.layoutConfiguration.create({
+      data: {
+        name,
+      },
+    });
+    return { success: true, data: configuration };
+  } catch (error) {
+    console.error("Error creating layout configuration:", error);
+    return { success: false, error: "Failed to create layout configuration" };
+  }
+}
+
+// Update layout configuration
+export async function updateLayoutConfiguration(id: number, name: string) {
+  try {
+    const configuration = await prisma.layoutConfiguration.update({
+      where: { id },
+      data: {
+        name,
+      },
+    });
+    return { success: true, data: configuration };
+  } catch (error) {
+    console.error("Error updating layout configuration:", error);
+    return { success: false, error: "Failed to update layout configuration" };
+  }
+}
+
+// Delete layout configuration
+export async function deleteLayoutConfiguration(id: number) {
+  try {
+    await prisma.layoutConfiguration.delete({
+      where: { id },
+    });
+    return { success: true };
+  } catch (error) {
+    console.error("Error deleting layout configuration:", error);
+    return { success: false, error: "Failed to delete layout configuration" };
+  }
+}

+ 6 - 0
app/components/header.tsx

@@ -27,6 +27,12 @@ const navigationItems = [
     description: "Browse and manage your files",
     icon: Folder,
   },
+  {
+    title: "Layout Configurations",
+    href: "/layout-configurations",
+    description: "Manage Excel layout configurations",
+    icon: FileText,
+  },
   {
     title: "API Docs",
     href: "/api-docs",

+ 96 - 0
app/components/layout-configurations/CreateLayoutConfigurationDialog.tsx

@@ -0,0 +1,96 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { createLayoutConfiguration } from "@/app/actions/layout-configurations";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { toast } from "@/hooks/use-toast";
+import { Plus } from "lucide-react";
+
+export function CreateLayoutConfigurationDialog() {
+  const [open, setOpen] = useState(false);
+  const [name, setName] = useState("");
+  const [loading, setLoading] = useState(false);
+  const router = useRouter();
+
+  async function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    if (!name.trim()) {
+      toast({
+        title: "Error",
+        description: "Please enter a configuration name",
+        variant: "destructive",
+      });
+      return;
+    }
+
+    setLoading(true);
+    try {
+      const result = await createLayoutConfiguration(name.trim());
+      if (result.success) {
+        toast({
+          title: "Success",
+          description: "Layout configuration created successfully",
+        });
+        setOpen(false);
+        setName("");
+        router.refresh();
+      } else {
+        toast({
+          title: "Error",
+          description: result.error,
+          variant: "destructive",
+        });
+      }
+    } catch (error) {
+      toast({
+        title: "Error",
+        description: "Failed to create layout configuration",
+        variant: "destructive",
+      });
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={setOpen}>
+      <DialogTrigger asChild>
+        <Button>
+          <Plus className="mr-2 h-4 w-4" />
+          Create Configuration
+        </Button>
+      </DialogTrigger>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>Create New Layout Configuration</DialogTitle>
+          <DialogDescription>
+            Enter a name for your new layout configuration.
+          </DialogDescription>
+        </DialogHeader>
+        <form onSubmit={handleSubmit}>
+          <div className="grid gap-4 py-4">
+            <div className="grid gap-2">
+              <Label htmlFor="name">Configuration Name</Label>
+              <Input
+                id="name"
+                value={name}
+                onChange={(e) => setName(e.target.value)}
+                placeholder="Enter configuration name"
+                disabled={loading}
+              />
+            </div>
+          </div>
+          <DialogFooter>
+            <Button type="submit" disabled={loading}>
+              {loading ? "Creating..." : "Create"}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 119 - 0
app/components/layout-configurations/LayoutConfigurationDetail.tsx

@@ -0,0 +1,119 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { LayoutSectionCard } from "./LayoutSectionCard";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { ArrowLeft, Plus } from "lucide-react";
+
+interface LayoutConfiguration {
+  id: number;
+  name: string;
+  sections: LayoutSection[];
+  createdAt: string;
+  updatedAt: string;
+}
+
+interface LayoutSection {
+  id: number;
+  name: string;
+  type: string;
+  sheetName: string;
+  startingRow?: number;
+  endingRow?: number;
+  tableName: string;
+  fields: LayoutSectionField[];
+}
+
+interface LayoutSectionField {
+  id: number;
+  name: string;
+  dataType: string;
+  cellPosition: string;
+  importTableColumnName: string;
+  importColumnOrderNumber: number;
+}
+
+interface LayoutConfigurationDetailProps {
+  configuration: LayoutConfiguration;
+}
+
+export function LayoutConfigurationDetail({ configuration }: LayoutConfigurationDetailProps) {
+  const router = useRouter();
+
+  return (
+    <div className="space-y-6">
+      <div className="flex items-center gap-4">
+        <Button
+          variant="ghost"
+          size="sm"
+          onClick={() => router.push("/layout-configurations")}
+        >
+          <ArrowLeft className="h-4 w-4 mr-2" />
+          Back to Configurations
+        </Button>
+      </div>
+
+      <Card>
+        <CardHeader>
+          <div className="flex justify-between items-start">
+            <div>
+              <CardTitle>Configuration Details</CardTitle>
+              <CardDescription>
+                Overview of the layout configuration and its sections
+              </CardDescription>
+            </div>
+            <div className="flex gap-2">
+              <Badge variant="secondary">
+                {configuration.sections.length} sections
+              </Badge>
+              <Badge variant="outline">
+                {configuration.sections.reduce((total, section) => total + section.fields.length, 0)} fields
+              </Badge>
+            </div>
+          </div>
+        </CardHeader>
+        <CardContent>
+          <div className="grid gap-4">
+            <div>
+              <span className="font-medium">Name:</span> {configuration.name}
+            </div>
+            <div>
+              <span className="font-medium">Created:</span> {new Date(configuration.createdAt).toLocaleDateString()}
+            </div>
+            <div>
+              <span className="font-medium">Last Updated:</span> {new Date(configuration.updatedAt).toLocaleDateString()}
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+
+      <div className="space-y-4">
+        <div className="flex justify-between items-center">
+          <h2 className="text-2xl font-semibold">Sections</h2>
+          <Button size="sm">
+            <Plus className="h-4 w-4 mr-2" />
+            Add Section
+          </Button>
+        </div>
+
+        {configuration.sections.length === 0 ? (
+          <Card>
+            <CardContent className="pt-6">
+              <p className="text-center text-muted-foreground">
+                No sections found for this configuration
+              </p>
+            </CardContent>
+          </Card>
+        ) : (
+          <div className="grid gap-4">
+            {configuration.sections.map((section) => (
+              <LayoutSectionCard key={section.id} section={section} />
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 188 - 0
app/components/layout-configurations/LayoutConfigurationsTable.tsx

@@ -0,0 +1,188 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { getLayoutConfigurations, deleteLayoutConfiguration } from "@/app/actions/layout-configurations";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
+import { toast } from "@/hooks/use-toast";
+import { Trash2, Eye, Edit } from "lucide-react";
+
+interface LayoutConfiguration {
+  id: number;
+  name: string;
+  sections: LayoutSection[];
+  createdAt: string;
+  updatedAt: string;
+}
+
+interface LayoutSection {
+  id: number;
+  name: string;
+  type: string;
+  sheetName: string;
+  fields: LayoutSectionField[];
+}
+
+interface LayoutSectionField {
+  id: number;
+  name: string;
+  dataType: string;
+  cellPosition: string;
+}
+
+export function LayoutConfigurationsTable() {
+  const [configurations, setConfigurations] = useState<LayoutConfiguration[]>([]);
+  const [loading, setLoading] = useState(true);
+  const router = useRouter();
+
+  useEffect(() => {
+    loadConfigurations();
+  }, []);
+
+  async function loadConfigurations() {
+    try {
+      const result = await getLayoutConfigurations();
+      if (result.success) {
+        setConfigurations(result.data || []);
+      } else {
+        toast({
+          title: "Error",
+          description: result.error,
+          variant: "destructive",
+        });
+      }
+    } catch (error) {
+      toast({
+        title: "Error",
+        description: "Failed to load configurations",
+        variant: "destructive",
+      });
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  async function handleDelete(id: number) {
+    try {
+      const result = await deleteLayoutConfiguration(id);
+      if (result.success) {
+        toast({
+          title: "Success",
+          description: "Configuration deleted successfully",
+        });
+        loadConfigurations();
+      } else {
+        toast({
+          title: "Error",
+          description: result.error,
+          variant: "destructive",
+        });
+      }
+    } catch (error) {
+      toast({
+        title: "Error",
+        description: "Failed to delete configuration",
+        variant: "destructive",
+      });
+    }
+  }
+
+  if (loading) {
+    return (
+      <div className="p-4 space-y-4">
+        {[...Array(3)].map((_, i) => (
+          <Card key={i}>
+            <CardHeader>
+              <Skeleton className="h-6 w-48" />
+              <Skeleton className="h-4 w-32" />
+            </CardHeader>
+            <CardContent>
+              <Skeleton className="h-4 w-full" />
+            </CardContent>
+          </Card>
+        ))}
+      </div>
+    );
+  }
+
+  if (configurations.length === 0) {
+    return (
+      <div className="p-8 text-center">
+        <p className="text-muted-foreground">No layout configurations found</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="p-4 space-y-4">
+      {configurations.map((config) => (
+        <Card key={config.id} className="hover:shadow-md transition-shadow">
+          <CardHeader>
+            <div className="flex justify-between items-start">
+              <div>
+                <CardTitle className="text-xl">{config.name}</CardTitle>
+                <CardDescription>
+                  Created: {new Date(config.createdAt).toLocaleDateString()}
+                </CardDescription>
+              </div>
+              <div className="flex gap-2">
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => router.push(`/layout-configurations/${config.id}`)}
+                >
+                  <Eye className="h-4 w-4" />
+                </Button>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => router.push(`/layout-configurations/${config.id}/edit`)}
+                >
+                  <Edit className="h-4 w-4" />
+                </Button>
+                <AlertDialog>
+                  <AlertDialogTrigger asChild>
+                    <Button variant="ghost" size="sm" className="text-destructive">
+                      <Trash2 className="h-4 w-4" />
+                    </Button>
+                  </AlertDialogTrigger>
+                  <AlertDialogContent>
+                    <AlertDialogHeader>
+                      <AlertDialogTitle>Delete Configuration</AlertDialogTitle>
+                      <AlertDialogDescription>
+                        Are you sure you want to delete "{config.name}"? This action cannot be undone.
+                      </AlertDialogDescription>
+                    </AlertDialogHeader>
+                    <AlertDialogFooter>
+                      <AlertDialogCancel>Cancel</AlertDialogCancel>
+                      <AlertDialogAction
+                        onClick={() => handleDelete(config.id)}
+                        className="bg-destructive text-destructive-foreground"
+                      >
+                        Delete
+                      </AlertDialogAction>
+                    </AlertDialogFooter>
+                  </AlertDialogContent>
+                </AlertDialog>
+              </div>
+            </div>
+          </CardHeader>
+          <CardContent>
+            <div className="flex items-center gap-4">
+              <Badge variant="secondary">
+                {config.sections.length} sections
+              </Badge>
+              <Badge variant="outline">
+                {config.sections.reduce((total, section) => total + section.fields.length, 0)} fields
+              </Badge>
+            </div>
+          </CardContent>
+        </Card>
+      ))}
+    </div>
+  );
+}

+ 109 - 0
app/components/layout-configurations/LayoutSectionCard.tsx

@@ -0,0 +1,109 @@
+"use client";
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Badge as BadgeIcon } from "lucide-react";
+
+interface LayoutSection {
+  id: number;
+  name: string;
+  type: string;
+  sheetName: string;
+  startingRow?: number;
+  endingRow?: number;
+  tableName: string;
+  fields: LayoutSectionField[];
+}
+
+interface LayoutSectionField {
+  id: number;
+  name: string;
+  dataType: string;
+  cellPosition: string;
+  importTableColumnName: string;
+  importColumnOrderNumber: number;
+}
+
+interface LayoutSectionCardProps {
+  section: LayoutSection;
+}
+
+export function LayoutSectionCard({ section }: LayoutSectionCardProps) {
+  const getTypeColor = (type: string) => {
+    switch (type.toLowerCase()) {
+      case "properties":
+        return "bg-blue-100 text-blue-800";
+      case "grid":
+        return "bg-green-100 text-green-800";
+      default:
+        return "bg-gray-100 text-gray-800";
+    }
+  };
+
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex justify-between items-start">
+          <div>
+            <CardTitle className="text-lg">{section.name}</CardTitle>
+            <CardDescription>
+              Sheet: {section.sheetName} | Table: {section.tableName}
+            </CardDescription>
+          </div>
+          <Badge className={getTypeColor(section.type)}>
+            {section.type}
+          </Badge>
+        </div>
+      </CardHeader>
+      <CardContent>
+        <div className="space-y-4">
+          <div className="flex gap-4 text-sm">
+            {section.startingRow && (
+              <span>
+                <strong>Rows:</strong> {section.startingRow} - {section.endingRow || "end"}
+              </span>
+            )}
+            <span>
+              <strong>Fields:</strong> {section.fields.length}
+            </span>
+          </div>
+
+          {section.fields.length > 0 && (
+            <div>
+              <h4 className="font-medium mb-2">Fields</h4>
+              <div className="overflow-x-auto">
+                <Table>
+                  <TableHeader>
+                    <TableRow>
+                      <TableHead>Name</TableHead>
+                      <TableHead>Type</TableHead>
+                      <TableHead>Position</TableHead>
+                      <TableHead>Column</TableHead>
+                      <TableHead>Order</TableHead>
+                    </TableRow>
+                  </TableHeader>
+                  <TableBody>
+                    {section.fields.map((field) => (
+                      <TableRow key={field.id}>
+                        <TableCell className="font-medium">{field.name}</TableCell>
+                        <TableCell>
+                          <Badge variant="outline" className="text-xs">
+                            {field.dataType}
+                          </Badge>
+                        </TableCell>
+                        <TableCell>{field.cellPosition}</TableCell>
+                        <TableCell>{field.importTableColumnName}</TableCell>
+                        <TableCell>{field.importColumnOrderNumber}</TableCell>
+                      </TableRow>
+                    ))}
+                  </TableBody>
+                </Table>
+              </div>
+            </div>
+          )}
+        </div>
+      </CardContent>
+    </Card>
+  );
+}

+ 53 - 0
app/layout-configurations/[id]/page.tsx

@@ -0,0 +1,53 @@
+import { Suspense } from "react";
+import { notFound } from "next/navigation";
+import { getLayoutConfiguration } from "@/app/actions/layout-configurations";
+import { LayoutConfigurationDetail } from "@/app/components/layout-configurations/LayoutConfigurationDetail";
+import { Skeleton } from "@/components/ui/skeleton";
+
+interface PageProps {
+  params: {
+    id: string;
+  };
+}
+
+export default async function LayoutConfigurationDetailPage({ params }: PageProps) {
+  const p = await params;
+  const id = parseInt(p.id);
+  
+  if (isNaN(id)) {
+    notFound();
+  }
+
+  const result = await getLayoutConfiguration(id);
+  
+  if (!result.success || !result.data) {
+    notFound();
+  }
+
+  const configuration = result.data;
+
+  return (
+    <div className="container mx-auto py-8 px-4">
+      <div className="mb-6">
+        <h1 className="text-3xl font-bold">{configuration.name}</h1>
+        <p className="text-muted-foreground">
+          Created: {new Date(configuration.createdAt).toLocaleDateString()}
+        </p>
+      </div>
+
+      <Suspense fallback={<LayoutConfigurationDetailSkeleton />}>
+        <LayoutConfigurationDetail configuration={configuration} />
+      </Suspense>
+    </div>
+  );
+}
+
+function LayoutConfigurationDetailSkeleton() {
+  return (
+    <div className="space-y-6">
+      <Skeleton className="h-8 w-64" />
+      <Skeleton className="h-4 w-32" />
+      <Skeleton className="h-32 w-full" />
+    </div>
+  );
+}

+ 33 - 0
app/layout-configurations/page.tsx

@@ -0,0 +1,33 @@
+import { Suspense } from "react";
+import { LayoutConfigurationsTable } from "@/app/components/layout-configurations/LayoutConfigurationsTable";
+import { CreateLayoutConfigurationDialog } from "@/app/components/layout-configurations/CreateLayoutConfigurationDialog";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function LayoutConfigurationsPage() {
+  return (
+    <div className="container mx-auto py-8 px-4">
+      <div className="flex justify-between items-center mb-6">
+        <h1 className="text-3xl font-bold">Layout Configurations</h1>
+        <CreateLayoutConfigurationDialog />
+      </div>
+      
+      <div className="bg-white rounded-lg shadow">
+        <Suspense fallback={<LayoutConfigurationsSkeleton />}>
+          <LayoutConfigurationsTable />
+        </Suspense>
+      </div>
+    </div>
+  );
+}
+
+function LayoutConfigurationsSkeleton() {
+  return (
+    <div className="p-4 space-y-4">
+      {[...Array(5)].map((_, i) => (
+        <div key={i} className="flex items-center space-x-4">
+          <Skeleton className="h-12 w-full" />
+        </div>
+      ))}
+    </div>
+  );
+}

+ 122 - 0
components/ui/dialog.tsx

@@ -0,0 +1,122 @@
+"use client"
+
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+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">
+        <X 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,
+  DialogClose,
+  DialogTrigger,
+  DialogContent,
+  DialogHeader,
+  DialogFooter,
+  DialogTitle,
+  DialogDescription,
+}

+ 9 - 0
lib/prisma.ts

@@ -0,0 +1,9 @@
+import { PrismaClient } from '@prisma/client'
+
+const globalForPrisma = globalThis as unknown as {
+  prisma: PrismaClient | undefined
+}
+
+export const prisma = globalForPrisma.prisma ?? new PrismaClient()
+
+if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

+ 561 - 36
package-lock.json

@@ -10,7 +10,8 @@
       "dependencies": {
         "@hookform/resolvers": "^5.1.1",
         "@kinde-oss/kinde-auth-nextjs": "^2.8.3",
-        "@prisma/client": "^6.11.1",
+        "@prisma/client": "^6.12.0",
+        "@prisma/extension-accelerate": "^2.0.2",
         "@radix-ui/react-alert-dialog": "^1.1.14",
         "@radix-ui/react-avatar": "^1.1.10",
         "@radix-ui/react-checkbox": "^1.3.2",
@@ -31,7 +32,6 @@
         "lucide-react": "^0.525.0",
         "next": "^15.4.1",
         "pg": "^8.16.3",
-        "prisma": "^6.11.1",
         "react": "^19.0.0",
         "react-dom": "^19.0.0",
         "react-hook-form": "^7.60.0",
@@ -47,7 +47,9 @@
         "eslint": "^9",
         "eslint-config-next": "15.1.6",
         "postcss": "^8",
+        "prisma": "^6.12.0",
         "tailwindcss": "^3.4.1",
+        "tsx": "^4.20.3",
         "typescript": "^5"
       }
     },
@@ -284,6 +286,448 @@
         "tslib": "^2.4.0"
       }
     },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
+      "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz",
+      "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz",
+      "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz",
+      "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz",
+      "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz",
+      "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz",
+      "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz",
+      "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz",
+      "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz",
+      "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz",
+      "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz",
+      "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz",
+      "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz",
+      "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz",
+      "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz",
+      "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz",
+      "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz",
+      "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz",
+      "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz",
+      "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz",
+      "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz",
+      "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz",
+      "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz",
+      "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz",
+      "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz",
+      "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@eslint-community/eslint-utils": {
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
@@ -1550,9 +1994,9 @@
       }
     },
     "node_modules/@prisma/client": {
-      "version": "6.11.1",
-      "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.11.1.tgz",
-      "integrity": "sha512-5CLFh8QP6KxRm83pJ84jaVCeSVPQr8k0L2SEtOJHwdkS57/VQDcI/wQpGmdyOZi+D9gdNabdo8tj1Uk+w+upsQ==",
+      "version": "6.12.0",
+      "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.12.0.tgz",
+      "integrity": "sha512-wn98bJ3Cj6edlF4jjpgXwbnQIo/fQLqqQHPk2POrZPxTlhY3+n90SSIF3LMRVa8VzRFC/Gec3YKJRxRu+AIGVA==",
       "hasInstallScript": true,
       "license": "Apache-2.0",
       "engines": {
@@ -1572,9 +2016,10 @@
       }
     },
     "node_modules/@prisma/config": {
-      "version": "6.11.1",
-      "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.11.1.tgz",
-      "integrity": "sha512-z6rCTQN741wxDq82cpdzx2uVykpnQIXalLhrWQSR0jlBVOxCIkz3HZnd8ern3uYTcWKfB3IpVAF7K2FU8t/8AQ==",
+      "version": "6.12.0",
+      "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.12.0.tgz",
+      "integrity": "sha512-HovZWzhWEMedHxmjefQBRZa40P81N7/+74khKFz9e1AFjakcIQdXgMWKgt20HaACzY+d1LRBC+L4tiz71t9fkg==",
+      "devOptional": true,
       "license": "Apache-2.0",
       "dependencies": {
         "jiti": "2.4.2"
@@ -1584,54 +2029,71 @@
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
       "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
+      "devOptional": true,
       "license": "MIT",
       "bin": {
         "jiti": "lib/jiti-cli.mjs"
       }
     },
     "node_modules/@prisma/debug": {
-      "version": "6.11.1",
-      "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.11.1.tgz",
-      "integrity": "sha512-lWRb/YSWu8l4Yum1UXfGLtqFzZkVS2ygkWYpgkbgMHn9XJlMITIgeMvJyX5GepChzhmxuSuiq/MY/kGFweOpGw==",
+      "version": "6.12.0",
+      "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.12.0.tgz",
+      "integrity": "sha512-plbz6z72orcqr0eeio7zgUrZj5EudZUpAeWkFTA/DDdXEj28YHDXuiakvR6S7sD6tZi+jiwQEJAPeV6J6m/tEQ==",
+      "devOptional": true,
       "license": "Apache-2.0"
     },
     "node_modules/@prisma/engines": {
-      "version": "6.11.1",
-      "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.11.1.tgz",
-      "integrity": "sha512-6eKEcV6V8W2eZAUwX2xTktxqPM4vnx3sxz3SDtpZwjHKpC6lhOtc4vtAtFUuf5+eEqBk+dbJ9Dcaj6uQU+FNNg==",
+      "version": "6.12.0",
+      "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.12.0.tgz",
+      "integrity": "sha512-4BRZZUaAuB4p0XhTauxelvFs7IllhPmNLvmla0bO1nkECs8n/o1pUvAVbQ/VOrZR5DnF4HED0PrGai+rIOVePA==",
+      "devOptional": true,
       "hasInstallScript": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@prisma/debug": "6.11.1",
-        "@prisma/engines-version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9",
-        "@prisma/fetch-engine": "6.11.1",
-        "@prisma/get-platform": "6.11.1"
+        "@prisma/debug": "6.12.0",
+        "@prisma/engines-version": "6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc",
+        "@prisma/fetch-engine": "6.12.0",
+        "@prisma/get-platform": "6.12.0"
       }
     },
     "node_modules/@prisma/engines-version": {
-      "version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9",
-      "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9.tgz",
-      "integrity": "sha512-swFJTOOg4tHyOM1zB/pHb3MeH0i6t7jFKn5l+ZsB23d9AQACuIRo9MouvuKGvnDogzkcjbWnXi/NvOZ0+n5Jfw==",
+      "version": "6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc",
+      "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc.tgz",
+      "integrity": "sha512-70vhecxBJlRr06VfahDzk9ow4k1HIaSfVUT3X0/kZoHCMl9zbabut4gEXAyzJZxaCGi5igAA7SyyfBI//mmkbQ==",
+      "devOptional": true,
       "license": "Apache-2.0"
     },
+    "node_modules/@prisma/extension-accelerate": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/@prisma/extension-accelerate/-/extension-accelerate-2.0.2.tgz",
+      "integrity": "sha512-yZK6/k7uOEFpEsKoZezQS1CKDboPtBCQ0NyI70e1Un8tDiRgg80iWGyjsJmRpps2ZIut3MroHP+dyR3wVKh8lA==",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@prisma/client": ">=4.16.1"
+      }
+    },
     "node_modules/@prisma/fetch-engine": {
-      "version": "6.11.1",
-      "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.11.1.tgz",
-      "integrity": "sha512-NBYzmkXTkj9+LxNPRSndaAeALOL1Gr3tjvgRYNqruIPlZ6/ixLeuE/5boYOewant58tnaYFZ5Ne0jFBPfGXHpQ==",
+      "version": "6.12.0",
+      "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.12.0.tgz",
+      "integrity": "sha512-EamoiwrK46rpWaEbLX9aqKDPOd8IyLnZAkiYXFNuq0YsU0Z8K09/rH8S7feOWAVJ3xzeSgcEJtBlVDrajM9Sag==",
+      "devOptional": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@prisma/debug": "6.11.1",
-        "@prisma/engines-version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9",
-        "@prisma/get-platform": "6.11.1"
+        "@prisma/debug": "6.12.0",
+        "@prisma/engines-version": "6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc",
+        "@prisma/get-platform": "6.12.0"
       }
     },
     "node_modules/@prisma/get-platform": {
-      "version": "6.11.1",
-      "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.11.1.tgz",
-      "integrity": "sha512-b2Z8oV2gwvdCkFemBTFd0x4lsL4O2jLSx8lB7D+XqoFALOQZPa7eAPE1NU0Mj1V8gPHRxIsHnyUNtw2i92psUw==",
+      "version": "6.12.0",
+      "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.12.0.tgz",
+      "integrity": "sha512-nRerTGhTlgyvcBlyWgt8OLNIV7QgJS2XYXMJD1hysorMCuLAjuDDuoxmVt7C2nLxbuxbWPp7OuFRHC23HqD9dA==",
+      "devOptional": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@prisma/debug": "6.11.1"
+        "@prisma/debug": "6.12.0"
       }
     },
     "node_modules/@radix-ui/number": {
@@ -5357,6 +5819,48 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/esbuild": {
+      "version": "0.25.8",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
+      "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.25.8",
+        "@esbuild/android-arm": "0.25.8",
+        "@esbuild/android-arm64": "0.25.8",
+        "@esbuild/android-x64": "0.25.8",
+        "@esbuild/darwin-arm64": "0.25.8",
+        "@esbuild/darwin-x64": "0.25.8",
+        "@esbuild/freebsd-arm64": "0.25.8",
+        "@esbuild/freebsd-x64": "0.25.8",
+        "@esbuild/linux-arm": "0.25.8",
+        "@esbuild/linux-arm64": "0.25.8",
+        "@esbuild/linux-ia32": "0.25.8",
+        "@esbuild/linux-loong64": "0.25.8",
+        "@esbuild/linux-mips64el": "0.25.8",
+        "@esbuild/linux-ppc64": "0.25.8",
+        "@esbuild/linux-riscv64": "0.25.8",
+        "@esbuild/linux-s390x": "0.25.8",
+        "@esbuild/linux-x64": "0.25.8",
+        "@esbuild/netbsd-arm64": "0.25.8",
+        "@esbuild/netbsd-x64": "0.25.8",
+        "@esbuild/openbsd-arm64": "0.25.8",
+        "@esbuild/openbsd-x64": "0.25.8",
+        "@esbuild/openharmony-arm64": "0.25.8",
+        "@esbuild/sunos-x64": "0.25.8",
+        "@esbuild/win32-arm64": "0.25.8",
+        "@esbuild/win32-ia32": "0.25.8",
+        "@esbuild/win32-x64": "0.25.8"
+      }
+    },
     "node_modules/escape-string-regexp": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -9189,14 +9693,15 @@
       }
     },
     "node_modules/prisma": {
-      "version": "6.11.1",
-      "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.11.1.tgz",
-      "integrity": "sha512-VzJToRlV0s9Vu2bfqHiRJw73hZNCG/AyJeX+kopbu4GATTjTUdEWUteO3p4BLYoHpMS4o8pD3v6tF44BHNZI1w==",
+      "version": "6.12.0",
+      "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.12.0.tgz",
+      "integrity": "sha512-pmV7NEqQej9WjizN6RSNIwf7Y+jeh9mY1JEX2WjGxJi4YZWexClhde1yz/FuvAM+cTwzchcMytu2m4I6wPkIzg==",
+      "devOptional": true,
       "hasInstallScript": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@prisma/config": "6.11.1",
-        "@prisma/engines": "6.11.1"
+        "@prisma/config": "6.12.0",
+        "@prisma/engines": "6.12.0"
       },
       "bin": {
         "prisma": "build/index.js"
@@ -10596,6 +11101,26 @@
       "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
       "license": "0BSD"
     },
+    "node_modules/tsx": {
+      "version": "4.20.3",
+      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
+      "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "~0.25.0",
+        "get-tsconfig": "^4.7.5"
+      },
+      "bin": {
+        "tsx": "dist/cli.mjs"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      }
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

+ 4 - 2
package.json

@@ -11,7 +11,8 @@
   "dependencies": {
     "@hookform/resolvers": "^5.1.1",
     "@kinde-oss/kinde-auth-nextjs": "^2.8.3",
-    "@prisma/client": "^6.11.1",
+    "@prisma/client": "^6.12.0",
+    "@prisma/extension-accelerate": "^2.0.2",
     "@radix-ui/react-alert-dialog": "^1.1.14",
     "@radix-ui/react-avatar": "^1.1.10",
     "@radix-ui/react-checkbox": "^1.3.2",
@@ -32,7 +33,6 @@
     "lucide-react": "^0.525.0",
     "next": "^15.4.1",
     "pg": "^8.16.3",
-    "prisma": "^6.11.1",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
     "react-hook-form": "^7.60.0",
@@ -48,7 +48,9 @@
     "eslint": "^9",
     "eslint-config-next": "15.1.6",
     "postcss": "^8",
+    "prisma": "^6.12.0",
     "tailwindcss": "^3.4.1",
+    "tsx": "^4.20.3",
     "typescript": "^5"
   }
 }

+ 59 - 0
prisma/migrations/20250720195820_add_layout_configurations/migration.sql

@@ -0,0 +1,59 @@
+-- CreateTable
+CREATE TABLE "users" (
+    "id" SERIAL NOT NULL,
+    "name" TEXT NOT NULL,
+    "username" TEXT NOT NULL,
+    "email" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "users_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "layout_configurations" (
+    "id" SERIAL NOT NULL,
+    "name" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "layout_configurations_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "layout_sections" (
+    "id" SERIAL NOT NULL,
+    "configurationId" INTEGER NOT NULL,
+    "name" TEXT NOT NULL,
+    "type" TEXT NOT NULL,
+    "sheetName" TEXT NOT NULL,
+    "startingRow" INTEGER,
+    "endingRow" INTEGER,
+    "tableName" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "layout_sections_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "layout_section_fields" (
+    "id" SERIAL NOT NULL,
+    "layoutSectionId" INTEGER NOT NULL,
+    "cellPosition" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "dataType" TEXT NOT NULL,
+    "dataTypeFormat" TEXT,
+    "importTableColumnName" TEXT NOT NULL,
+    "importColumnOrderNumber" INTEGER NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "layout_section_fields_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "layout_sections" ADD CONSTRAINT "layout_sections_configurationId_fkey" FOREIGN KEY ("configurationId") REFERENCES "layout_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "layout_section_fields" ADD CONSTRAINT "layout_section_fields_layoutSectionId_fkey" FOREIGN KEY ("layoutSectionId") REFERENCES "layout_sections"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 54 - 0
prisma/schema.prisma

@@ -19,3 +19,57 @@ model File {
   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt
 }
+
+model User {
+  id        Int      @id @default(autoincrement())
+  name      String
+  username  String
+  email     String
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  @@map("users")
+}
+
+model LayoutConfiguration {
+  id        Int            @id @default(autoincrement())
+  name      String
+  sections  LayoutSection[]
+  createdAt DateTime       @default(now())
+  updatedAt DateTime       @updatedAt
+
+  @@map("layout_configurations")
+}
+
+model LayoutSection {
+  id               Int                  @id @default(autoincrement())
+  configurationId  Int
+  name             String
+  type             String
+  sheetName        String
+  startingRow      Int?
+  endingRow        Int?
+  tableName        String
+  fields           LayoutSectionField[]
+  layoutConfiguration LayoutConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade)
+  createdAt        DateTime             @default(now())
+  updatedAt        DateTime             @updatedAt
+
+  @@map("layout_sections")
+}
+
+model LayoutSectionField {
+  id                    Int          @id @default(autoincrement())
+  layoutSectionId       Int
+  cellPosition          String
+  name                  String
+  dataType              String
+  dataTypeFormat        String?
+  importTableColumnName String
+  importColumnOrderNumber Int
+  layoutSection         LayoutSection @relation(fields: [layoutSectionId], references: [id], onDelete: Cascade)
+  createdAt             DateTime      @default(now())
+  updatedAt             DateTime      @updatedAt
+
+  @@map("layout_section_fields")
+}