diff --git a/apps/erp/app/hooks/usePermissions.tsx b/apps/erp/app/hooks/usePermissions.tsx index 7a97da441..5a0cdb185 100644 --- a/apps/erp/app/hooks/usePermissions.tsx +++ b/apps/erp/app/hooks/usePermissions.tsx @@ -1,7 +1,7 @@ +import type { Role } from "@carbon/auth"; import { useRouteData } from "@carbon/remix"; import { useCallback } from "react"; import type { Permission } from "~/modules/users"; -import type { Role } from "~/types"; import { path } from "~/utils/path"; import { useUser } from "./useUser"; diff --git a/apps/erp/app/modules/items/ui/Consumables/useConsumableNavigation.tsx b/apps/erp/app/modules/items/ui/Consumables/useConsumableNavigation.tsx index fc4aaa141..5ca91e040 100644 --- a/apps/erp/app/modules/items/ui/Consumables/useConsumableNavigation.tsx +++ b/apps/erp/app/modules/items/ui/Consumables/useConsumableNavigation.tsx @@ -1,5 +1,6 @@ import { useParams } from "@remix-run/react"; +import type { Role } from "@carbon/auth"; import { LuBox, LuChartLine, @@ -8,7 +9,6 @@ import { LuTags, } from "react-icons/lu"; import { usePermissions, useRouteData } from "~/hooks"; -import type { Role } from "~/types"; import { path } from "~/utils/path"; import type { ConsumableSummary } from "../../types"; diff --git a/apps/erp/app/modules/items/ui/Materials/useMaterialNavigation.tsx b/apps/erp/app/modules/items/ui/Materials/useMaterialNavigation.tsx index 0e2f103ed..be53dd4b6 100644 --- a/apps/erp/app/modules/items/ui/Materials/useMaterialNavigation.tsx +++ b/apps/erp/app/modules/items/ui/Materials/useMaterialNavigation.tsx @@ -1,5 +1,6 @@ import { useParams } from "@remix-run/react"; +import type { Role } from "@carbon/auth"; import { LuBox, LuChartLine, @@ -8,7 +9,6 @@ import { LuTags, } from "react-icons/lu"; import { usePermissions, useRouteData } from "~/hooks"; -import type { Role } from "~/types"; import { path } from "~/utils/path"; import type { MaterialSummary } from "../../types"; diff --git a/apps/erp/app/modules/items/ui/Parts/usePartNavigation.tsx b/apps/erp/app/modules/items/ui/Parts/usePartNavigation.tsx index 54967b1f8..2e6cfb0be 100644 --- a/apps/erp/app/modules/items/ui/Parts/usePartNavigation.tsx +++ b/apps/erp/app/modules/items/ui/Parts/usePartNavigation.tsx @@ -1,5 +1,6 @@ import { useParams } from "@remix-run/react"; +import type { Role } from "@carbon/auth"; import { LuBox, LuChartLine, @@ -10,7 +11,6 @@ import { LuTags, } from "react-icons/lu"; import { usePermissions, useRouteData } from "~/hooks"; -import type { Role } from "~/types"; import { path } from "~/utils/path"; import type { PartSummary } from "../../types"; diff --git a/apps/erp/app/modules/items/ui/Tools/useToolNavigation.tsx b/apps/erp/app/modules/items/ui/Tools/useToolNavigation.tsx index c4f7c819b..c6a56281a 100644 --- a/apps/erp/app/modules/items/ui/Tools/useToolNavigation.tsx +++ b/apps/erp/app/modules/items/ui/Tools/useToolNavigation.tsx @@ -1,5 +1,6 @@ import { useParams } from "@remix-run/react"; +import type { Role } from "@carbon/auth"; import { LuBox, LuChartLine, @@ -9,7 +10,6 @@ import { LuTags, } from "react-icons/lu"; import { usePermissions, useRouteData } from "~/hooks"; -import type { Role } from "~/types"; import { path } from "~/utils/path"; import type { ToolSummary } from "../../types"; diff --git a/apps/erp/app/modules/purchasing/ui/PurchaseOrder/usePurchaseOrderTotals.ts b/apps/erp/app/modules/purchasing/ui/PurchaseOrder/usePurchaseOrderTotals.ts index a2c28b620..664bcbbf6 100644 --- a/apps/erp/app/modules/purchasing/ui/PurchaseOrder/usePurchaseOrderTotals.ts +++ b/apps/erp/app/modules/purchasing/ui/PurchaseOrder/usePurchaseOrderTotals.ts @@ -1,5 +1,5 @@ +import { useNanoStore } from "@carbon/remix"; import { atom } from "nanostores"; -import { useNanoStore } from "~/hooks"; const $totals = atom<{ total: number }>({ total: 0, diff --git a/apps/erp/app/modules/purchasing/ui/Supplier/SupplierSidebar/useSupplierSidebar.tsx b/apps/erp/app/modules/purchasing/ui/Supplier/SupplierSidebar/useSupplierSidebar.tsx index 8372902cc..3adad9459 100644 --- a/apps/erp/app/modules/purchasing/ui/Supplier/SupplierSidebar/useSupplierSidebar.tsx +++ b/apps/erp/app/modules/purchasing/ui/Supplier/SupplierSidebar/useSupplierSidebar.tsx @@ -1,15 +1,15 @@ +import type { Role } from "@carbon/auth"; import { useParams } from "@remix-run/react"; import { LuBuilding, + LuCog, LuContact, LuCreditCard, LuLayoutList, LuMapPin, LuPackageSearch, - LuCog, } from "react-icons/lu"; import { usePermissions } from "~/hooks"; -import type { Role } from "~/types"; import { path } from "~/utils/path"; type Props = { diff --git a/apps/erp/app/modules/sales/ui/Customer/CustomerSidebar/useCustomerSidebar.tsx b/apps/erp/app/modules/sales/ui/Customer/CustomerSidebar/useCustomerSidebar.tsx index 4c85abcfe..e20cebf16 100644 --- a/apps/erp/app/modules/sales/ui/Customer/CustomerSidebar/useCustomerSidebar.tsx +++ b/apps/erp/app/modules/sales/ui/Customer/CustomerSidebar/useCustomerSidebar.tsx @@ -1,3 +1,4 @@ +import type { Role } from "@carbon/auth"; import { useParams } from "@remix-run/react"; import { LuBuilding, @@ -12,7 +13,6 @@ import { RiProgress8Line, } from "react-icons/ri"; import { usePermissions } from "~/hooks"; -import type { Role } from "~/types"; import { path } from "~/utils/path"; type Props = { diff --git a/apps/erp/app/modules/sales/ui/SalesOrder/useSalesOrderTotals.ts b/apps/erp/app/modules/sales/ui/SalesOrder/useSalesOrderTotals.ts index b228a11cb..fd94df3d7 100644 --- a/apps/erp/app/modules/sales/ui/SalesOrder/useSalesOrderTotals.ts +++ b/apps/erp/app/modules/sales/ui/SalesOrder/useSalesOrderTotals.ts @@ -1,5 +1,5 @@ +import { useNanoStore } from "@carbon/remix"; import { atom } from "nanostores"; -import { useNanoStore } from "~/hooks"; const $totals = atom<{ total: number }>({ total: 0, diff --git a/apps/erp/app/root.tsx b/apps/erp/app/root.tsx index ba7dbc84d..8ae705394 100644 --- a/apps/erp/app/root.tsx +++ b/apps/erp/app/root.tsx @@ -26,7 +26,6 @@ import type { import { json } from "@vercel/remix"; import React, { useEffect } from "react"; import { getMode, setMode } from "~/services/mode.server"; -import Background from "~/styles/background.css?url"; import NProgress from "~/styles/nprogress.css?url"; import Tailwind from "~/styles/tailwind.css?url"; import { getTheme } from "./services/theme.server"; @@ -36,7 +35,6 @@ export const config = { runtime: "edge", regions: ["iad1"] }; export const links: LinksFunction = () => { return [ { rel: "stylesheet", href: Tailwind }, - { rel: "stylesheet", href: Background }, { rel: "stylesheet", href: NProgress }, ]; }; diff --git a/apps/erp/app/routes/x+/_index.tsx b/apps/erp/app/routes/x+/_index.tsx index d1a2e7516..efab8f648 100644 --- a/apps/erp/app/routes/x+/_index.tsx +++ b/apps/erp/app/routes/x+/_index.tsx @@ -1,5 +1,5 @@ import { Heading, cn } from "@carbon/react"; -import { getLocalTimeZone } from "@internationalized/date"; +import { getLocalTimeZone, now } from "@internationalized/date"; import { useLocale } from "@react-aria/i18n"; import { Link } from "@remix-run/react"; import { useMemo, type ComponentProps } from "react"; @@ -22,9 +22,23 @@ export default function AppIndexRoute() { [locale] ); + const greeting = useMemo(() => { + const time = now(getLocalTimeZone()); + + if (time.hour >= 3 && time.hour < 11) { + return "Good Morning"; + } else if (time.hour >= 11 && time.hour < 16) { + return "Good Afternoon"; + } else { + return "Good Evening"; + } + }, []); + return (
- Hello, {user.firstName} + + {greeting}, {user.firstName} + {formatter.format(date)}
diff --git a/apps/erp/app/stores/bom.ts b/apps/erp/app/stores/bom.ts index e2453f722..4e914a5e6 100644 --- a/apps/erp/app/stores/bom.ts +++ b/apps/erp/app/stores/bom.ts @@ -1,5 +1,5 @@ +import { useNanoStore } from "@carbon/remix"; import { atom } from "nanostores"; -import { useNanoStore } from "~/hooks"; const $bomStore = atom(null); export const useBom = () => useNanoStore($bomStore, "bom"); diff --git a/apps/erp/app/stores/customers.ts b/apps/erp/app/stores/customers.ts index 3cba9f97c..0ae8893bf 100644 --- a/apps/erp/app/stores/customers.ts +++ b/apps/erp/app/stores/customers.ts @@ -1,5 +1,5 @@ +import { useNanoStore } from "@carbon/remix"; import { atom } from "nanostores"; -import { useNanoStore } from "~/hooks"; import type { ListItem } from "~/types"; const $customersStore = atom([]); diff --git a/apps/erp/app/stores/items.ts b/apps/erp/app/stores/items.ts index 45b20f9e2..36bfcafe8 100644 --- a/apps/erp/app/stores/items.ts +++ b/apps/erp/app/stores/items.ts @@ -1,7 +1,7 @@ import type { Database } from "@carbon/database"; +import { useNanoStore } from "@carbon/remix"; import { useStore as useValue } from "@nanostores/react"; import { atom, computed } from "nanostores"; -import { useNanoStore } from "~/hooks"; import type { ListItem } from "~/types"; export type Item = ListItem & { diff --git a/apps/erp/app/stores/people.ts b/apps/erp/app/stores/people.ts index e91c3e1c6..493e5ac0c 100644 --- a/apps/erp/app/stores/people.ts +++ b/apps/erp/app/stores/people.ts @@ -1,5 +1,5 @@ +import { useNanoStore } from "@carbon/remix"; import { atom } from "nanostores"; -import { useNanoStore } from "~/hooks"; import type { ListItem } from "~/types"; const $peopleStore = atom<(ListItem & { avatarUrl: string | null })[]>([]); diff --git a/apps/erp/app/stores/suppliers.ts b/apps/erp/app/stores/suppliers.ts index f25c1ad28..af825713d 100644 --- a/apps/erp/app/stores/suppliers.ts +++ b/apps/erp/app/stores/suppliers.ts @@ -1,5 +1,5 @@ +import { useNanoStore } from "@carbon/remix"; import { atom } from "nanostores"; -import { useNanoStore } from "~/hooks"; import type { ListItem } from "~/types"; const $suppliersStore = atom([]); diff --git a/apps/erp/app/types/index.ts b/apps/erp/app/types/index.ts index b446f950d..e1af63c1c 100644 --- a/apps/erp/app/types/index.ts +++ b/apps/erp/app/types/index.ts @@ -1,3 +1,4 @@ +import type { Role } from "@carbon/auth"; import type { ValidationErrorResponseData } from "@carbon/form"; import type { FileObject } from "@supabase/storage-js"; import type { TypedResponse } from "@vercel/remix"; @@ -50,8 +51,6 @@ export type Result = { message?: string; }; -export type Role = "employee" | "customer" | "supplier"; - export type Route = { name: string; to: string; diff --git a/apps/mes/app/components/Feedback.tsx b/apps/mes/app/components/Feedback.tsx index e758a33ec..699a6b8fa 100644 --- a/apps/mes/app/components/Feedback.tsx +++ b/apps/mes/app/components/Feedback.tsx @@ -25,7 +25,7 @@ import type { ChangeEvent } from "react"; import { useEffect, useRef, useState } from "react"; import { LuImage, LuMessageCircle } from "react-icons/lu"; import type { action } from "~/routes/x+/feedback"; -import { feedbackValidator } from "~/services/models"; +import { feedbackValidator } from "~/services/shared.models"; import { path } from "~/utils/path"; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB in bytes diff --git a/apps/mes/app/components/Icons.tsx b/apps/mes/app/components/Icons.tsx index 30df9e407..bb25152ab 100644 --- a/apps/mes/app/components/Icons.tsx +++ b/apps/mes/app/components/Icons.tsx @@ -45,7 +45,7 @@ import { InProgressStatusIcon } from "~/assets/icons/InProgressStatusIcon"; import { LowPriorityIcon } from "~/assets/icons/LowPriorityIcon"; import { MediumPriorityIcon } from "~/assets/icons/MediumPriorityIcon"; import { TodoStatusIcon } from "~/assets/icons/TodoStatusIcon"; -import type { documentTypes } from "~/services/models"; +import type { documentTypes } from "~/services/shared.models"; import type { Operation } from "~/services/types"; type FileIconProps = { diff --git a/apps/mes/app/components/JobOperation.tsx b/apps/mes/app/components/JobOperation.tsx new file mode 100644 index 000000000..311b684dd --- /dev/null +++ b/apps/mes/app/components/JobOperation.tsx @@ -0,0 +1,5076 @@ +import { + Alert, + AlertDescription, + AlertTitle, + Avatar, + Badge, + Button, + Checkbox, + cn, + Combobox as ComboboxBase, + Copy, + DropdownMenu, + DropdownMenuContent, + DropdownMenuIcon, + DropdownMenuItem, + DropdownMenuTrigger, + Heading, + HStack, + IconButton, + Input, + InputGroup, + InputRightElement, + Loading, + Modal, + ModalBody, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalTitle, + ModelViewer, + NumberDecrementStepper, + NumberField, + NumberIncrementStepper, + NumberInput, + NumberInputGroup, + NumberInputStepper, + Progress, + ScrollArea, + Separator, + SidebarTrigger, + Skeleton, + SplitButton, + Switch, + Table, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Tbody, + Td, + Th, + Thead, + toast, + ToggleGroup, + ToggleGroupItem, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + Tr, + useDebounce, + useDisclosure, + useInterval, + useMount, + VStack, + type JSONContent, +} from "@carbon/react"; +import { generateHTML } from "@carbon/react/Editor"; +import { + Await, + useFetcher, + useNavigate, + useParams, + useRevalidator, +} from "@remix-run/react"; +import type { ComponentProps, ReactNode } from "react"; +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + DeadlineIcon, + FileIcon, + FilePreview, + OperationStatusIcon, +} from "~/components"; +import { useUrlParams, useUser } from "~/hooks"; +import type { productionEventType } from "~/services/shared.models"; +import { + finishValidator, + issueValidator, + nonScrapQuantityValidator, + productionEventValidator, + scrapQuantityValidator, + stepRecordValidator, +} from "~/services/shared.models"; +import type { + Job, + JobMakeMethod, + JobMaterial, + JobOperationAttribute, + JobOperationParameter, + Kanban, + OperationWithDetails, + ProductionEvent, + ProductionQuantity, + StorageItem, + TrackedEntity, + TrackedInput, +} from "~/services/types"; +import { path } from "~/utils/path"; + +import type { Result } from "@carbon/auth"; +import { useCarbon } from "@carbon/auth"; +import { + Combobox, + DateTimePicker, + Hidden, + Input as InputField, + Number, + NumberControlled, + Select, + Submit, + TextArea, + ValidatedForm, +} from "@carbon/form"; +import { useKeyboardWedge, useMode } from "@carbon/remix"; +import type { TrackedEntityAttributes } from "@carbon/utils"; +import { + convertDateStringToIsoString, + convertKbToString, + formatDate, + formatDateTime, + formatDurationMilliseconds, + formatRelativeTime, + getItemReadableId, + labelSizes, +} from "@carbon/utils"; +import { + getLocalTimeZone, + now, + parseAbsolute, + toZoned, +} from "@internationalized/date"; +import { useNumberFormatter } from "@react-aria/i18n"; +import type { + PostgrestSingleResponse, + RealtimeChannel, +} from "@supabase/supabase-js"; +import { nanoid } from "nanoid"; +import { flushSync } from "react-dom"; +import { FaTasks } from "react-icons/fa"; +import { FaCheck, FaPause, FaPlay, FaPlus, FaTrash } from "react-icons/fa6"; +import { + LuActivity, + LuAxis3D, + LuBarcode, + LuCheck, + LuChevronDown, + LuChevronLeft, + LuChevronRight, + LuChevronUp, + LuCircleCheck, + LuCirclePlus, + LuClipboardCheck, + LuDownload, + LuEllipsisVertical, + LuFile, + LuGitBranchPlus, + LuHammer, + LuHardHat, + LuList, + LuPaperclip, + LuPrinter, + LuQrCode, + LuSend, + LuSquareUser, + LuTimer, + LuTrash, + LuTriangleAlert, + LuUndo2, + LuX, +} from "react-icons/lu"; +import { + MethodIcon, + ProcedureStepTypeIcon, + TrackingTypeIcon, +} from "~/components/Icons"; +import type { + getBatchNumbersForItem, + getSerialNumbersForItem, +} from "~/services/inventory.service"; +import { getFileType } from "~/services/operations.service"; +import { useItems, usePeople } from "~/stores"; +import FileDropzone from "./FileDropzone"; +import ItemThumbnail from "./ItemThumbnail"; +import ScrapReason from "./ScrapReason"; + +type JobOperationProps = { + events: ProductionEvent[]; + files: Promise; + kanban: Kanban | null; + materials: Promise<{ + materials: JobMaterial[]; + trackedInputs: TrackedInput[]; + }>; + method: JobMakeMethod | null; + operation: OperationWithDetails; + procedure: Promise<{ + attributes: JobOperationAttribute[]; + parameters: JobOperationParameter[]; + }>; + job: Job; + thumbnailPath: string | null; + trackedEntities: TrackedEntity[]; + workCenter: Promise>; +}; + +export const JobOperation = ({ + events, + files, + job, + kanban, + materials, + method, + operation: originalOperation, + procedure, + thumbnailPath, + trackedEntities, + workCenter, +}: JobOperationProps) => { + const [params, setParams] = useUrlParams(); + + const trackedEntityParam = params.get("trackedEntityId"); + const trackedEntityId = trackedEntityParam ?? trackedEntities[0]?.id; + + const parentIsSerial = method?.requiresSerialTracking; + const parentIsBatch = method?.requiresBatchTracking; + + const serialIndex = + trackedEntities.findIndex((entity) => entity.id === trackedEntityId) ?? 0; + + const navigate = useNavigate(); + + const [items] = useItems(); + const { downloadFile, downloadModel, getFilePath } = useFiles(job); + + const attributeRecordModal = useDisclosure(); + const attributeRecordDeleteModal = useDisclosure(); + const [activeStep, setActiveStep] = useState( + parentIsSerial ? serialIndex : 0 + ); + const [hasMultipleRecords, setHasMultipleRecords] = useState(false); + + useEffect(() => { + if (parentIsSerial) { + setActiveStep(serialIndex); + } + }, [parentIsSerial, serialIndex]); + + const isModalOpen = + attributeRecordModal.isOpen || attributeRecordDeleteModal.isOpen; + + const { + availableEntities, + active, + activeTab, + completeModal, + eventType, + finishModal, + isOverdue, + issueModal, + laborProductionEvent, + machineProductionEvent, + operation, + progress, + reworkModal, + scrapModal, + serialModal, + selectedMaterial, + setActiveTab, + setEventType, + setSelectedMaterial, + setupProductionEvent, + } = useOperation({ + operation: originalOperation, + events, + trackedEntities, + pauseInterval: isModalOpen, + procedure, + }); + + const controlsHeight = useMemo(() => { + let operations = 1; + if (operation.setupDuration > 0) operations++; + if (operation.laborDuration > 0) operations++; + if (operation.machineDuration > 0) operations++; + return 40 + operations * 24; + }, [ + operation.laborDuration, + operation.machineDuration, + operation.setupDuration, + ]); + + const mode = useMode(); + const { operationId } = useParams(); + + const modelUpload = + job.modelPath || operation.itemModelPath + ? { + modelPath: operation.itemModelPath ?? job.modelPath, + modelId: operation.itemModelId ?? job.modelId, + modelName: operation.itemModelName ?? job.modelName, + modelSize: operation.itemModelSize ?? job.modelSize, + } + : null; + + const [selectedAttribute, setSelectedAttribute] = + useState(null); + + const onRecordAttributeRecord = (attribute: JobOperationAttribute) => { + flushSync(() => { + setSelectedAttribute(attribute); + }); + attributeRecordModal.onOpen(); + }; + + const onDeleteAttributeRecord = (attribute: JobOperationAttribute) => { + flushSync(() => { + setSelectedAttribute(attribute); + }); + attributeRecordDeleteModal.onOpen(); + }; + + const onDeselectAttribute = () => { + setSelectedAttribute(null); + attributeRecordModal.onClose(); + attributeRecordDeleteModal.onClose(); + }; + + const navigateToTrackingLabels = ( + zpl?: boolean, + { + labelSize, + trackedEntityId, + }: { labelSize?: string; trackedEntityId?: string } = {} + ) => { + if (!window) return; + if (!operationId) return; + + if (zpl) { + window.open( + window.location.origin + + path.to.file.operationLabelsZpl(operationId, { + labelSize, + trackedEntityId, + }), + "_blank" + ); + } else { + window.open( + window.location.origin + + path.to.file.operationLabelsPdf(operationId, { + labelSize, + trackedEntityId, + }), + "_blank" + ); + } + }; + + const completeFetcher = useFetcher(); + useKeyboardWedge({ + test: (input) => { + if (kanban?.completedBarcodeOverride) { + return input === kanban.completedBarcodeOverride; + } else if (kanban?.id) { + return input === path.to.kanbanComplete(kanban.id); + } + return false; + }, + callback: () => { + completeFetcher.load(path.to.endOperation(operation.id)); + }, + active: !!kanban?.id, + }); + + return ( + <> + +
+ +
+ + + +
+
+ + + Details + + + Model + + + Procedure + + + Chat + + +
+
+
+ +
+ + {operation.jobReadableId} + + } + /> + + + + + {job.customer?.name && ( + + + {job.customer.name} + + )} + {operation.description && ( + + + + {operation.description} + + + )} + {operation.operationStatus && ( + + + + {operation.jobStatus === "Paused" + ? "Paused" + : operation.operationStatus} + + + )} + {typeof operation.duration === "number" && ( + + + + {formatDurationMilliseconds(operation.duration)} + + + )} + {operation.jobDeadlineType && ( + + + + + {["ASAP", "No Deadline"].includes(operation.jobDeadlineType) + ? operation.jobDeadlineType + : operation.jobDueDate + ? `Due ${formatRelativeTime( + convertDateStringToIsoString(operation.jobDueDate) + )}` + : "–"} + + + )} + +
+ + + + +
+ + {thumbnailPath && ( + + )} +
+ + {operation.description} + +

+ {operation.itemDescription}{" "} +

+
+
+
+ + {formatDurationMilliseconds( + ((progress.setup ?? 0) + + (progress.labor ?? 0) + + (progress.machine ?? 0)) / + Math.max(operation.quantityComplete, 1), + { + style: "short", + } + )} + +

+ {operation.itemUnitOfMeasure} +

+
+
+ +
+
+
+
+

+ Completed +

+ +
+
+ + {operation.quantityComplete} of{" "} + {operation.operationQuantity} + +
+
+
+
+

+ Scrapped +

+ +
+
+ {operation.quantityScrapped} +
+
+
+
+

+ Due Date +

+ +
+
+ + + {["ASAP", "No Deadline"].includes( + operation.jobDeadlineType + ) + ? operation.jobDeadlineType + : operation.jobDueDate + ? `Due ${formatRelativeTime( + convertDateStringToIsoString(operation.jobDueDate) + )}` + : "–"} + + + {operation.jobDueDate + ? formatDate(operation.jobDueDate) + : null} + + +
+
+
+
+ + + + {(resolvedProcedure) => { + const { attributes, parameters } = resolvedProcedure; + + return ( + <> + {attributes.length > 0 && ( + <> + +
+
+ + Steps +
+ {attributes.length > 0 && + (() => { + const maxRecords = parentIsSerial + ? trackedEntities.length + : operation.operationQuantity + + operation.quantityScrapped; + + const isRecordSetStarted = + recordSetIsStarted( + attributes, + activeStep + ); + + const canCreateNewRecord = + !parentIsSerial && isRecordSetStarted; + + const canNavigateNext = + isRecordSetStarted && + activeStep < + operation.operationQuantity + + operation.quantityScrapped - + 1; + + const showNavigation = + hasMultipleRecords || + attributes.some( + (att) => + att.jobOperationStepRecord.length > + 1 + ); + + return ( +
+
+ {showNavigation && + !parentIsSerial && ( + <> + } + onClick={() => { + setActiveStep( + activeStep - 1 + ); + }} + isDisabled={ + activeStep === 0 + } + /> + + Record {activeStep + 1} + + } + onClick={() => { + setActiveStep( + activeStep + 1 + ); + }} + isDisabled={ + !canNavigateNext + } + /> + + )} + {canCreateNewRecord && + !showNavigation && ( + + )} + {parentIsSerial && ( + + {serialIndex + 1} of{" "} + {operation.operationQuantity} + + )} +
+ + + a.jobOperationStepRecord.some( + (r) => r.index === activeStep + ) + ).length / + attributes.length) * + 100 + } + className="h-2 w-24" + /> + + { + attributes.filter((a) => + a.jobOperationStepRecord.some( + (r) => r.index === activeStep + ) + ).length + }{" "} + of {attributes.length} complete + +
+ ); + })()} +
+
+
+ {attributes + .sort( + (a, b) => + (a.sortOrder ?? 0) - (b.sortOrder ?? 0) + ) + .map((a, index) => ( + + ))} +
+
+
+ + )} + {parameters.length > 0 && ( + <> + +
+
+ + Process Parameters + +
+ {parameters + .sort((a, b) => + (a.key ?? "").localeCompare(b.key ?? "") + ) + .map((p, index) => ( + + ))} +
+
+
+ + )} + + ); + }} +
+
+ + +
+
+ + Materials + + + } + > + + {(resolvedMaterials) => { + const baseMaterials = resolvedMaterials?.materials.filter( + (m) => !m.isKitComponent + ); + + const kitMaterialsByParentId = + resolvedMaterials?.materials + .filter((m) => m.isKitComponent ?? false) + .reduce((acc, material) => { + if (material.kitParentId) { + if (!acc[material.kitParentId]) { + acc[material.kitParentId] = []; + } + acc[material.kitParentId].push(material); + } + return acc; + }, {} as Record); + + return ( + <> + + + + + + + + + + + {baseMaterials.length === 0 ? ( + + + + ) : ( + baseMaterials.map((material) => { + const isRelatedToOperation = + material.jobOperationId === operationId; + const kittedChildren = material.id + ? kitMaterialsByParentId[material.id] + : []; + + return ( + <> + + + + + + + + + + {kittedChildren && + kittedChildren.map( + (kittedChild, index) => ( + + + + + + + + + ) + )} + + ); + }) + )} + +
PartMethodEstimatedActual +
+ No materials +
+ + + + {getItemReadableId( + items, + material.itemId ?? "" + )} + + + {material.description} + + + {material.requiresBatchTracking ? ( + + + + ) : material.requiresSerialTracking ? ( + + + + ) : null} + + + + + {material.methodType === "Make" && + material.kit + ? "Kit" + : material.methodType} + + + {parentIsSerial && + (material.requiresBatchTracking || + material.requiresSerialTracking) + ? `${material.quantity}/${material.estimatedQuantity}` + : material.estimatedQuantity} + + {material.methodType === "Make" && + material.requiresBatchTracking === + false && + material.requiresSerialTracking === + false ? ( + + ) : parentIsSerial && + (material.requiresBatchTracking || + material.requiresSerialTracking) ? ( + `${material.quantityIssued}/${material.quantity}` + ) : ( + material.quantityIssued + )} + + {material.methodType !== "Make" && + material.requiresBatchTracking === + false && + material.requiresSerialTracking === + false && ( + } + className="h-8 w-8" + onClick={() => { + flushSync(() => { + setSelectedMaterial( + material + ); + }); + issueModal.onOpen(); + }} + /> + )} + {(material.requiresBatchTracking || + material.requiresSerialTracking) && ( + } + className="h-8 w-8" + onClick={() => { + flushSync(() => { + setSelectedMaterial(material); + }); + issueModal.onOpen(); + }} + /> + )} +
+ + + + {getItemReadableId( + items, + kittedChild.itemId + )} + + + {kittedChild.description} + + + {kittedChild.requiresBatchTracking ? ( + + + + ) : kittedChild.requiresSerialTracking ? ( + + + + ) : null} + + + + + {kittedChild.methodType === + "Make" && kittedChild.kit + ? "Kit" + : kittedChild.methodType} + + + {parentIsSerial && + (kittedChild.requiresBatchTracking || + kittedChild.requiresSerialTracking) + ? `${kittedChild.quantity}/${kittedChild.estimatedQuantity}` + : kittedChild.estimatedQuantity} + + {kittedChild.methodType === + "Make" && + kittedChild.requiresBatchTracking === + false && + kittedChild.requiresSerialTracking === + false ? ( + + ) : parentIsSerial && + (kittedChild.requiresBatchTracking || + kittedChild.requiresSerialTracking) ? ( + `${kittedChild.quantityIssued}/${kittedChild.quantity}` + ) : ( + kittedChild.quantityIssued + )} + + {kittedChild.methodType !== + "Make" && + kittedChild.requiresBatchTracking === + false && + kittedChild.requiresSerialTracking === + false && ( + } + className="h-8 w-8" + onClick={() => { + flushSync(() => { + setSelectedMaterial( + kittedChild + ); + }); + issueModal.onOpen(); + }} + /> + )} + {(kittedChild.requiresBatchTracking || + kittedChild.requiresSerialTracking) && ( + } + className="h-8 w-8" + onClick={() => { + flushSync(() => { + setSelectedMaterial( + kittedChild + ); + }); + issueModal.onOpen(); + }} + /> + )} +
+ {issueModal.isOpen && + selectedMaterial?.requiresBatchTracking !== true && + selectedMaterial?.requiresSerialTracking !== + true && ( + { + setSelectedMaterial(null); + issueModal.onClose(); + }} + /> + )} + {issueModal.isOpen && + selectedMaterial?.requiresBatchTracking === + true && ( + { + setSelectedMaterial(null); + issueModal.onClose(); + }} + /> + )} + {issueModal.isOpen && + selectedMaterial?.requiresSerialTracking === + true && ( + { + setSelectedMaterial(null); + issueModal.onClose(); + }} + /> + )} + + ); + }} +
+
+
+
+ + +
+
+ Files +

+ Files related to the job and the opportunity line. +

+ } + > + + {(resolvedFiles) => ( + + + + + + + + + + {resolvedFiles.length === 0 && !modelUpload ? ( + + + + ) : ( + <> + {modelUpload?.modelName && ( + + + + + + )} + {resolvedFiles.map((file) => { + const type = getFileType(file.name); + return ( + + + + + + ); + })} + + )} + +
NameSize
+ No files +
+ + + {modelUpload.modelName} + + + {modelUpload.modelSize + ? convertKbToString( + Math.floor( + (modelUpload.modelSize ?? 0) / 1024 + ) + ) + : "--"} + +
+ + + } + variant="secondary" + /> + + + + downloadModel(modelUpload) + } + > + } + /> + Download + + + +
+
+ + + { + if ( + ["PDF", "Image"].includes(type) + ) { + window.open( + path.to.file.previewFile( + `${"private"}/${getFilePath( + file + )}` + ), + "_blank" + ); + } + }} + > + {["PDF", "Image"].includes(type) ? ( + + {file.name} + + ) : ( + file.name + )} + + + + {convertKbToString( + Math.floor( + (file.metadata?.size ?? 0) / 1024 + ) + )} + +
+ + + } + variant="secondary" + /> + + + downloadFile(file)} + > + } + /> + Download + + + +
+
+ )} +
+
+
+
+ + {parentIsSerial && ( + <> + +
+
+ + Serial Numbers + {trackedEntities?.length > 0 && ( + + } + dropdownItems={labelSizes.map((size) => ({ + label: size.name, + onClick: () => + navigateToTrackingLabels(!!size.zpl, { + labelSize: size.id, + }), + }))} + // TODO: if we knew the preferred label size, we could use that here + onClick={() => navigateToTrackingLabels(false)} + > + Tracking Labels + + + + )} + + + + + + + + + + {trackedEntities?.length === 0 ? ( + + + + ) : ( + trackedEntities?.map((entity) => ( + + + + + + )) + )} + +
Serial +
+ + No serial numbers +
+ {entity.id} + {entity.id === trackedEntityId && ( + + )} + + +
+ } + variant="secondary" + onClick={() => { + navigateToTrackingLabels(false, { + trackedEntityId: entity.id, + }); + }} + /> + +
+
+
+
+ + )} +
+
+ +
+ +
+
+ +
+ + + {(resolvedProcedure) => { + const { attributes, parameters } = resolvedProcedure; + if (attributes.length === 0 && parameters.length === 0) + return null; + + return ( + + +
+ + Steps + + Parameters + + +
+ + + {attributes.length > 0 && + (() => { + const maxRecords = parentIsSerial + ? trackedEntities.length + : operation.operationQuantity + + operation.quantityScrapped; + + const isRecordSetStarted = recordSetIsStarted( + attributes, + activeStep + ); + + const canCreateNewRecord = + !parentIsSerial && isRecordSetStarted; + + const canNavigateNext = + isRecordSetStarted && + activeStep < + operation.operationQuantity + + operation.quantityScrapped - + 1; + + const showNavigation = + hasMultipleRecords || + attributes.some( + (att) => + att.jobOperationStepRecord.length > 1 + ); + + return ( +
+
+ {showNavigation && !parentIsSerial && ( + <> + } + onClick={() => { + setActiveStep(activeStep - 1); + }} + isDisabled={activeStep === 0} + /> + + Record {activeStep + 1} + + } + onClick={() => { + setActiveStep(activeStep + 1); + }} + isDisabled={!canNavigateNext} + /> + + )} + {canCreateNewRecord && + !showNavigation && ( + + )} + {parentIsSerial && ( + + {serialIndex + 1} of{" "} + {operation.operationQuantity} + + )} +
+ +
+ + a.jobOperationStepRecord.some( + (r) => r.index === activeStep + ) + ).length / + attributes.length) * + 100 + } + className="h-2 w-24" + /> + + { + attributes.filter((a) => + a.jobOperationStepRecord.some( + (r) => r.index === activeStep + ) + ).length + }{" "} + of {attributes.length} completed + +
+
+ ); + })()} + {attributes.length > 0 && ( + <> +
+
+
+ {attributes + .sort( + (a, b) => + (a.sortOrder ?? 0) - + (b.sortOrder ?? 0) + ) + .map((a, index) => ( + + ))} +
+
+
+ + )} +
+
+ + + {parameters.length > 0 && ( + <> + +
+
+
+ {parameters + .sort((a, b) => + (a.key ?? "").localeCompare( + b.key ?? "" + ) + ) + .map((p, index) => ( + + ))} +
+
+
+ + )} +
+
+
+
+ ); + }} +
+
+ + +
+ +
+ + + + + {!["chat", "procedure"].includes(activeTab) && ( + +
+ + + + Work Center + + ...} + key={`work-center-${operationId}`} + > + + {(resolvedWorkCenter) => + resolvedWorkCenter.data && ( + + {resolvedWorkCenter.data?.name} + + ) + } + + + + + + Item + + {operation.itemReadableId} + + + + +
+ + Job + + + + {operation.jobReadableId} + + + + {job.customer?.name && ( + + + Customer + + + + + {job.customer.name} + + + + )} + + {operation.description && ( + + + Description + + + + + {operation.description} + + + + )} + {operation.jobDeadlineType && ( + + + Deadline + + + + + + {["ASAP", "No Deadline"].includes( + operation.jobDeadlineType + ) + ? operation.jobDeadlineType + : operation.jobDueDate + ? `Due ${formatRelativeTime( + convertDateStringToIsoString(operation.jobDueDate) + )}` + : "–"} + + + + )} +
+ + + + +
+ {/* + } + tooltip="Log Rework" + onClick={reworkModal.onOpen} + /> + */} + + entity.id === trackedEntityId && + `Operation ${operationId}` in + (entity.attributes as TrackedEntityAttributes) + ) + } + icon={ + + } + tooltip="Log Scrap" + onClick={scrapModal.onOpen} + /> + + + entity.id === trackedEntityId && + `Operation ${operationId}` in + (entity.attributes as TrackedEntityAttributes) + ) + } + icon={ + + } + tooltip="Log Completed" + onClick={completeModal.onOpen} + /> + } + variant={ + operation.quantityComplete === operation.operationQuantity + ? "success" + : "default" + } + tooltip="Close Out" + onClick={finishModal.onOpen} + /> +
+
+
+ )} + {!["chat"].includes(activeTab) && ( + +
+
+ {operation.setupDuration > 0 && ( + + + + + + Setup + + operation.setupDuration + ? "bg-red-500" + : "" + } + /> + + )} + {operation.laborDuration > 0 && ( + + + + + + Labor + + operation.laborDuration + ? "bg-red-500" + : "" + } + /> + + )} + {operation.machineDuration > 0 && ( + + + + + + Machine + + operation.machineDuration + ? "bg-red-500" + : "" + } + /> + + )} + + + + + + Quantity + + + +
+
+
+ )} + + {reworkModal.isOpen && ( + + )} + {scrapModal.isOpen && ( + + )} + {completeModal.isOpen && ( + + + {(resolvedMaterials) => { + return ( + + ); + }} + + + )} + {/* @ts-ignore */} + {finishModal.isOpen && ( + + + {(resolvedProcedure) => { + const { attributes } = resolvedProcedure; + const allAttributesRecorded = attributes.every( + (a) => a.jobOperationStepRecord !== null + ); + return ( + + ); + }} + + + )} + + {serialModal.isOpen && ( + navigate(path.to.operations)} + onSelect={(entity) => { + const entityIndex = availableEntities.findIndex( + (e) => e.id === entity.id + ); + if (entityIndex !== -1) { + setActiveStep(entityIndex); + } + setParams({ + trackedEntityId: entity.id, + }); + serialModal.onClose(); + }} + /> + )} + + {attributeRecordModal.isOpen && selectedAttribute ? ( + + ) : null} + + {attributeRecordDeleteModal.isOpen && selectedAttribute && ( + r.index === activeStep + )?.id ?? "" + } + title="Delete Step" + description="Are you sure you want to delete this step?" + /> + )} + + ); +}; + +type Message = { + id: string; + createdBy: string; + createdAt: string; + note: string; +}; + +function recordSetIsStarted( + attributes: JobOperationAttribute[], + activeStep: number +) { + return attributes.some((att) => + att.jobOperationStepRecord.some( + (record) => + record.index === activeStep && + (record.value !== null || + record.numericValue !== null || + record.booleanValue !== null || + record.userValue !== null) + ) + ); +} + +function OperationChat({ operation }: { operation: OperationWithDetails }) { + const user = useUser(); + const [employees] = usePeople(); + const [messages, setMessages] = useState([]); + + const [isLoading, setIsLoading] = useState(false); + const { carbon, accessToken } = useCarbon(); + + const fetchChats = async () => { + if (!carbon) return; + flushSync(() => { + setIsLoading(true); + }); + + const { data, error } = await carbon + ?.from("jobOperationNote") + .select("*") + .eq("jobOperationId", operation.id) + .order("createdAt", { ascending: true }); + + if (error) { + console.error(error); + return; + } + setMessages(data); + setIsLoading(false); + }; + + useMount(() => { + fetchChats(); + }); + + const channelRef = useRef(null); + + useMount(() => { + if (!channelRef.current && carbon && accessToken) { + carbon.realtime.setAuth(accessToken); + channelRef.current = carbon + .channel(`job-operation-notes-${operation.id}`) + .on( + "postgres_changes", + { + event: "INSERT", + schema: "public", + table: "jobOperationNote", + filter: `jobOperationId=eq.${operation.id}`, + }, + (payload) => { + setMessages((prev) => { + if (prev.some((note) => note.id === payload.new.id)) { + return prev; + } + return [...prev, payload.new as Message]; + }); + } + ) + .subscribe(); + } + + return () => { + if (channelRef.current) { + channelRef.current.unsubscribe(); + carbon?.removeChannel(channelRef.current); + channelRef.current = null; + } + }; + }); + + useEffect(() => { + if (carbon && accessToken && channelRef.current) + carbon.realtime.setAuth(accessToken); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accessToken]); + + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ + block: "start", + behavior: messages.length > 0 ? "smooth" : "auto", + }); + }, [messages]); + + const [message, setMessage] = useState(""); + + const notify = useDebounce( + async () => { + if (!carbon) return; + + const response = await fetch(path.to.messagingNotify, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + type: "jobOperationNote", + operationId: operation.id, + }), + credentials: "include", // This is sufficient for CORS with cookies + }); + + if (!response.ok) { + console.error("Failed to notify user"); + } + }, + 5000, + true + ); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!message.trim()) return; + + const newMessage = { + id: nanoid(), + jobOperationId: operation.id, + createdBy: user.id, + note: message, + createdAt: new Date().toISOString(), + companyId: user.company.id, + }; + + flushSync(() => { + setMessages((prev) => [...prev, newMessage]); + setMessage(""); + }); + + await Promise.all([ + carbon?.from("jobOperationNote").insert(newMessage), + notify(), + ]); + }; + + return ( +
+ + +
+ {messages.map((m) => { + const createdBy = employees.find( + (employee) => employee.id === m.createdBy + ); + const isUser = m.createdBy === user.id; + return ( +
+ + +
+
+ {!isUser && ( + + {createdBy?.name} + + )} +
+

{m.note}

+ + + {new Date(m.createdAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + +
+
+
+
+ ); + })} +
+
+ + + +
+
+ setMessage(e.target.value)} + /> + +
+
+
+ ); +} + +function useOperation({ + operation, + events, + trackedEntities, + pauseInterval, + procedure, +}: { + operation: OperationWithDetails; + events: ProductionEvent[]; + trackedEntities: TrackedEntity[]; + pauseInterval: boolean; + procedure: Promise<{ + attributes: JobOperationAttribute[]; + parameters: JobOperationParameter[]; + }>; +}) { + const [params] = useUrlParams(); + const trackedEntityParam = params.get("trackedEntityId"); + const { carbon, accessToken } = useCarbon(); + const user = useUser(); + const revalidator = useRevalidator(); + const channelRef = useRef(null); + + const scrapModal = useDisclosure(); + const reworkModal = useDisclosure(); + const completeModal = useDisclosure(); + const finishModal = useDisclosure(); + const issueModal = useDisclosure(); + const serialModal = useDisclosure(); + + // we do this to avoid re-rendering when the modal is open + const isAnyModalOpen = + pauseInterval || + scrapModal.isOpen || + reworkModal.isOpen || + completeModal.isOpen || + finishModal.isOpen || + issueModal.isOpen || + serialModal.isOpen; + + const [selectedMaterial, setSelectedMaterial] = useState( + null + ); + + const [activeTab, setActiveTab] = useState("details"); + const [eventType, setEventType] = useState(() => { + if (operation.setupDuration > 0) { + return "Setup"; + } + if (operation.machineDuration > 0) { + return "Machine"; + } + return "Labor"; + }); + + const [operationState, setOperationState] = useState(operation); + + const [eventState, setEventState] = useState(events); + + useEffect(() => { + setEventState(events); + }, [events]); + + useEffect(() => { + setOperationState(operation); + }, [operation]); + + useMount(() => { + if (!channelRef.current && carbon && accessToken) { + carbon.realtime.setAuth(accessToken); + channelRef.current = carbon + .channel(`job-operations:${operation.id}`) + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "job", + filter: `id=eq.${operation.jobId}`, + }, + (payload) => { + if (payload.eventType === "UPDATE") { + revalidator.revalidate(); + } + } + ) + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "productionEvent", + filter: `jobOperationId=eq.${operation.id}`, + }, + (payload) => { + switch (payload.eventType) { + case "INSERT": + const { new: inserted } = payload; + setEventState((prevEvents) => [ + ...prevEvents, + inserted as ProductionEvent, + ]); + break; + case "UPDATE": + const { new: updated } = payload; + + setEventState((prevEvents) => + prevEvents.map((event) => + event.id === updated.id + ? ({ + ...event, + ...updated, + } as ProductionEvent) + : event + ) + ); + break; + case "DELETE": + const { old: deleted } = payload; + setEventState((prevEvents) => + prevEvents.filter((event) => event.id !== deleted.id) + ); + break; + default: + break; + } + } + ) + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "jobOperation", + filter: `id=eq.${operation.id}`, + }, + (payload) => { + if (payload.eventType === "UPDATE") { + const updated = payload.new; + setOperationState((prev) => ({ + ...prev, + ...updated, + operationStatus: updated.status ?? prev.operationStatus, + })); + } else if (payload.eventType === "DELETE") { + toast.error("This operation has been deleted"); + window.location.href = path.to.operations; + } + } + ) + .subscribe(); + } + + return () => { + if (channelRef.current) { + channelRef.current.unsubscribe(); + carbon?.removeChannel(channelRef.current); + channelRef.current = null; + } + }; + }); + + useEffect(() => { + if (carbon && accessToken && channelRef.current) + carbon.realtime.setAuth(accessToken); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accessToken]); + + const getProgress = useCallback(() => { + const timeNow = now(getLocalTimeZone()); + return eventState.reduce( + (acc, event) => { + if (event.endTime && event.type) { + acc[event.type.toLowerCase() as keyof typeof acc] += + (event.duration ?? 0) * 1000; + } else if (event.startTime && event.type) { + const startTime = toZoned( + parseAbsolute(event.startTime, getLocalTimeZone()), + getLocalTimeZone() + ); + + const difference = timeNow.compare(startTime); + + if (difference > 0) { + acc[event.type.toLowerCase() as keyof typeof acc] += difference; + } + } + return acc; + }, + { + setup: 0, + labor: 0, + machine: 0, + } + ); + }, [eventState]); + + const [progress, setProgress] = useState<{ + setup: number; + labor: number; + machine: number; + }>(getProgress); + + const activeEvents = useMemo(() => { + return { + setupProductionEvent: events.find( + (e) => + e.type === "Setup" && e.endTime === null && e.employeeId === user.id + ), + laborProductionEvent: events.find( + (e) => + e.type === "Labor" && e.endTime === null && e.employeeId === user.id + ), + machineProductionEvent: eventState.find( + (e) => e.type === "Machine" && e.endTime === null + ), + }; + }, [eventState, events, user.id]); + + const active = useMemo(() => { + return { + setup: !!activeEvents.setupProductionEvent, + labor: !!activeEvents.laborProductionEvent, + machine: !!activeEvents.machineProductionEvent, + }; + }, [activeEvents]); + + useInterval( + () => { + setProgress(getProgress()); + }, + (active.setup || active.labor || active.machine) && !isAnyModalOpen + ? 1000 + : null + ); + + const { operationId } = useParams(); + const [availableEntities, setAvailableEntities] = useState( + [] + ); + // show the serial selector with the remaining serial numbers for the operation + useEffect(() => { + if (trackedEntityParam) return; + const uncompletedEntities = trackedEntities.filter( + (entity) => + !( + `Operation ${operationId}` in + ((entity.attributes as TrackedEntityAttributes) ?? {}) + ) + ); + if (uncompletedEntities.length > 0) serialModal.onOpen(); + setAvailableEntities(uncompletedEntities); + // causes an infinite loop on navigation + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [trackedEntities, trackedEntityParam]); + + return { + active, + availableEntities, + hasActiveEvents: + progress.setup > 0 || progress.labor > 0 || progress.machine > 0, + ...activeEvents, + progress, + operation: operationState, + + activeTab, + eventType, + scrapModal, + reworkModal, + completeModal, + finishModal, + issueModal, + serialModal, + isOverdue: operation.jobDueDate + ? new Date(operation.jobDueDate) < new Date() + : false, + selectedMaterial, + setSelectedMaterial, + setActiveTab, + setEventType, + }; +} + +function useFiles(job: Job) { + const user = useUser(); + + const getFilePath = useCallback( + (file: StorageItem) => { + const companyId = user.company.id; + const { bucket } = file; + let id: string | null = ""; + + switch (bucket) { + case "job": + id = job.id; + break; + case "opportunity-line": + id = job.salesOrderLineId ?? job.quoteLineId; + break; + case "parts": + id = job.itemId; + break; + } + + return `${companyId}/${bucket}/${id}/${file.name}`; + }, + [job.id, job.itemId, job.quoteLineId, job.salesOrderLineId, user.company.id] + ); + + const downloadFile = useCallback( + async (file: StorageItem) => { + const url = path.to.file.previewFile(`private/${getFilePath(file)}`); + try { + const response = await fetch(url); + const blob = await response.blob(); + const blobUrl = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + document.body.appendChild(a); + a.href = blobUrl; + a.download = file.name; + a.click(); + window.URL.revokeObjectURL(blobUrl); + document.body.removeChild(a); + } catch (error) { + toast.error("Error downloading file"); + console.error(error); + } + }, + [getFilePath] + ); + + const downloadModel = useCallback( + async (model: { modelPath: string; modelName: string }) => { + if (!model.modelPath || !model.modelName) { + toast.error("Model data is missing"); + return; + } + + const url = path.to.file.previewFile(`private/${model.modelPath}`); + try { + const response = await fetch(url); + const blob = await response.blob(); + const blobUrl = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + document.body.appendChild(a); + a.href = blobUrl; + a.download = model.modelName; + a.click(); + window.URL.revokeObjectURL(blobUrl); + document.body.removeChild(a); + } catch (error) { + toast.error("Error downloading file"); + console.error(error); + } + }, + [] + ); + + return { + downloadFile, + downloadModel, + getFilePath, + }; +} + +function TableSkeleton() { + return ( + + + + + + + + + {[...Array(5)].map((_, index) => ( + + + + + ))} + +
+ + + +
+ + + +
+ ); +} + +function Controls({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +function Times({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( + +
+ {children} +
+
+ ); +} + +function ButtonWithTooltip({ + tooltip, + children, + ...props +}: ComponentProps<"button"> & { tooltip: string }) { + return ( + + + + + {tooltip} + + ); +} + +function IconButtonWithTooltip({ + icon, + tooltip, + disabled, + variant, + ...props +}: ComponentProps<"button"> & { + icon: ReactNode; + tooltip: string; + variant?: "default" | "success" | "destructive"; + disabled?: boolean; +}) { + return ( + + {icon} + + ); +} + +function WorkTypeToggle({ + active, + operation, + value, + onChange, + className, +}: { + active: { setup: boolean; labor: boolean; machine: boolean }; + operation: OperationWithDetails; + value: string; + onChange: (type: string) => void; + className?: string; +}) { + const count = useMemo(() => { + let count = 0; + if (operation.setupDuration > 0) { + count++; + } + if (operation.laborDuration > 0) { + count++; + } + if (operation.machineDuration > 0) { + count++; + } + return count; + }, [ + operation.laborDuration, + operation.machineDuration, + operation.setupDuration, + ]); + + return ( + + {operation.setupDuration > 0 && ( + + + Setup + {active.setup && ( + + )} + + )} + {operation.laborDuration > 0 && ( + + + Labor + {active.labor && ( + + )} + + )} + {operation.machineDuration > 0 && ( + + + Machine + {active.machine && ( + + )} + + )} + + ); +} + +const startStopFormId = "start-stop-form"; +function StartStopButton({ + className, + job, + operation, + eventType, + setupProductionEvent, + laborProductionEvent, + machineProductionEvent, + isTrackedActivity, + trackedEntityId, + ...props +}: ComponentProps<"button"> & { + eventType: (typeof productionEventType)[number]; + job: Job; + operation: OperationWithDetails; + setupProductionEvent: ProductionEvent | undefined; + laborProductionEvent: ProductionEvent | undefined; + machineProductionEvent: ProductionEvent | undefined; + isTrackedActivity: boolean; + trackedEntityId: string | undefined; +}) { + const fetcher = useFetcher(); + + const isActive = useMemo(() => { + if (fetcher.formData?.get("action") === "End") { + return false; + } + if (eventType === "Setup") { + return ( + (fetcher.formData?.get("action") === "Start" && + fetcher.formData.get("type") === "Setup") || + !!setupProductionEvent + ); + } + if (eventType === "Labor") { + return ( + (fetcher.formData?.get("action") === "Start" && + fetcher.formData.get("type") === "Labor") || + !!laborProductionEvent + ); + } + return ( + (fetcher.formData?.get("action") === "Start" && + fetcher.formData.get("type") === "Machine") || + !!machineProductionEvent + ); + }, [ + eventType, + setupProductionEvent, + laborProductionEvent, + machineProductionEvent, + fetcher.formData, + ]); + + const id = useMemo(() => { + if (eventType === "Setup") { + return setupProductionEvent?.id; + } + if (eventType === "Labor") { + return laborProductionEvent?.id; + } + return machineProductionEvent?.id; + }, [ + eventType, + setupProductionEvent, + laborProductionEvent, + machineProductionEvent, + ]); + + return ( + + + {isTrackedActivity && ( + + )} + + + + + + + {isActive ? ( + + ) : ( + + )} + + ); +} + +function PauseButton({ className, ...props }: ComponentProps<"button">) { + return ( + + + + ); +} + +function PlayButton({ className, ...props }: ComponentProps<"button">) { + return ( + + + + ); +} + +function QuantityModal({ + allAttributesRecorded = true, + laborProductionEvent, + machineProductionEvent, + materials = [], + operation, + parentIsSerial = false, + parentIsBatch = false, + setupProductionEvent, + trackedEntityId, + type, + onClose, +}: { + allAttributesRecorded?: boolean; + laborProductionEvent: ProductionEvent | undefined; + machineProductionEvent: ProductionEvent | undefined; + materials?: JobMaterial[]; + operation: OperationWithDetails; + parentIsSerial?: boolean; + parentIsBatch?: boolean; + setupProductionEvent: ProductionEvent | undefined; + trackedEntityId: string; + type: "scrap" | "rework" | "complete" | "finish"; + onClose: () => void; +}) { + const fetcher = useFetcher(); + const [quantity, setQuantity] = useState(type === "finish" ? 0 : 1); + + const titleMap = { + scrap: `Log scrap for ${operation.itemReadableId}`, + rework: `Log rework for ${operation.itemReadableId}`, + complete: `Log completed for ${operation.itemReadableId}`, + finish: `Close out ${operation.itemReadableId}`, + }; + + const isOperationComplete = + operation.quantityComplete >= operation.operationQuantity; + + const descriptionMap = { + scrap: "Select a scrap quantity and reason", + rework: "Select a rework quantity", + complete: "Select a completion quantity", + finish: + "Are you sure you want to close out this operation? This will end all active production events for this operation.", + }; + + const actionMap = { + scrap: path.to.scrap, + rework: path.to.rework, + complete: path.to.complete, + finish: path.to.finish, + }; + + const actionButtonMap = { + scrap: "Log Scrap", + rework: "Log Rework", + complete: "Log Completed", + finish: isOperationComplete ? "Close" : "Close Anyways", + }; + + const validatorMap = { + scrap: scrapQuantityValidator, + rework: nonScrapQuantityValidator, + complete: nonScrapQuantityValidator, + finish: finishValidator, + }; + + const hasUnissuedMaterials = useMemo(() => { + return ( + parentIsSerial && + materials.some( + (material) => + material.jobOperationId === operation.id && + (material?.quantityIssued ?? 0) < (material?.quantity ?? 0) + ) + ); + }, [materials, parentIsSerial, operation.id]); + + return ( + { + if (!open) { + onClose(); + } + }} + > + + { + onClose(); + }} + > + + {titleMap[type]} + {descriptionMap[type]} + + + + + + + + + + {hasUnissuedMaterials && ( + + + Unissued materials + + Please issue all materials for this operation before + closing. + + + )} + + {type === "finish" && !isOperationComplete && ( + + + Insufficient quantity + + The completed quantity for this operation is less than the + required quantity of {operation.operationQuantity}. + + + )} + {type === "finish" && !allAttributesRecorded && ( + + + Steps are missing + + Please record all steps for this operation before closing. + + + )} + {type !== "finish" && ( + <> + + + )} + {type === "scrap" ? ( + <> + +