diff --git a/src/App.tsx b/src/App.tsx index 45508d39..54f7d69e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,6 +50,7 @@ const Operations = lazy(() => import("./pages/admin/Operations").then(m => ({ de const Settings = lazy(() => import("./pages/admin/Settings").then(m => ({ default: m.Settings }))); const IntegrationsMarketplace = lazy(() => import("./pages/admin/IntegrationsMarketplace")); const Shipments = lazy(() => import("./pages/admin/Shipments")); +const Batches = lazy(() => import("./pages/admin/Batches")); const StepsTemplatesView = lazy(() => import("./pages/admin/StepsTemplatesView")); const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard")); @@ -620,6 +621,19 @@ function AppRoutes() { } /> + + + + + + + + } + /> + {/* Analytics Routes */} void; +} + +export default function BatchDetailModal({ batchId, onClose }: BatchDetailModalProps) { + const { t } = useTranslation(); + const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); + const [removeOpId, setRemoveOpId] = useState(null); + + const { data: batch, isLoading } = useBatch(batchId); + const startBatch = useStartBatch(); + const completeBatch = useCompleteBatch(); + const cancelBatch = useCancelBatch(); + const removeOperation = useRemoveOperationFromBatch(); + + if (!batchId) return null; + + if (isLoading) { + return ( + + +
+
Loading...
+
+
+
+ ); + } + + if (!batch) return null; + + const statusConfig = BATCH_STATUS_CONFIG[batch.status]; + const typeConfig = BATCH_TYPE_CONFIG[batch.batch_type]; + const nestingMeta = batch.nesting_metadata as Record | null; + + const handleStart = async () => { + await startBatch.mutateAsync(batch.id); + }; + + const handleComplete = async () => { + await completeBatch.mutateAsync(batch.id); + }; + + const handleCancel = async () => { + await cancelBatch.mutateAsync(batch.id); + setCancelConfirmOpen(false); + }; + + const handleRemoveOperation = async () => { + if (!removeOpId) return; + await removeOperation.mutateAsync({ + batch_id: batch.id, + operation_id: removeOpId, + }); + setRemoveOpId(null); + }; + + return ( + + + +
+
+
+ +
+
+ {batch.batch_number} +
+ {t(`batches.types.${batch.batch_type}`)} + + {t(`batches.status.${batch.status}`)} + +
+
+
+ + {/* Action buttons based on status */} +
+ {(batch.status === "draft" || batch.status === "ready") && ( + + )} + {batch.status === "in_progress" && ( + <> + + + + )} +
+
+
+ + +
+ {/* Info Cards */} +
+ + +
+ + {t("batches.operationsCount")} +
+
{batch.operations_count}
+
+
+ + + +
+ + {t("batches.cell")} +
+
{batch.cell?.name || "-"}
+
+
+ + {batch.material && ( + + +
{t("batches.material")}
+
+ {batch.material} + {batch.thickness_mm && ` (${batch.thickness_mm}mm)`} +
+
+
+ )} + + {nestingMeta?.efficiency_percent && ( + + +
+ + {t("batches.nesting.efficiency")} +
+
+ {nestingMeta.efficiency_percent}% +
+
+
+ )} +
+ + {/* Timestamps */} + + + + + {t("common.timeline")} + + + +
+
+
{t("batches.createdAt")}
+
+ {format(new Date(batch.created_at), "dd MMM yyyy HH:mm")} +
+ {batch.created_by_user && ( +
+ + {batch.created_by_user.full_name} +
+ )} +
+ {batch.started_at && ( +
+
{t("batches.startedAt")}
+
+ {format(new Date(batch.started_at), "dd MMM yyyy HH:mm")} +
+ {batch.started_by_user && ( +
+ + {batch.started_by_user.full_name} +
+ )} +
+ )} + {batch.completed_at && ( +
+
{t("batches.completedAt")}
+
+ {format(new Date(batch.completed_at), "dd MMM yyyy HH:mm")} +
+
+ )} + {batch.estimated_time && ( +
+
{t("batches.estimatedTime")}
+
{batch.estimated_time} min
+
+ )} +
+
+
+ + {/* External Reference */} + {(batch.external_id || batch.external_source) && ( + + +
+ + {t("batches.externalSource")}: + {batch.external_source} + {batch.external_id && ( + <> + + {batch.external_id} + + )} +
+
+
+ )} + + {/* Notes */} + {batch.notes && ( + + + {t("batches.notes")} + + +

{batch.notes}

+
+
+ )} + + + + {/* Operations in Batch */} + + + + + + {t("batches.operationsInBatch")} + + {batch.batch_operations?.length || 0} + + + + {!batch.batch_operations?.length ? ( +
+ {t("batches.noOperationsInBatch")} +
+ ) : ( + + + + # + {t("parts.partNumber")} + {t("operations.title")} + {t("jobs.job")} + {t("parts.quantity")} + {t("operations.status")} + {batch.status === "draft" && } + + + + {batch.batch_operations.map((bo: any, idx) => ( + + {idx + 1} + + {bo.operation?.part?.part_number || "-"} + + {bo.operation?.operation_name || "-"} + +
+ {bo.operation?.part?.job?.job_number || "-"} +
+ {bo.operation?.part?.job?.customer && ( +
+ {bo.operation.part.job.customer} +
+ )} +
+ + {bo.operation?.part?.quantity ? ( + x{bo.operation.part.quantity} + ) : ( + "-" + )} + + + + {bo.operation?.status || "unknown"} + + + {batch.status === "draft" && ( + + + + )} +
+ ))} +
+
+ )} +
+
+
+
+
+ + {/* Cancel Confirmation Dialog */} + + + + {t("batches.cancelBatch")} + + {t("batches.deleteConfirm")} + + + + {t("common.cancel")} + + {t("batches.cancelBatch")} + + + + + + {/* Remove Operation Confirmation Dialog */} + setRemoveOpId(null)}> + + + {t("batches.removeOperation")} + + {t("batches.removeOperation")}? + + + + {t("common.cancel")} + + {t("common.remove")} + + + + +
+ ); +} diff --git a/src/components/admin/CreateBatchModal.tsx b/src/components/admin/CreateBatchModal.tsx new file mode 100644 index 00000000..443be0b9 --- /dev/null +++ b/src/components/admin/CreateBatchModal.tsx @@ -0,0 +1,447 @@ +import { useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { useCreateBatch, useGroupableOperations } from "@/hooks/useBatches"; +import type { BatchType, NestingMetadata, GroupableOperation } from "@/types/batches"; +import { BATCH_TYPE_CONFIG } from "@/types/batches"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + Zap, + Cylinder, + Scissors, + Sparkles, + Layers, + CheckCircle2, + Package, +} from "lucide-react"; + +interface CreateBatchModalProps { + open: boolean; + onClose: () => void; + preselectedCellId?: string; + preselectedOperationIds?: string[]; +} + +const BATCH_TYPE_ICONS: Record = { + laser_nesting: Zap, + tube_batch: Cylinder, + saw_batch: Scissors, + finishing_batch: Sparkles, + general: Layers, +}; + +export default function CreateBatchModal({ + open, + onClose, + preselectedCellId, + preselectedOperationIds, +}: CreateBatchModalProps) { + const { t } = useTranslation(); + const [step, setStep] = useState<"type" | "cell" | "operations" | "details">("type"); + const [batchType, setBatchType] = useState(null); + const [cellId, setCellId] = useState(preselectedCellId || ""); + const [selectedOperationIds, setSelectedOperationIds] = useState(preselectedOperationIds || []); + const [notes, setNotes] = useState(""); + const [efficiency, setEfficiency] = useState(""); + + const createBatch = useCreateBatch(); + + // Fetch cells + const { data: cells } = useQuery({ + queryKey: ["cells"], + queryFn: async () => { + const { data, error } = await supabase + .from("cells") + .select("id, name") + .eq("active", true) + .order("sequence"); + if (error) throw error; + return data || []; + }, + }); + + // Get groupable operations + const { data: groupableData, isLoading: loadingOperations } = useGroupableOperations(cellId || undefined); + + // Calculate selected operations summary + const selectedSummary = useMemo(() => { + if (!groupableData?.operations) return null; + const selected = groupableData.operations.filter(op => selectedOperationIds.includes(op.id)); + if (selected.length === 0) return null; + + const materials = new Set(selected.map(op => op.material)); + const thicknesses = new Set(selected.map(op => op.thickness_mm).filter(t => t !== null)); + const jobs = new Set(selected.map(op => op.job_number)); + const totalTime = selected.reduce((sum, op) => sum + op.estimated_time, 0); + + return { + count: selected.length, + materials: Array.from(materials), + thicknesses: Array.from(thicknesses), + jobs: Array.from(jobs), + totalTime, + // For batch creation, use the most common material/thickness + primaryMaterial: selected[0]?.material || null, + primaryThickness: selected[0]?.thickness_mm || null, + }; + }, [groupableData?.operations, selectedOperationIds]); + + const handleSelectGroup = (operations: GroupableOperation[]) => { + const ids = operations.map(op => op.id); + setSelectedOperationIds(prev => { + const allSelected = ids.every(id => prev.includes(id)); + if (allSelected) { + return prev.filter(id => !ids.includes(id)); + } else { + return [...new Set([...prev, ...ids])]; + } + }); + }; + + const handleToggleOperation = (id: string) => { + setSelectedOperationIds(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] + ); + }; + + const handleCreate = async () => { + if (!batchType || !cellId || selectedOperationIds.length === 0) return; + + const nestingMetadata: NestingMetadata | undefined = efficiency + ? { efficiency_percent: parseFloat(efficiency) } + : undefined; + + await createBatch.mutateAsync({ + batch_type: batchType, + cell_id: cellId, + material: selectedSummary?.primaryMaterial || undefined, + thickness_mm: selectedSummary?.primaryThickness || undefined, + notes: notes || undefined, + nesting_metadata: nestingMetadata, + operation_ids: selectedOperationIds, + }); + + handleClose(); + }; + + const handleClose = () => { + setStep("type"); + setBatchType(null); + setCellId(preselectedCellId || ""); + setSelectedOperationIds(preselectedOperationIds || []); + setNotes(""); + setEfficiency(""); + onClose(); + }; + + const canProceed = () => { + switch (step) { + case "type": + return !!batchType; + case "cell": + return !!cellId; + case "operations": + return selectedOperationIds.length > 0; + case "details": + return true; + default: + return false; + } + }; + + const handleNext = () => { + switch (step) { + case "type": + setStep("cell"); + break; + case "cell": + setStep("operations"); + break; + case "operations": + setStep("details"); + break; + case "details": + handleCreate(); + break; + } + }; + + const handleBack = () => { + switch (step) { + case "cell": + setStep("type"); + break; + case "operations": + setStep("cell"); + break; + case "details": + setStep("operations"); + break; + } + }; + + return ( + + + + {t("batches.createBatch")} + + +
+ {/* Step 1: Select Batch Type */} + {step === "type" && ( +
+

{t("batches.selectBatchType")}

+
+ {(Object.keys(BATCH_TYPE_CONFIG) as BatchType[]).map((type) => { + const config = BATCH_TYPE_CONFIG[type]; + const Icon = BATCH_TYPE_ICONS[type]; + const isSelected = batchType === type; + return ( + + ); + })} +
+
+ )} + + {/* Step 2: Select Cell */} + {step === "cell" && ( +
+ + +
+ )} + + {/* Step 3: Select Operations */} + {step === "operations" && ( +
+
+ + {selectedSummary && ( + + {selectedSummary.count} {t("batches.operationsCount").toLowerCase()} + + )} +
+ + {loadingOperations ? ( +
{t("common.loading")}
+ ) : !groupableData?.materialGroups?.length ? ( +
+ {t("batches.noOperationsInBatch")} +
+ ) : ( + + + {groupableData.materialGroups.map((group, idx) => { + const groupOperationIds = group.operations.map(op => op.id); + const selectedInGroup = groupOperationIds.filter(id => selectedOperationIds.includes(id)).length; + const allSelected = selectedInGroup === group.operations.length; + + return ( + + +
+
+ handleSelectGroup(group.operations)} + onClick={(e) => e.stopPropagation()} + /> + {group.material} + {group.thickness_mm && ( + + {group.thickness_mm}mm + + )} +
+ + {selectedInGroup}/{group.operations.length} + +
+
+ +
+ {group.operations.map((op) => ( +
+ handleToggleOperation(op.id)} + /> +
+
+ + {op.part_number} + + + {op.operation_name} + +
+
+ {op.job_number} {op.customer && `• ${op.customer}`} +
+
+ {op.quantity && ( + + x{op.quantity} + + )} +
+ ))} +
+
+
+ ); + })} +
+
+ )} +
+ )} + + {/* Step 4: Details & Confirm */} + {step === "details" && ( +
+ {/* Summary */} + {selectedSummary && ( +
+
+ + + {selectedSummary.count} {t("batches.operationsCount").toLowerCase()} + +
+
+ {selectedSummary.materials.join(", ")} + {selectedSummary.thicknesses.length > 0 && ( + <> • {selectedSummary.thicknesses.join(", ")}mm + )} +
+
+ {t("batches.estimatedTime")}: {Math.round(selectedSummary.totalTime)} min +
+
+ )} + + + + {/* Optional: Nesting Efficiency */} + {(batchType === "laser_nesting" || batchType === "tube_batch") && ( +
+ + setEfficiency(e.target.value)} + placeholder="e.g. 85" + /> +
+ )} + + {/* Notes */} +
+ +