From 7545ec3cb8fb2042c6a779b6cfe2d427284173e2 Mon Sep 17 00:00:00 2001 From: John Lenz Date: Tue, 29 Oct 2024 13:57:12 -0500 Subject: [PATCH] client: start on rebookings page --- client/insight/src/cell-status/loading.ts | 4 + client/insight/src/cell-status/rebookings.ts | 171 ++++++++++++++++++ client/insight/src/components/App.tsx | 13 ++ client/insight/src/components/Navigation.tsx | 8 +- .../src/components/operations/Rebookings.tsx | 45 +++++ client/insight/src/components/routes.ts | 3 + client/insight/src/network/backend-mock.ts | 3 + client/insight/src/network/backend.ts | 1 + 8 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 client/insight/src/cell-status/rebookings.ts create mode 100644 client/insight/src/components/operations/Rebookings.tsx diff --git a/client/insight/src/cell-status/loading.ts b/client/insight/src/cell-status/loading.ts index 0697e7bc4..03cde694a 100644 --- a/client/insight/src/cell-status/loading.ts +++ b/client/insight/src/cell-status/loading.ts @@ -54,6 +54,7 @@ import * as palCycles from "./pallet-cycles.js"; import * as statCycles from "./station-cycles.js"; import * as toolReplace from "./tool-replacements.js"; import * as simDayUsage from "./sim-day-usage.js"; +import * as rebookings from "./rebookings.js"; import { Atom, atom } from "jotai"; export interface ServerEventAndTime { @@ -78,6 +79,7 @@ export const onServerEvent = atom(null, (_, set, evt: ServerEventAndTime) => { set(palCycles.updateLast30PalletCycles, evt); set(toolReplace.updateLastToolReplacements, evt); set(statCycles.updateLast30StationCycles, evt); + set(rebookings.updateLast30Rebookings, evt); set(simDayUsage.updateLatestSimDayUsage, evt); if (evt.evt.logEntry) { @@ -93,6 +95,7 @@ export const onLoadLast30Jobs = atom(null, (get, set, historicData: Readonly>) => { @@ -105,6 +108,7 @@ export const onLoadLast30Log = atom(null, (_, set, log: ReadonlyArray x.counter)?.counter ?? null; set(lastEventCounterRW, (oldCntr) => (oldCntr === null ? newCntr : Math.max(oldCntr, newCntr ?? -1))); diff --git a/client/insight/src/cell-status/rebookings.ts b/client/insight/src/cell-status/rebookings.ts new file mode 100644 index 000000000..27ec01de9 --- /dev/null +++ b/client/insight/src/cell-status/rebookings.ts @@ -0,0 +1,171 @@ +/* Copyright (c) 2024, John Lenz + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of John Lenz, Black Maple Software, SeedTactics, + nor the names of other contributors may be used to endorse or + promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { Atom, atom, Setter } from "jotai"; +import { ServerEventAndTime } from "./loading"; +import { IJob, ILogEntry, IRebooking, IRecentHistoricData, LogType, MaterialDetails } from "../network/api"; +import { LazySeq, OrderedMap, OrderedSet } from "@seedtactics/immutable-collections"; +import { JobsBackend } from "../network/backend"; +import { loadable } from "jotai/utils"; +import { addDays } from "date-fns"; + +const rebookingEvts = atom(OrderedMap.empty>()); + +// unscheduled rebookings should be loaded from the last 30 events, but it is +// possible for some to be older than 30 days, so load them here. But don't +// wait for them to be loaded before rendering, so everything is wrapped in loadable. +const unschRebookings = loadable( + atom(async (_, { signal }) => { + const b = await JobsBackend.unscheduledRebookings(signal); + return OrderedMap.build(b, (b) => b.bookingId); + }), +); + +export const last30Rebookings: Atom>> = atom((get) => { + const evts = get(rebookingEvts); + const unsch = get(unschRebookings); + + if (unsch.state === "hasData") { + return evts.union(unsch.data, (fromEvt, fromUnsch) => fromEvt ?? fromUnsch); + } else { + return evts; + } +}); + +const canceledRebookingsRW = atom(OrderedSet.empty()); +export const canceledRebookings: Atom> = canceledRebookingsRW; + +type ScheduledBooking = { + readonly scheduledTime: Date; + readonly jobUnique: string; +}; + +const scheduledRW = atom(OrderedMap.empty()); +export const last30ScheduledBookings: Atom> = scheduledRW; + +function convertLogToRebooking(log: Readonly): Readonly { + let qty = 1; + if (log.details?.["Quantity"]) { + qty = parseInt(log.details["Quantity"], 10); + } + let mat: MaterialDetails | undefined; + if (log.material?.[0].id >= 0) { + const m = log.material[0]; + mat = new MaterialDetails({ + materialID: m.id, + jobUnique: m.uniq, + partName: m.part, + numProcesses: m.numproc, + workorder: m.workorder, + serial: m.serial, + }); + } + + const restrictedProcs = LazySeq.of(log.material ?? []) + .map((m) => m.proc) + .filter((p) => p > 0) + .toSortedArray((p) => p); + + return { + bookingId: log.result, + partName: log.program, + quantity: qty, + timeUTC: log.endUTC, + priority: log.locnum, + notes: log.details?.["Notes"], + workorder: log.details?.["Workorder"] ?? log.material?.[0].workorder, + restrictedProcs: restrictedProcs as number[], + material: mat, + }; +} + +export const setLast30Rebookings = atom(null, (get, set, log: ReadonlyArray>) => { + set(rebookingEvts, (old) => + old.union( + LazySeq.of(log) + .filter((e) => e.type === LogType.Rebooking) + .toOrderedMap((e) => [e.result, convertLogToRebooking(e)]), + ), + ); + set(canceledRebookingsRW, (old) => + old.union( + LazySeq.of(log) + .filter((e) => e.type === LogType.CancelRebooking) + .toOrderedSet((e) => e.result), + ), + ); +}); + +function updateJobs(set: Setter, jobs: LazySeq>, expire?: Date) { + set(scheduledRW, (old) => { + if (expire) { + old = old.filter((b) => b.scheduledTime >= expire); + } + return old.union( + jobs + .flatMap((j) => + (j.bookings ?? []).map( + (b) => + [ + b, + { + scheduledTime: j.routeStartUTC, + jobUnique: j.unique, + }, + ] as const, + ), + ) + .toOrderedMap((x) => x), + ); + }); +} + +export const setLast30RebookingJobs = atom(null, (_, set, historic: Readonly) => { + updateJobs( + set, + LazySeq.ofObject(historic.jobs).map(([_, j]) => j), + ); +}); + +export const updateLast30Rebookings = atom(null, (get, set, { evt, now, expire }: ServerEventAndTime) => { + if (evt.newJobs) { + updateJobs(set, LazySeq.of(evt.newJobs.jobs), expire ? addDays(now, -30) : undefined); + } else if (evt.logEntry) { + const e = evt.logEntry; + if (e.type === LogType.Rebooking) { + set(rebookingEvts, (old) => old.set(e.result, convertLogToRebooking(e))); + } else if (e.type === LogType.CancelRebooking) { + set(canceledRebookingsRW, (old) => old.add(e.result)); + } + } +}); diff --git a/client/insight/src/components/App.tsx b/client/insight/src/components/App.tsx index 9bf0b219a..9639b2e0b 100644 --- a/client/insight/src/components/App.tsx +++ b/client/insight/src/components/App.tsx @@ -52,6 +52,7 @@ import { CallSplit, AttachMoney as CostIcon, Opacity, + Replay, } from "@mui/icons-material"; import OperationDashboard from "./operations/Dashboard.js"; @@ -93,6 +94,7 @@ import { useAtom, useAtomValue } from "jotai"; import { SimDayUsagePage } from "./operations/SimDayUsage.js"; import { latestSimDayUsage } from "../cell-status/sim-day-usage.js"; import { CloseoutReport } from "./operations/CloseoutReport.js"; +import { RebookingsPage } from "./operations/Rebookings.js"; const OperationsReportsTab = "bms-operations-reports-tab"; @@ -171,6 +173,12 @@ const operationsReports: ReadonlyArray = [ route: { route: routes.RouteLocation.Operations_Production }, icon: , }, + { + name: "Rebooking", + route: { route: routes.RouteLocation.Operations_Rebookings }, + icon: , + hidden: (info) => Boolean(info.supportsRebookings), + }, ]; const analysisReports: ReadonlyArray = [ @@ -542,6 +550,11 @@ const App = memo(function App(props: AppProps) { nav1 = OperationsTabs; menuNavItems = operationsReports; break; + case routes.RouteLocation.Operations_Rebookings: + page = ; + nav1 = OperationsTabs; + menuNavItems = operationsReports; + break; case routes.RouteLocation.Engineering_Cycles: page = ; diff --git a/client/insight/src/components/Navigation.tsx b/client/insight/src/components/Navigation.tsx index 373fa4c7c..91f8b028e 100644 --- a/client/insight/src/components/Navigation.tsx +++ b/client/insight/src/components/Navigation.tsx @@ -63,12 +63,14 @@ import { RouteLocation, RouteState, currentRoute, helpUrl } from "./routes"; import { fmsInformation, logout } from "../network/server-settings"; import { useAtom, useAtomValue } from "jotai"; import { QRScanButton } from "./BarcodeScanning"; +import { IFMSInfo } from "../network/api"; export type MenuNavItem = | { readonly name: string; readonly icon: ReactNode; readonly route: RouteState; + readonly hidden?: (info: Readonly) => boolean; } | { readonly separator: string }; @@ -168,6 +170,7 @@ function ToolButtons({ } function MenuNavSelect({ menuNavs }: { menuNavs: ReadonlyArray }) { + const fmsInfo = useAtomValue(fmsInformation); const [curRoute, setCurrentRoute] = useAtom(currentRoute); const [isPending, startTransition] = useTransition(); return ( @@ -201,7 +204,7 @@ function MenuNavSelect({ menuNavs }: { menuNavs: ReadonlyArray }) { {menuNavs.map((item) => "separator" in item ? ( {item.separator} - ) : ( + ) : item.hidden?.(fmsInfo) ? undefined : ( {item.icon} @@ -282,6 +285,7 @@ export function Header({ } export function SideMenu({ menuItems }: { menuItems?: ReadonlyArray }) { + const fmsInfo = useAtomValue(fmsInformation); const [curRoute, setCurrentRoute] = useAtom(currentRoute); const [isPending, startTransition] = useTransition(); @@ -304,7 +308,7 @@ export function SideMenu({ menuItems }: { menuItems?: ReadonlyArray {menuItems?.map((item) => "separator" in item ? ( {item.separator} - ) : ( + ) : item.hidden?.(fmsInfo) ? undefined : ( }) {} + +function RebookingTable() {} + +export function RebookingsPage() { + useSetTitle("Rebookings"); + return ; +} diff --git a/client/insight/src/components/routes.ts b/client/insight/src/components/routes.ts index 57e4b6cd7..019035482 100644 --- a/client/insight/src/components/routes.ts +++ b/client/insight/src/components/routes.ts @@ -64,6 +64,7 @@ export enum RouteLocation { Operations_Quality = "/operations/quality", Operations_Inspections = "/operations/inspections", Operations_CloseoutReport = "/operations/closeout", + Operations_Rebookings = "/analysis/rebookings", Engineering_Cycles = "/engineering", Engineering_Hours = "/engineering/hours", @@ -136,6 +137,7 @@ export type RouteState = | { route: RouteLocation.Operations_Quality } | { route: RouteLocation.Operations_Inspections } | { route: RouteLocation.Operations_CloseoutReport } + | { route: RouteLocation.Operations_Rebookings} | { route: RouteLocation.Engineering_Cycles } | { route: RouteLocation.Engineering_Outliers } | { route: RouteLocation.Engineering_Hours } @@ -305,6 +307,7 @@ export function helpUrl(r: RouteState): string { case RouteLocation.Operations_CurrentWorkorders: case RouteLocation.Operations_CloseoutReport: case RouteLocation.Operations_Production: + case RouteLocation.Operations_Rebookings: return "https://www.seedtactics.com/docs/fms-insight/client-operations"; case RouteLocation.Operations_MachineOutliers: diff --git a/client/insight/src/network/backend-mock.ts b/client/insight/src/network/backend-mock.ts index f2404468c..d962457ff 100644 --- a/client/insight/src/network/backend-mock.ts +++ b/client/insight/src/network/backend-mock.ts @@ -262,6 +262,9 @@ export function registerMockBackend( invalidatePalletCycle(): Promise { return Promise.resolve(); }, + unscheduledRebookings(): Promise>> { + return Promise.resolve([]); + } }; const serialsToMatId = data.then(() => diff --git a/client/insight/src/network/backend.ts b/client/insight/src/network/backend.ts index f71249ded..30e760642 100644 --- a/client/insight/src/network/backend.ts +++ b/client/insight/src/network/backend.ts @@ -84,6 +84,7 @@ export interface JobAPI { operName: string | null, process: number, ): Promise; + unscheduledRebookings(signal?: AbortSignal): Promise>>; } export interface FmsAPI {