Răsfoiți Sursa

feat(imports): add file upload functionality to import creation process

- Added new file-upload server action for handling file uploads to database
- Extended import schema to support fileId references
- Updated CreateImportDialog to include file upload step before import creation
- Added file upload UI with drag-and-drop support and file validation
- Increased server action body size limit to 1000mb for large file uploads
- Added new File model to Prisma schema for storing uploaded files as binary data
vtugulan 6 luni în urmă
părinte
comite
54b8921346

+ 41 - 0
app/actions/file-upload.ts

@@ -0,0 +1,41 @@
+'use server';
+
+import { prisma } from '@/lib/prisma';
+
+export async function uploadFile(file: File) {
+  try {
+    const bytes = await file.arrayBuffer();
+    const buffer = Buffer.from(bytes);
+
+    const uploadedFile = await prisma.file.create({
+      data: {
+        filename: file.name,
+        mimetype: file.type,
+        size: file.size,
+        data: buffer,
+      },
+    });
+
+    return { success: true, data: uploadedFile };
+  } catch (error) {
+    console.error('Error uploading file:', error);
+    return { success: false, error: 'Failed to upload file' };
+  }
+}
+
+export async function getFileById(id: string) {
+  try {
+    const file = await prisma.file.findUnique({
+      where: { id },
+    });
+
+    if (!file) {
+      return { success: false, error: 'File not found' };
+    }
+
+    return { success: true, data: file };
+  } catch (error) {
+    console.error('Error fetching file:', error);
+    return { success: false, error: 'Failed to fetch file' };
+  }
+}

+ 6 - 0
app/actions/imports.ts

@@ -8,17 +8,20 @@ import { z } from 'zod';
 const createImportSchema = z.object({
   name: z.string().min(1, 'Import name is required'),
   layoutId: z.number().int().positive('Layout configuration is required'),
+  fileId: z.string().optional(),
 });
 
 const updateImportSchema = z.object({
   id: z.number().int().positive(),
   name: z.string().min(1, 'Import name is required'),
+  fileId: z.string().optional(),
 });
 
 // Create a new import
 export async function createImport(data: {
   name: string;
   layoutId: number;
+  fileId?: string;
 }) {
   try {
     const validatedData = createImportSchema.parse(data);
@@ -28,6 +31,7 @@ export async function createImport(data: {
         name: validatedData.name,
         layoutId: validatedData.layoutId,
         importDate: new Date(),
+        ...(validatedData.fileId && { fileId: validatedData.fileId }),
       },
       include: {
         layout: true,
@@ -99,6 +103,7 @@ export async function getImportById(id: number) {
 export async function updateImport(data: {
   id: number;
   name: string;
+  fileId?: string;
 }) {
   try {
     const validatedData = updateImportSchema.parse(data);
@@ -107,6 +112,7 @@ export async function updateImport(data: {
       where: { id: validatedData.id },
       data: {
         name: validatedData.name,
+        ...(validatedData.fileId !== undefined && { fileId: validatedData.fileId }),
       },
       include: {
         layout: true,

+ 57 - 3
app/components/imports/CreateImportDialog.tsx

@@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label';
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
 import { useToast } from '@/hooks/use-toast';
 import { createImport, getLayoutConfigurations } from '@/app/actions/imports';
+import { uploadFile } from '@/app/actions/file-upload';
 
 interface LayoutConfiguration {
   id: number;
@@ -23,6 +24,7 @@ interface CreateImportDialogProps {
 export function CreateImportDialog({ open, onOpenChange, onSuccess }: CreateImportDialogProps) {
   const [name, setName] = useState('');
   const [layoutId, setLayoutId] = useState<string>('');
+  const [selectedFile, setSelectedFile] = useState<File | null>(null);
   const [loading, setLoading] = useState(false);
   const [layouts, setLayouts] = useState<LayoutConfiguration[]>([]);
   const [loadingLayouts, setLoadingLayouts] = useState(true);
@@ -51,9 +53,29 @@ export function CreateImportDialog({ open, onOpenChange, onSuccess }: CreateImpo
     }
   }, [open, toast]);
 
+  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (file) {
+      setSelectedFile(file);
+      // Auto-fill name from filename if empty
+      if (!name.trim()) {
+        setName(file.name.replace(/\.[^/.]+$/, ''));
+      }
+    }
+  };
+
   async function handleSubmit(e: React.FormEvent) {
     e.preventDefault();
 
+    if (!selectedFile) {
+      toast({
+        title: 'Error',
+        description: 'Please select a file to upload',
+        variant: 'destructive',
+      });
+      return;
+    }
+
     if (!name.trim()) {
       toast({
         title: 'Error',
@@ -75,9 +97,22 @@ export function CreateImportDialog({ open, onOpenChange, onSuccess }: CreateImpo
     setLoading(true);
 
     try {
+      // First, upload the file
+      const uploadResult = await uploadFile(selectedFile);
+      if (!uploadResult.success) {
+        toast({
+          title: 'Error',
+          description: uploadResult.error || 'Failed to upload file',
+          variant: 'destructive',
+        });
+        return;
+      }
+
+      // Then create the import with the file reference
       const result = await createImport({
         name: name.trim(),
         layoutId: parseInt(layoutId),
+        fileId: uploadResult.data?.id,
       });
 
       if (result.success) {
@@ -87,6 +122,7 @@ export function CreateImportDialog({ open, onOpenChange, onSuccess }: CreateImpo
         });
         setName('');
         setLayoutId('');
+        setSelectedFile(null);
         onOpenChange(false);
         onSuccess?.();
       } else {
@@ -109,15 +145,30 @@ export function CreateImportDialog({ open, onOpenChange, onSuccess }: CreateImpo
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="sm:max-w-[425px]">
+      <DialogContent className="sm:max-w-[500px]">
         <DialogHeader>
           <DialogTitle>Create New Import</DialogTitle>
           <DialogDescription>
-            Create a new import with a layout configuration and file name.
+            Upload a file and create a new import with a layout configuration.
           </DialogDescription>
         </DialogHeader>
         <form onSubmit={handleSubmit}>
           <div className="grid gap-4 py-4">
+            <div className="grid gap-2">
+              <Label htmlFor="file">Upload File</Label>
+              <Input
+                id="file"
+                type="file"
+                onChange={handleFileChange}
+                accept=".csv,.xlsx,.xls"
+                disabled={loading}
+              />
+              {selectedFile && (
+                <p className="text-sm text-muted-foreground">
+                  Selected: {selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
+                </p>
+              )}
+            </div>
             <div className="grid gap-2">
               <Label htmlFor="name">Import Name</Label>
               <Input
@@ -148,7 +199,10 @@ export function CreateImportDialog({ open, onOpenChange, onSuccess }: CreateImpo
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
               Cancel
             </Button>
-            <Button type="submit" disabled={loading || !name || !layoutId}>
+            <Button 
+              type="submit" 
+              disabled={loading || !selectedFile || !name || !layoutId}
+            >
               {loading ? 'Creating...' : 'Create Import'}
             </Button>
           </DialogFooter>

+ 5 - 0
next.config.ts

@@ -12,6 +12,11 @@ const nextConfig: NextConfig = {
       },
     ],
   },
+  experimental: {
+    serverActions: {
+      bodySizeLimit: '1000mb',
+    },
+  },
   async rewrites() {
     return [
       {

+ 2 - 0
prisma/migrations/20250721033012_add_file_to_imports/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "imports" ADD COLUMN     "fileId" TEXT;

+ 1 - 0
prisma/schema.prisma

@@ -81,6 +81,7 @@ model Import {
   importDate DateTime @default(now())
   layoutId   Int
   layout     LayoutConfiguration @relation(fields: [layoutId], references: [id])
+  fileId     String?
   createdAt  DateTime @default(now())
   updatedAt  DateTime @updatedAt