Переглянути джерело

feat(db): add import management functionality with cintas summary support

- Added Import model to track data imports with relationship to LayoutConfiguration
- Added CintasSummary model for storing weekly summary data from imports
- Added date-fns dependency for date manipulation
- Added navigation item for imports in header component
vtugulan 6 місяців тому
батько
коміт
c752d15745

+ 212 - 0
app/actions/imports.ts

@@ -0,0 +1,212 @@
+'use server';
+
+import { prisma } from '@/lib/prisma';
+import { revalidatePath } from 'next/cache';
+import { z } from 'zod';
+
+// Validation schemas
+const createImportSchema = z.object({
+  name: z.string().min(1, 'Import name is required'),
+  layoutId: z.number().int().positive('Layout configuration is required'),
+});
+
+const updateImportSchema = z.object({
+  id: z.number().int().positive(),
+  name: z.string().min(1, 'Import name is required'),
+});
+
+// Create a new import
+export async function createImport(data: {
+  name: string;
+  layoutId: number;
+}) {
+  try {
+    const validatedData = createImportSchema.parse(data);
+    
+    const importRecord = await prisma.import.create({
+      data: {
+        name: validatedData.name,
+        layoutId: validatedData.layoutId,
+        importDate: new Date(),
+      },
+      include: {
+        layout: true,
+      },
+    });
+
+    revalidatePath('/imports');
+    return { success: true, data: importRecord };
+  } catch (error) {
+    console.error('Error creating import:', error);
+    return { success: false, error: 'Failed to create import' };
+  }
+}
+
+// Get all imports
+export async function getImports() {
+  try {
+    const imports = await prisma.import.findMany({
+      include: {
+        layout: true,
+      },
+      orderBy: {
+        importDate: 'desc',
+      },
+    });
+    
+    return { success: true, data: imports };
+  } catch (error) {
+    console.error('Error fetching imports:', error);
+    return { success: false, error: 'Failed to fetch imports' };
+  }
+}
+
+// Get a single import by ID
+export async function getImportById(id: number) {
+  try {
+    const importRecord = await prisma.import.findUnique({
+      where: { id },
+      include: {
+        layout: {
+          include: {
+            sections: {
+              include: {
+                fields: true,
+              },
+            },
+          },
+        },
+        cintasSummaries: {
+          orderBy: {
+            weekId: 'desc',
+          },
+        },
+      },
+    });
+
+    if (!importRecord) {
+      return { success: false, error: 'Import not found' };
+    }
+
+    return { success: true, data: importRecord };
+  } catch (error) {
+    console.error('Error fetching import:', error);
+    return { success: false, error: 'Failed to fetch import' };
+  }
+}
+
+// Update an import
+export async function updateImport(data: {
+  id: number;
+  name: string;
+}) {
+  try {
+    const validatedData = updateImportSchema.parse(data);
+    
+    const importRecord = await prisma.import.update({
+      where: { id: validatedData.id },
+      data: {
+        name: validatedData.name,
+      },
+      include: {
+        layout: true,
+      },
+    });
+
+    revalidatePath('/imports');
+    return { success: true, data: importRecord };
+  } catch (error) {
+    console.error('Error updating import:', error);
+    return { success: false, error: 'Failed to update import' };
+  }
+}
+
+// Delete an import
+export async function deleteImport(id: number) {
+  try {
+    await prisma.import.delete({
+      where: { id },
+    });
+
+    revalidatePath('/imports');
+    return { success: true };
+  } catch (error) {
+    console.error('Error deleting import:', error);
+    return { success: false, error: 'Failed to delete import' };
+  }
+}
+
+// Calculate Cintas summaries for an import
+export async function calculateCintasSummaries(importId: number) {
+  try {
+    // This would typically call a stored procedure or perform calculations
+    // For now, we'll simulate the calculation
+    
+    // In a real implementation, you might call:
+    // await prisma.$executeRaw`CALL cintas_calculate_summary(${importId})`;
+    
+    // For demo purposes, we'll create some sample data
+    const summaries = [
+      {
+        importId,
+        week: '2024-W01',
+        trrTotal: 100,
+        fourWkAverages: 95,
+        trrPlus4Wk: 195,
+        powerAdds: 25,
+        weekId: 1,
+      },
+      {
+        importId,
+        week: '2024-W02',
+        trrTotal: 110,
+        fourWkAverages: 100,
+        trrPlus4Wk: 210,
+        powerAdds: 30,
+        weekId: 2,
+      },
+    ];
+
+    // Clear existing summaries for this import
+    await prisma.cintasSummary.deleteMany({
+      where: { importId },
+    });
+
+    // Create new summaries
+    const createdSummaries = await Promise.all(
+      summaries.map(summary =>
+        prisma.cintasSummary.create({
+          data: summary,
+        })
+      )
+    );
+
+    return { success: true, data: createdSummaries };
+  } catch (error) {
+    console.error('Error calculating Cintas summaries:', error);
+    return { success: false, error: 'Failed to calculate summaries' };
+  }
+}
+
+// Get available layout configurations
+export async function getLayoutConfigurations() {
+  try {
+    const layouts = await prisma.layoutConfiguration.findMany({
+      include: {
+        sections: {
+          include: {
+            fields: true,
+          },
+        },
+      },
+      orderBy: {
+        name: 'asc',
+      },
+    });
+    
+    return { success: true, data: layouts };
+  } catch (error) {
+    console.error('Error fetching layout configurations:', error);
+    return { success: false, error: 'Failed to fetch layout configurations' };
+  }
+}

+ 6 - 0
app/components/header.tsx

@@ -33,6 +33,12 @@ const navigationItems = [
     description: "Manage Excel layout configurations",
     icon: FileText,
   },
+  {
+    title: "Imports",
+    href: "/imports",
+    description: "Manage data imports",
+    icon: FileText,
+  },
   {
     title: "API Docs",
     href: "/api-docs",

+ 160 - 0
app/components/imports/CreateImportDialog.tsx

@@ -0,0 +1,160 @@
+'use client';
+
+import { useState } from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+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 { useEffect } from 'react';
+
+interface LayoutConfiguration {
+  id: number;
+  name: string;
+}
+
+interface CreateImportDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onSuccess?: () => void;
+}
+
+export function CreateImportDialog({ open, onOpenChange, onSuccess }: CreateImportDialogProps) {
+  const [name, setName] = useState('');
+  const [layoutId, setLayoutId] = useState<string>('');
+  const [loading, setLoading] = useState(false);
+  const [layouts, setLayouts] = useState<LayoutConfiguration[]>([]);
+  const [loadingLayouts, setLoadingLayouts] = useState(true);
+  const { toast } = useToast();
+
+  useEffect(() => {
+    if (open) {
+      loadLayouts();
+    }
+  }, [open]);
+
+  async function loadLayouts() {
+    try {
+      const result = await getLayoutConfigurations();
+      if (result.success && result.data) {
+        setLayouts(result.data);
+      }
+    } catch (error) {
+      toast({
+        title: 'Error',
+        description: 'Failed to load layout configurations',
+        variant: 'destructive',
+      });
+    } finally {
+      setLoadingLayouts(false);
+    }
+  }
+
+  async function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    
+    if (!name.trim()) {
+      toast({
+        title: 'Error',
+        description: 'Please enter an import name',
+        variant: 'destructive',
+      });
+      return;
+    }
+
+    if (!layoutId) {
+      toast({
+        title: 'Error',
+        description: 'Please select a layout configuration',
+        variant: 'destructive',
+      });
+      return;
+    }
+
+    setLoading(true);
+
+    try {
+      const result = await createImport({
+        name: name.trim(),
+        layoutId: parseInt(layoutId),
+      });
+
+      if (result.success) {
+        toast({
+          title: 'Success',
+          description: 'Import created successfully',
+        });
+        setName('');
+        setLayoutId('');
+        onOpenChange(false);
+        onSuccess?.();
+      } else {
+        toast({
+          title: 'Error',
+          description: result.error || 'Failed to create import',
+          variant: 'destructive',
+        });
+      }
+    } catch (error) {
+      toast({
+        title: 'Error',
+        description: 'Failed to create import',
+        variant: 'destructive',
+      });
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-[425px]">
+        <DialogHeader>
+          <DialogTitle>Create New Import</DialogTitle>
+          <DialogDescription>
+            Create a new import with a layout configuration and file name.
+          </DialogDescription>
+        </DialogHeader>
+        <form onSubmit={handleSubmit}>
+          <div className="grid gap-4 py-4">
+            <div className="grid gap-2">
+              <Label htmlFor="name">Import Name</Label>
+              <Input
+                id="name"
+                value={name}
+                onChange={(e) => setName(e.target.value)}
+                placeholder="Enter import name"
+                disabled={loading}
+              />
+            </div>
+            <div className="grid gap-2">
+              <Label htmlFor="layout">Layout Configuration</Label>
+              <Select value={layoutId} onValueChange={setLayoutId} disabled={loadingLayouts || loading}>
+                <SelectTrigger>
+                  <SelectValue placeholder="Select a layout configuration" />
+                </SelectTrigger>
+                <SelectContent>
+                  {layouts.map((layout) => (
+                    <SelectItem key={layout.id} value={layout.id.toString()}>
+                      {layout.name}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+            </div>
+          </div>
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
+              Cancel
+            </Button>
+            <Button type="submit" disabled={loading || !name || !layoutId}>
+              {loading ? 'Creating...' : 'Create Import'}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 111 - 0
app/components/imports/EditImportDialog.tsx

@@ -0,0 +1,111 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useToast } from '@/hooks/use-toast';
+import { updateImport } from '@/app/actions/imports';
+import { Import } from '@/app/actions/imports';
+
+interface EditImportDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  importRecord: Import | null;
+  onSuccess?: () => void;
+}
+
+export function EditImportDialog({ open, onOpenChange, importRecord, onSuccess }: EditImportDialogProps) {
+  const [name, setName] = useState('');
+  const [loading, setLoading] = useState(false);
+  const { toast } = useToast();
+
+  useEffect(() => {
+    if (importRecord) {
+      setName(importRecord.name);
+    }
+  }, [importRecord]);
+
+  async function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    
+    if (!importRecord || !name.trim()) {
+      toast({
+        title: 'Error',
+        description: 'Please enter a valid import name',
+        variant: 'destructive',
+      });
+      return;
+    }
+
+    setLoading(true);
+
+    try {
+      const result = await updateImport({
+        id: importRecord.id,
+        name: name.trim(),
+      });
+
+      if (result.success) {
+        toast({
+          title: 'Success',
+          description: 'Import updated successfully',
+        });
+        onOpenChange(false);
+        onSuccess?.();
+      } else {
+        toast({
+          title: 'Error',
+          description: result.error || 'Failed to update import',
+          variant: 'destructive',
+        });
+      }
+    } catch (error) {
+      toast({
+        title: 'Error',
+        description: 'Failed to update import',
+        variant: 'destructive',
+      });
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  if (!importRecord) return null;
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-[425px]">
+        <DialogHeader>
+          <DialogTitle>Edit Import</DialogTitle>
+          <DialogDescription>
+            Update the name of the import record.
+          </DialogDescription>
+        </DialogHeader>
+        <form onSubmit={handleSubmit}>
+          <div className="grid gap-4 py-4">
+            <div className="grid gap-2">
+              <Label htmlFor="edit-name">Import Name</Label>
+              <Input
+                id="edit-name"
+                value={name}
+                onChange={(e) => setName(e.target.value)}
+                placeholder="Enter import name"
+                disabled={loading}
+              />
+            </div>
+          </div>
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
+              Cancel
+            </Button>
+            <Button type="submit" disabled={loading || !name.trim()}>
+              {loading ? 'Updating...' : 'Update Import'}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 236 - 0
app/components/imports/ImportDetailDialog.tsx

@@ -0,0 +1,236 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { format } from 'date-fns';
+import { useToast } from '@/hooks/use-toast';
+import { getImportById, calculateCintasSummaries } from '@/app/actions/imports';
+
+interface CintasSummary {
+  id: number;
+  week: string;
+  trrTotal: number;
+  fourWkAverages: number;
+  trrPlus4Wk: number;
+  powerAdds: number;
+  weekId: number;
+}
+
+interface ImportDetail {
+  id: number;
+  name: string;
+  importDate: string;
+  layout: {
+    id: number;
+    name: string;
+    sections: Array<{
+      id: number;
+      name: string;
+      tableName: string;
+      fields: Array<{
+        name: string;
+        importTableColumnName: string;
+      }>;
+    }>;
+  };
+  cintasSummaries: CintasSummary[];
+}
+
+interface ImportDetailDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  importId: number;
+}
+
+export function ImportDetailDialog({ open, onOpenChange, importId }: ImportDetailDialogProps) {
+  const [importDetail, setImportDetail] = useState<ImportDetail | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [calculating, setCalculating] = useState(false);
+  const { toast } = useToast();
+
+  useEffect(() => {
+    if (open && importId) {
+      loadImportDetail();
+    }
+  }, [open, importId]);
+
+  async function loadImportDetail() {
+    try {
+      const result = await getImportById(importId);
+      if (result.success) {
+        setImportDetail(result.data);
+      } else {
+        toast({
+          title: 'Error',
+          description: result.error || 'Failed to load import details',
+          variant: 'destructive',
+        });
+      }
+    } catch (error) {
+      toast({
+        title: 'Error',
+        description: 'Failed to load import details',
+        variant: 'destructive',
+      });
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  async function handleCalculateSummaries() {
+    setCalculating(true);
+    try {
+      const result = await calculateCintasSummaries(importId);
+      if (result.success) {
+        toast({
+          title: 'Success',
+          description: 'Cintas summaries calculated successfully',
+        });
+        loadImportDetail();
+      } else {
+        toast({
+          title: 'Error',
+          description: result.error || 'Failed to calculate summaries',
+          variant: 'destructive',
+        });
+      }
+    } catch (error) {
+      toast({
+        title: 'Error',
+        description: 'Failed to calculate summaries',
+        variant: 'destructive',
+      });
+    } finally {
+      setCalculating(false);
+    }
+  }
+
+  if (loading) {
+    return (
+      <Dialog open={open} onOpenChange={onOpenChange}>
+        <DialogContent className="max-w-4xl max-h-[80vh]">
+          <div className="flex justify-center py-8">
+            <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
+          </div>
+        </DialogContent>
+      </Dialog>
+    );
+  }
+
+  if (!importDetail) return null;
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+        <DialogHeader>
+          <DialogTitle>Import Details</DialogTitle>
+          <DialogDescription>
+            View detailed information about this import
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-6">
+          <Card>
+            <CardHeader>
+              <CardTitle>Import Information</CardTitle>
+            </CardHeader>
+            <CardContent className="space-y-2">
+              <div className="flex justify-between">
+                <span className="font-medium">Name:</span>
+                <span>{importDetail.name}</span>
+              </div>
+              <div className="flex justify-between">
+                <span className="font-medium">Layout:</span>
+                <span>{importDetail.layout.name}</span>
+              </div>
+              <div className="flex justify-between">
+                <span className="font-medium">Import Date:</span>
+                <span>{format(new Date(importDetail.importDate), 'PPpp')}</span>
+              </div>
+            </CardContent>
+          </Card>
+
+          <Card>
+            <CardHeader>
+              <CardTitle>Layout Configuration</CardTitle>
+            </CardHeader>
+            <CardContent>
+              <div className="space-y-4">
+                {importDetail.layout.sections.map((section) => (
+                  <div key={section.id} className="border rounded-lg p-4">
+                    <h4 className="font-medium mb-2">{section.name}</h4>
+                    <p className="text-sm text-muted-foreground mb-2">
+                      Table: {section.tableName}
+                    </p>
+                    <div className="grid gap-1">
+                      {section.fields.map((field) => (
+                        <div key={field.importTableColumnName} className="text-sm">
+                          <span className="font-medium">{field.name}:</span>
+                          <span className="ml-2 text-muted-foreground">
+                            {field.importTableColumnName}
+                          </span>
+                        </div>
+                      ))}
+                    </div>
+                  </div>
+                ))}
+              </div>
+            </CardContent>
+          </Card>
+
+          <Card>
+            <CardHeader>
+              <div className="flex justify-between items-center">
+                <CardTitle>Cintas Summaries</CardTitle>
+                <Button 
+                  onClick={handleCalculateSummaries} 
+                  disabled={calculating}
+                  size="sm"
+                >
+                  {calculating ? 'Calculating...' : 'Calculate Summaries'}
+                </Button>
+              </div>
+            </CardHeader>
+            <CardContent>
+              {importDetail.cintasSummaries.length === 0 ? (
+                <p className="text-muted-foreground">No summaries calculated yet</p>
+              ) : (
+                <div className="overflow-x-auto">
+                  <table className="w-full text-sm">
+                    <thead>
+                      <tr className="border-b">
+                        <th className="text-left py-2">Week</th>
+                        <th className="text-right py-2">TRR Total</th>
+                        <th className="text-right py-2">4WK Averages</th>
+                        <th className="text-right py-2">TRR + 4WK</th>
+                        <th className="text-right py-2">Power Adds</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      {importDetail.cintasSummaries.map((summary) => (
+                        <tr key={summary.id} className="border-b">
+                          <td className="py-2">{summary.week}</td>
+                          <td className="text-right py-2">{summary.trrTotal}</td>
+                          <td className="text-right py-2">{summary.fourWkAverages}</td>
+                          <td className="text-right py-2">{summary.trrPlus4Wk}</td>
+                          <td className="text-right py-2">{summary.powerAdds}</td>
+                        </tr>
+                      ))}
+                    </tbody>
+                  </table>
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        </div>
+
+        <div className="flex justify-end gap-2 mt-6">
+          <Button onClick={() => onOpenChange(false)}>Close</Button>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 206 - 0
app/imports/page.tsx

@@ -0,0 +1,206 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Plus, FileText, Calendar, Settings, Trash2, Edit } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { format } from 'date-fns';
+import { getImports, deleteImport } from '@/app/actions/imports';
+import { CreateImportDialog } from '@/app/components/imports/CreateImportDialog';
+import { EditImportDialog } from '@/app/components/imports/EditImportDialog';
+import { ImportDetailDialog } from '@/app/components/imports/ImportDetailDialog';
+import { useToast } from '@/hooks/use-toast';
+
+interface Import {
+  id: number;
+  name: string;
+  importDate: string;
+  layoutId: number;
+  layout: {
+    id: number;
+    name: string;
+  };
+}
+
+export default function ImportsPage() {
+  const [imports, setImports] = useState<Import[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [createDialogOpen, setCreateDialogOpen] = useState(false);
+  const [editDialogOpen, setEditDialogOpen] = useState(false);
+  const [detailDialogOpen, setDetailDialogOpen] = useState(false);
+  const [selectedImport, setSelectedImport] = useState<Import | null>(null);
+  const { toast } = useToast();
+
+  useEffect(() => {
+    loadImports();
+  }, []);
+
+  async function loadImports() {
+    try {
+      const result = await getImports();
+      if (result.success) {
+        setImports(result.data);
+      } else {
+        toast({
+          title: 'Error',
+          description: 'Failed to load imports',
+          variant: 'destructive',
+        });
+      }
+    } catch (error) {
+      toast({
+        title: 'Error',
+        description: 'Failed to load imports',
+        variant: 'destructive',
+      });
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  async function handleDeleteImport(id: number) {
+    if (!confirm('Are you sure you want to delete this import?')) return;
+
+    try {
+      const result = await deleteImport(id);
+      if (result.success) {
+        toast({
+          title: 'Success',
+          description: 'Import deleted successfully',
+        });
+        loadImports();
+      } else {
+        toast({
+          title: 'Error',
+          description: result.error || 'Failed to delete import',
+          variant: 'destructive',
+        });
+      }
+    } catch (error) {
+      toast({
+        title: 'Error',
+        description: 'Failed to delete import',
+        variant: 'destructive',
+      });
+    }
+  }
+
+  function handleEditImport(importRecord: Import) {
+    setSelectedImport(importRecord);
+    setEditDialogOpen(true);
+  }
+
+  function handleViewImport(importRecord: Import) {
+    setSelectedImport(importRecord);
+    setDetailDialogOpen(true);
+  }
+
+  if (loading) {
+    return (
+      <div className="container mx-auto py-8">
+        <div className="flex justify-center">
+          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="container mx-auto py-8">
+      <div className="flex justify-between items-center mb-6">
+        <div>
+          <h1 className="text-3xl font-bold">Import Management</h1>
+          <p className="text-muted-foreground">Manage your data imports and configurations</p>
+        </div>
+        <Button onClick={() => setCreateDialogOpen(true)}>
+          <Plus className="mr-2 h-4 w-4" />
+          Create Import
+        </Button>
+      </div>
+
+      {imports.length === 0 ? (
+        <Card>
+          <CardContent className="flex flex-col items-center justify-center py-12">
+            <FileText className="h-12 w-12 text-muted-foreground mb-4" />
+            <h3 className="text-lg font-semibold mb-2">No imports yet</h3>
+            <p className="text-muted-foreground mb-4">
+              Get started by creating your first import
+            </p>
+            <Button onClick={() => setCreateDialogOpen(true)}>
+              <Plus className="mr-2 h-4 w-4" />
+              Create Import
+            </Button>
+          </CardContent>
+        </Card>
+      ) : (
+        <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+          {imports.map((importRecord) => (
+            <Card key={importRecord.id} className="hover:shadow-lg transition-shadow">
+              <CardHeader>
+                <div className="flex justify-between items-start">
+                  <div>
+                    <CardTitle className="text-lg">{importRecord.name}</CardTitle>
+                    <CardDescription>
+                      Layout: {importRecord.layout.name}
+                    </CardDescription>
+                  </div>
+                  <Badge variant="outline">
+                    <Calendar className="mr-1 h-3 w-3" />
+                    {format(new Date(importRecord.importDate), 'MMM d, yyyy')}
+                  </Badge>
+                </div>
+              </CardHeader>
+              <CardContent>
+                <div className="flex justify-between items-center">
+                  <div className="flex gap-2">
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      onClick={() => handleViewImport(importRecord)}
+                    >
+                      <FileText className="h-4 w-4" />
+                    </Button>
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      onClick={() => handleEditImport(importRecord)}
+                    >
+                      <Edit className="h-4 w-4" />
+                    </Button>
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      onClick={() => handleDeleteImport(importRecord.id)}
+                    >
+                      <Trash2 className="h-4 w-4" />
+                    </Button>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+          ))}
+        </div>
+      )}
+
+      <CreateImportDialog
+        open={createDialogOpen}
+        onOpenChange={setCreateDialogOpen}
+        onSuccess={loadImports}
+      />
+
+      <EditImportDialog
+        open={editDialogOpen}
+        onOpenChange={setEditDialogOpen}
+        importRecord={selectedImport}
+        onSuccess={loadImports}
+      />
+
+      <ImportDetailDialog
+        open={detailDialogOpen}
+        onOpenChange={setDetailDialogOpen}
+        importId={selectedImport?.id || 0}
+      />
+    </div>
+  );
+}

+ 11 - 0
package-lock.json

@@ -29,6 +29,7 @@
         "@tanstack/react-table": "^8.21.3",
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",
+        "date-fns": "^4.1.0",
         "lucide-react": "^0.525.0",
         "next": "^15.4.1",
         "pg": "^8.16.3",
@@ -5454,6 +5455,16 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/date-fns": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+      "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/kossnocorp"
+      }
+    },
     "node_modules/debug": {
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",

+ 1 - 0
package.json

@@ -30,6 +30,7 @@
     "@tanstack/react-table": "^8.21.3",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
+    "date-fns": "^4.1.0",
     "lucide-react": "^0.525.0",
     "next": "^15.4.1",
     "pg": "^8.16.3",

+ 33 - 0
prisma/migrations/20250720215418_add_imports_and_cintas_tables/migration.sql

@@ -0,0 +1,33 @@
+-- CreateTable
+CREATE TABLE "imports" (
+    "id" SERIAL NOT NULL,
+    "name" TEXT NOT NULL,
+    "importDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "layoutId" INTEGER NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "imports_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "cintas_intall_calendar_summary" (
+    "id" SERIAL NOT NULL,
+    "importId" INTEGER NOT NULL,
+    "week" TEXT NOT NULL,
+    "trrTotal" INTEGER NOT NULL,
+    "fourWkAverages" INTEGER NOT NULL,
+    "trrPlus4Wk" INTEGER NOT NULL,
+    "powerAdds" INTEGER NOT NULL,
+    "weekId" INTEGER NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "cintas_intall_calendar_summary_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "imports" ADD CONSTRAINT "imports_layoutId_fkey" FOREIGN KEY ("layoutId") REFERENCES "layout_configurations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "cintas_intall_calendar_summary" ADD CONSTRAINT "cintas_intall_calendar_summary_importId_fkey" FOREIGN KEY ("importId") REFERENCES "imports"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 31 - 0
prisma/schema.prisma

@@ -35,6 +35,7 @@ model LayoutConfiguration {
   id        Int            @id @default(autoincrement())
   name      String
   sections  LayoutSection[]
+  imports   Import[]
   createdAt DateTime       @default(now())
   updatedAt DateTime       @updatedAt
 
@@ -73,3 +74,33 @@ model LayoutSectionField {
 
   @@map("layout_section_fields")
 }
+
+model Import {
+  id         Int      @id @default(autoincrement())
+  name       String
+  importDate DateTime @default(now())
+  layoutId   Int
+  layout     LayoutConfiguration @relation(fields: [layoutId], references: [id])
+  createdAt  DateTime @default(now())
+  updatedAt  DateTime @updatedAt
+
+  cintasSummaries CintasSummary[]
+
+  @@map("imports")
+}
+
+model CintasSummary {
+  id            Int    @id @default(autoincrement())
+  importId      Int
+  import        Import  @relation(fields: [importId], references: [id], onDelete: Cascade)
+  week          String
+  trrTotal      Int
+  fourWkAverages Int
+  trrPlus4Wk    Int
+  powerAdds     Int
+  weekId        Int
+  createdAt     DateTime @default(now())
+  updatedAt     DateTime @updatedAt
+
+  @@map("cintas_intall_calendar_summary")
+}