|
|
|
@@ -62,8 +62,16 @@ type DraggingOperation = {
|
|
|
|
start: string;
|
|
|
|
start: string;
|
|
|
|
loadMinutes: number;
|
|
|
|
loadMinutes: number;
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
type SavedWorkbenchView = {
|
|
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
|
|
mode: WorkbenchMode;
|
|
|
|
|
|
|
|
group: WorkbenchGroup;
|
|
|
|
|
|
|
|
filter: WorkbenchFilter;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
|
|
|
|
const WORKBENCH_VIEW_STORAGE_KEY = "codexium.workbench.savedViews";
|
|
|
|
|
|
|
|
|
|
|
|
function formatDate(value: string | null, options?: Intl.DateTimeFormatOptions) {
|
|
|
|
function formatDate(value: string | null, options?: Intl.DateTimeFormatOptions) {
|
|
|
|
if (!value) {
|
|
|
|
if (!value) {
|
|
|
|
@@ -116,6 +124,38 @@ function densityTone(cell: HeatmapCell) {
|
|
|
|
return "border-line/60 bg-surface/70";
|
|
|
|
return "border-line/60 bg-surface/70";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function readinessTone(record: FocusRecord) {
|
|
|
|
|
|
|
|
if (record.overdue) {
|
|
|
|
|
|
|
|
return "border-rose-300/60 bg-rose-500/10 text-rose-200 dark:text-rose-200";
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (record.readinessState === "BLOCKED" || record.blockedReason) {
|
|
|
|
|
|
|
|
return "border-amber-300/60 bg-amber-400/10 text-amber-300";
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (record.totalShortageQuantity > 0 || record.readinessState === "SHORTAGE" || record.readinessState === "PENDING_SUPPLY") {
|
|
|
|
|
|
|
|
return "border-orange-300/60 bg-orange-400/10 text-orange-300";
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (record.releaseReady || record.readinessState === "READY") {
|
|
|
|
|
|
|
|
return "border-emerald-300/60 bg-emerald-500/10 text-emerald-300";
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return "border-line/70 bg-page/60 text-muted";
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function readinessLabel(record: FocusRecord) {
|
|
|
|
|
|
|
|
if (record.overdue) {
|
|
|
|
|
|
|
|
return "OVERDUE";
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (record.readinessState === "BLOCKED" || record.blockedReason) {
|
|
|
|
|
|
|
|
return "BLOCKED";
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (record.totalShortageQuantity > 0 || record.readinessState === "SHORTAGE" || record.readinessState === "PENDING_SUPPLY") {
|
|
|
|
|
|
|
|
return "SHORTAGE";
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (record.releaseReady || record.readinessState === "READY") {
|
|
|
|
|
|
|
|
return "READY";
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return record.readinessState.replaceAll("_", " ");
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildFocusRecords(tasks: GanttTaskDto[]) {
|
|
|
|
function buildFocusRecords(tasks: GanttTaskDto[]) {
|
|
|
|
return tasks.map((task) => ({
|
|
|
|
return tasks.map((task) => ({
|
|
|
|
id: task.id,
|
|
|
|
id: task.id,
|
|
|
|
@@ -169,6 +209,10 @@ function exceptionTargetId(exceptionId: string) {
|
|
|
|
return exceptionId.startsWith("project-") ? exceptionId : exceptionId.replace("work-order-unscheduled-", "work-order-");
|
|
|
|
return exceptionId.startsWith("project-") ? exceptionId : exceptionId.replace("work-order-unscheduled-", "work-order-");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function canQueueRelease(record: FocusRecord) {
|
|
|
|
|
|
|
|
return record.kind === "WORK_ORDER" && record.releaseReady && record.actions.some((action) => action.kind === "RELEASE_WORK_ORDER" && action.workOrderId);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function WorkbenchPage() {
|
|
|
|
export function WorkbenchPage() {
|
|
|
|
const navigate = useNavigate();
|
|
|
|
const navigate = useNavigate();
|
|
|
|
const { token } = useAuth();
|
|
|
|
const { token } = useAuth();
|
|
|
|
@@ -186,6 +230,36 @@ export function WorkbenchPage() {
|
|
|
|
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
|
|
|
|
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
|
|
|
|
const [draggingOperation, setDraggingOperation] = useState<DraggingOperation | null>(null);
|
|
|
|
const [draggingOperation, setDraggingOperation] = useState<DraggingOperation | null>(null);
|
|
|
|
const [dropStationId, setDropStationId] = useState<string | null>(null);
|
|
|
|
const [dropStationId, setDropStationId] = useState<string | null>(null);
|
|
|
|
|
|
|
|
const [queuedWorkOrderIds, setQueuedWorkOrderIds] = useState<string[]>([]);
|
|
|
|
|
|
|
|
const [savedViews, setSavedViews] = useState<SavedWorkbenchView[]>([]);
|
|
|
|
|
|
|
|
const [isBatchReleasing, setIsBatchReleasing] = useState(false);
|
|
|
|
|
|
|
|
const [selectedOperationIds, setSelectedOperationIds] = useState<string[]>([]);
|
|
|
|
|
|
|
|
const [batchRescheduleStart, setBatchRescheduleStart] = useState("");
|
|
|
|
|
|
|
|
const [batchStationId, setBatchStationId] = useState("");
|
|
|
|
|
|
|
|
const [isBatchRescheduling, setIsBatchRescheduling] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
if (typeof window === "undefined") {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const stored = window.localStorage.getItem(WORKBENCH_VIEW_STORAGE_KEY);
|
|
|
|
|
|
|
|
if (!stored) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const parsed = JSON.parse(stored) as SavedWorkbenchView[];
|
|
|
|
|
|
|
|
setSavedViews(Array.isArray(parsed) ? parsed : []);
|
|
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
|
|
setSavedViews([]);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
if (typeof window === "undefined") {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
window.localStorage.setItem(WORKBENCH_VIEW_STORAGE_KEY, JSON.stringify(savedViews));
|
|
|
|
|
|
|
|
}, [savedViews]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
if (!token) {
|
|
|
|
if (!token) {
|
|
|
|
@@ -213,6 +287,33 @@ export function WorkbenchPage() {
|
|
|
|
const filteredFocusRecords = useMemo(() => focusRecords.filter((record) => matchesWorkbenchFilter(record, workbenchFilter)), [focusRecords, workbenchFilter]);
|
|
|
|
const filteredFocusRecords = useMemo(() => focusRecords.filter((record) => matchesWorkbenchFilter(record, workbenchFilter)), [focusRecords, workbenchFilter]);
|
|
|
|
const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]);
|
|
|
|
const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]);
|
|
|
|
const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : filteredFocusRecords[0] ?? focusRecords[0] ?? null;
|
|
|
|
const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : filteredFocusRecords[0] ?? focusRecords[0] ?? null;
|
|
|
|
|
|
|
|
const queuedRecords = useMemo(() => queuedWorkOrderIds.reduce<FocusRecord[]>((records, id) => {
|
|
|
|
|
|
|
|
const match = focusRecords.find((record) => (record.workOrderId ?? record.id) === id);
|
|
|
|
|
|
|
|
if (match) {
|
|
|
|
|
|
|
|
records.push(match);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return records;
|
|
|
|
|
|
|
|
}, []), [focusRecords, queuedWorkOrderIds]);
|
|
|
|
|
|
|
|
const releasableQueuedRecords = useMemo(() => queuedRecords.filter((record) => canQueueRelease(record)), [queuedRecords]);
|
|
|
|
|
|
|
|
const visibleReleasableRecords = useMemo(() => filteredFocusRecords.filter((record) => canQueueRelease(record)), [filteredFocusRecords]);
|
|
|
|
|
|
|
|
const selectedOperations = useMemo(() => selectedOperationIds.reduce<FocusRecord[]>((records, id) => {
|
|
|
|
|
|
|
|
const match = focusRecords.find((record) => record.id === id && record.kind === "OPERATION");
|
|
|
|
|
|
|
|
if (match) {
|
|
|
|
|
|
|
|
records.push(match);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return records;
|
|
|
|
|
|
|
|
}, []), [focusRecords, selectedOperationIds]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
setQueuedWorkOrderIds((current) => current.filter((id) => {
|
|
|
|
|
|
|
|
const record = focusRecords.find((candidate) => (candidate.workOrderId ?? candidate.id) === id);
|
|
|
|
|
|
|
|
return record ? canQueueRelease(record) : false;
|
|
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
}, [focusRecords]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
setSelectedOperationIds((current) => current.filter((id) => focusRecords.some((record) => record.id === id && record.kind === "OPERATION")));
|
|
|
|
|
|
|
|
}, [focusRecords]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
if (selectedFocus?.kind === "OPERATION") {
|
|
|
|
if (selectedFocus?.kind === "OPERATION") {
|
|
|
|
@@ -224,6 +325,20 @@ export function WorkbenchPage() {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}, [selectedFocus?.id, selectedFocus?.kind, selectedFocus?.start, selectedFocus?.stationId]);
|
|
|
|
}, [selectedFocus?.id, selectedFocus?.kind, selectedFocus?.start, selectedFocus?.stationId]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
if (selectedOperations.length === 0) {
|
|
|
|
|
|
|
|
setBatchRescheduleStart("");
|
|
|
|
|
|
|
|
setBatchStationId("");
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const first = selectedOperations[0];
|
|
|
|
|
|
|
|
if (!first) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
setBatchRescheduleStart((current) => current || first.start.slice(0, 16));
|
|
|
|
|
|
|
|
setBatchStationId((current) => current || first.stationId || "");
|
|
|
|
|
|
|
|
}, [selectedOperations]);
|
|
|
|
|
|
|
|
|
|
|
|
const heatmap = useMemo(() => {
|
|
|
|
const heatmap = useMemo(() => {
|
|
|
|
const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date());
|
|
|
|
const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date());
|
|
|
|
const cells = new Map<string, HeatmapCell>();
|
|
|
|
const cells = new Map<string, HeatmapCell>();
|
|
|
|
@@ -270,6 +385,91 @@ export function WorkbenchPage() {
|
|
|
|
.slice(0, 18),
|
|
|
|
.slice(0, 18),
|
|
|
|
[focusRecords, workbenchFilter]
|
|
|
|
[focusRecords, workbenchFilter]
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
const keyboardRecords = useMemo(() => {
|
|
|
|
|
|
|
|
if (workbenchMode === "agenda") {
|
|
|
|
|
|
|
|
return agendaItems;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (workbenchMode === "heatmap") {
|
|
|
|
|
|
|
|
return (selectedHeatmapCell?.tasks ?? filteredFocusRecords.filter((record) => record.kind !== "PROJECT")).slice(0, 18);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const projects = filteredFocusRecords.filter((record) => record.kind === "PROJECT").slice(0, 6);
|
|
|
|
|
|
|
|
const operations = filteredFocusRecords.filter((record) => record.kind === "OPERATION");
|
|
|
|
|
|
|
|
const workOrders = filteredFocusRecords.filter((record) => record.kind === "WORK_ORDER").slice(0, 10);
|
|
|
|
|
|
|
|
const exceptionRows = filteredFocusRecords
|
|
|
|
|
|
|
|
.filter((record) => record.kind !== "PROJECT" && (record.overdue || record.totalShortageQuantity > 0 || record.readinessState === "BLOCKED" || record.readinessState === "PENDING_SUPPLY"))
|
|
|
|
|
|
|
|
.slice(0, 10);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (workbenchGroup === "projects") {
|
|
|
|
|
|
|
|
return [...projects, ...operations.slice(0, 10), ...workOrders].filter((record, index, array) => array.findIndex((candidate) => candidate.id === record.id) === index);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (workbenchGroup === "exceptions") {
|
|
|
|
|
|
|
|
return [...exceptionRows, ...workOrders].filter((record, index, array) => array.findIndex((candidate) => candidate.id === record.id) === index);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const stationBuckets = new Map<string, FocusRecord[]>();
|
|
|
|
|
|
|
|
for (const record of operations) {
|
|
|
|
|
|
|
|
if (!record.stationId) {
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const bucket = stationBuckets.get(record.stationId) ?? [];
|
|
|
|
|
|
|
|
bucket.push(record);
|
|
|
|
|
|
|
|
stationBuckets.set(record.stationId, bucket);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const bucket of stationBuckets.values()) {
|
|
|
|
|
|
|
|
bucket.sort((left, right) => new Date(left.start).getTime() - new Date(right.start).getTime());
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return stationLoads
|
|
|
|
|
|
|
|
.slice(0, 10)
|
|
|
|
|
|
|
|
.flatMap((station) => (stationBuckets.get(station.stationId) ?? []).slice(0, 5));
|
|
|
|
|
|
|
|
}, [agendaItems, filteredFocusRecords, selectedHeatmapCell?.dateKey, selectedHeatmapCell?.tasks, stationLoads, workbenchGroup, workbenchMode]);
|
|
|
|
|
|
|
|
const visibleOperations = useMemo(() => keyboardRecords.filter((record) => record.kind === "OPERATION"), [keyboardRecords]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
|
|
|
|
|
|
const target = event.target;
|
|
|
|
|
|
|
|
if (target instanceof HTMLElement) {
|
|
|
|
|
|
|
|
const tagName = target.tagName;
|
|
|
|
|
|
|
|
if (target.isContentEditable || tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT" || tagName === "BUTTON") {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (event.altKey || event.ctrlKey || event.metaKey || keyboardRecords.length === 0) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (event.key === "ArrowDown" || event.key === "j") {
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
const currentIndex = selectedFocus ? keyboardRecords.findIndex((record) => record.id === selectedFocus.id) : -1;
|
|
|
|
|
|
|
|
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, keyboardRecords.length - 1) : 0;
|
|
|
|
|
|
|
|
setSelectedFocusId(keyboardRecords[nextIndex]?.id ?? null);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (event.key === "ArrowUp" || event.key === "k") {
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
const currentIndex = selectedFocus ? keyboardRecords.findIndex((record) => record.id === selectedFocus.id) : -1;
|
|
|
|
|
|
|
|
const nextIndex = currentIndex >= 0 ? Math.max(currentIndex - 1, 0) : 0;
|
|
|
|
|
|
|
|
setSelectedFocusId(keyboardRecords[nextIndex]?.id ?? null);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (event.key === "Enter" && selectedFocus?.detailHref) {
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
navigate(selectedFocus.detailHref);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (event.key === "Escape") {
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
if (selectedHeatmapDate) {
|
|
|
|
|
|
|
|
setSelectedHeatmapDate(null);
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
setSelectedFocusId(null);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener("keydown", handleKeydown);
|
|
|
|
|
|
|
|
return () => window.removeEventListener("keydown", handleKeydown);
|
|
|
|
|
|
|
|
}, [keyboardRecords, navigate, selectedFocus, selectedHeatmapDate]);
|
|
|
|
|
|
|
|
|
|
|
|
const modeOptions: Array<{ value: WorkbenchMode; label: string; detail: string }> = [
|
|
|
|
const modeOptions: Array<{ value: WorkbenchMode; label: string; detail: string }> = [
|
|
|
|
{ value: "overview", label: "Overview", detail: "Dense planner board" },
|
|
|
|
{ value: "overview", label: "Overview", detail: "Dense planner board" },
|
|
|
|
@@ -313,6 +513,110 @@ export function WorkbenchPage() {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function addRecordToQueue(record: FocusRecord) {
|
|
|
|
|
|
|
|
if (!canQueueRelease(record)) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const queueId = record.workOrderId ?? record.id;
|
|
|
|
|
|
|
|
setQueuedWorkOrderIds((current) => (current.includes(queueId) ? current : [...current, queueId]));
|
|
|
|
|
|
|
|
setStatus(`Queued ${record.title} for batch release.`);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function queueVisibleReady() {
|
|
|
|
|
|
|
|
const nextIds = visibleReleasableRecords.map((record) => record.workOrderId ?? record.id);
|
|
|
|
|
|
|
|
if (nextIds.length === 0) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
setQueuedWorkOrderIds((current) => [...new Set([...current, ...nextIds])]);
|
|
|
|
|
|
|
|
setStatus(`Queued ${nextIds.length} visible release-ready work orders.`);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function releaseQueuedWorkOrders() {
|
|
|
|
|
|
|
|
if (!token || releasableQueuedRecords.length === 0) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
setIsBatchReleasing(true);
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
await Promise.all(releasableQueuedRecords.map((record) => api.updateWorkOrderStatus(token, record.workOrderId!, { status: "RELEASED" })));
|
|
|
|
|
|
|
|
setQueuedWorkOrderIds([]);
|
|
|
|
|
|
|
|
await refreshWorkbench(`Released ${releasableQueuedRecords.length} queued work orders from Workbench.`);
|
|
|
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
|
|
|
const message = error instanceof ApiError ? error.message : "Unable to release queued work orders.";
|
|
|
|
|
|
|
|
setStatus(message);
|
|
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
|
|
setIsBatchReleasing(false);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function saveCurrentView() {
|
|
|
|
|
|
|
|
if (typeof window === "undefined") {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const suggested = `${workbenchMode.toUpperCase()} ${workbenchGroup.toUpperCase()}`;
|
|
|
|
|
|
|
|
const name = window.prompt("Save workbench view as:", suggested)?.trim();
|
|
|
|
|
|
|
|
if (!name) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const nextView: SavedWorkbenchView = {
|
|
|
|
|
|
|
|
id: `${Date.now()}`,
|
|
|
|
|
|
|
|
name,
|
|
|
|
|
|
|
|
mode: workbenchMode,
|
|
|
|
|
|
|
|
group: workbenchGroup,
|
|
|
|
|
|
|
|
filter: workbenchFilter,
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
setSavedViews((current) => [nextView, ...current].slice(0, 8));
|
|
|
|
|
|
|
|
setStatus(`Saved workbench view: ${name}.`);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function applySavedView(view: SavedWorkbenchView) {
|
|
|
|
|
|
|
|
setWorkbenchMode(view.mode);
|
|
|
|
|
|
|
|
setWorkbenchGroup(view.group);
|
|
|
|
|
|
|
|
setWorkbenchFilter(view.filter);
|
|
|
|
|
|
|
|
setStatus(`Loaded saved view: ${view.name}.`);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function toggleOperationSelection(record: FocusRecord) {
|
|
|
|
|
|
|
|
if (record.kind !== "OPERATION") {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
setSelectedOperationIds((current) => current.includes(record.id) ? current.filter((id) => id !== record.id) : [...current, record.id]);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function selectVisibleOperations() {
|
|
|
|
|
|
|
|
const nextIds = visibleOperations.map((record) => record.id);
|
|
|
|
|
|
|
|
if (nextIds.length === 0) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
setSelectedOperationIds((current) => [...new Set([...current, ...nextIds])]);
|
|
|
|
|
|
|
|
setStatus(`Selected ${nextIds.length} visible operations for batch rebalance.`);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function applyBatchReschedule() {
|
|
|
|
|
|
|
|
if (!token || selectedOperations.length === 0 || !batchRescheduleStart) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
setIsBatchRescheduling(true);
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const plannedStart = new Date(batchRescheduleStart).toISOString();
|
|
|
|
|
|
|
|
await Promise.all(selectedOperations.map((record) => {
|
|
|
|
|
|
|
|
if (!record.workOrderId || !record.entityId) {
|
|
|
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return api.updateWorkOrderOperationSchedule(token, record.workOrderId, record.entityId, {
|
|
|
|
|
|
|
|
plannedStart,
|
|
|
|
|
|
|
|
stationId: batchStationId || record.stationId || null,
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
setSelectedOperationIds([]);
|
|
|
|
|
|
|
|
await refreshWorkbench(`Rebalanced ${selectedOperations.length} operations from Workbench.`);
|
|
|
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
|
|
|
const message = error instanceof ApiError ? error.message : "Unable to rebalance selected operations.";
|
|
|
|
|
|
|
|
setStatus(message);
|
|
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
|
|
setIsBatchRescheduling(false);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function rebalanceOperation(record: FocusRecord, nextStartIso?: string, nextStationId?: string | null) {
|
|
|
|
async function rebalanceOperation(record: FocusRecord, nextStartIso?: string, nextStationId?: string | null) {
|
|
|
|
if (!token || record.kind !== "OPERATION" || !record.workOrderId || !record.entityId) {
|
|
|
|
if (!token || record.kind !== "OPERATION" || !record.workOrderId || !record.entityId) {
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
@@ -425,6 +729,11 @@ export function WorkbenchPage() {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted">
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted">
|
|
|
|
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">{filteredFocusRecords.length} visible rows</span>
|
|
|
|
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">{filteredFocusRecords.length} visible rows</span>
|
|
|
|
|
|
|
|
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">Up/Down navigate</span>
|
|
|
|
|
|
|
|
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">Enter open</span>
|
|
|
|
|
|
|
|
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">Esc clear</span>
|
|
|
|
|
|
|
|
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">{releasableQueuedRecords.length} queued</span>
|
|
|
|
|
|
|
|
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">{selectedOperations.length} ops selected</span>
|
|
|
|
{selectedFocus ? (
|
|
|
|
{selectedFocus ? (
|
|
|
|
<span className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-text">
|
|
|
|
<span className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-text">
|
|
|
|
Selected {selectedFocus.kind.toLowerCase()}: {selectedFocus.title}
|
|
|
|
Selected {selectedFocus.kind.toLowerCase()}: {selectedFocus.title}
|
|
|
|
@@ -436,6 +745,90 @@ export function WorkbenchPage() {
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
|
|
|
|
|
|
<button type="button" onClick={saveCurrentView} className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-sm font-semibold text-text">
|
|
|
|
|
|
|
|
Save view
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
<button type="button" onClick={queueVisibleReady} disabled={visibleReleasableRecords.length === 0} className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
|
|
|
|
|
|
|
Queue visible ready
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
<button type="button" onClick={() => void releaseQueuedWorkOrders()} disabled={isBatchReleasing || releasableQueuedRecords.length === 0} className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
|
|
|
|
|
|
|
{isBatchReleasing ? "Releasing..." : "Release queued"}
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
{queuedWorkOrderIds.length > 0 ? (
|
|
|
|
|
|
|
|
<button type="button" onClick={() => setQueuedWorkOrderIds([])} className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-sm font-semibold text-text">
|
|
|
|
|
|
|
|
Clear queue
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
<button type="button" onClick={selectVisibleOperations} disabled={visibleOperations.length === 0} className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
|
|
|
|
|
|
|
|
Select visible ops
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
{selectedOperationIds.length > 0 ? (
|
|
|
|
|
|
|
|
<button type="button" onClick={() => setSelectedOperationIds([])} className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-sm font-semibold text-text">
|
|
|
|
|
|
|
|
Clear op selection
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
{savedViews.length > 0 ? (
|
|
|
|
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
|
|
|
|
|
|
{savedViews.map((view) => (
|
|
|
|
|
|
|
|
<div key={view.id} className="flex items-center overflow-hidden rounded-2xl border border-line/70 bg-page/60">
|
|
|
|
|
|
|
|
<button type="button" onClick={() => applySavedView(view)} className="px-3 py-2 text-sm font-semibold text-text">
|
|
|
|
|
|
|
|
{view.name}
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
<button type="button" onClick={() => setSavedViews((current) => current.filter((candidate) => candidate.id !== view.id))} className="border-l border-line/70 px-2 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
|
|
|
|
|
|
|
|
X
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
{queuedRecords.length > 0 ? (
|
|
|
|
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted">
|
|
|
|
|
|
|
|
{queuedRecords.slice(0, 6).map((record) => (
|
|
|
|
|
|
|
|
<span key={record.workOrderId ?? record.id} className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-text">
|
|
|
|
|
|
|
|
{record.title}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
{queuedRecords.length > 6 ? <span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">+{queuedRecords.length - 6} more</span> : null}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
{selectedOperations.length > 0 ? (
|
|
|
|
|
|
|
|
<div className="mt-3 rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
|
|
|
|
|
|
|
<div className="section-kicker">BATCH REBALANCE</div>
|
|
|
|
|
|
|
|
<div className="mt-3 grid gap-2 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
|
|
|
type="datetime-local"
|
|
|
|
|
|
|
|
value={batchRescheduleStart}
|
|
|
|
|
|
|
|
onChange={(event) => setBatchRescheduleStart(event.target.value)}
|
|
|
|
|
|
|
|
className="rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
<select
|
|
|
|
|
|
|
|
value={batchStationId}
|
|
|
|
|
|
|
|
onChange={(event) => setBatchStationId(event.target.value)}
|
|
|
|
|
|
|
|
className="rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<option value="">Keep current station</option>
|
|
|
|
|
|
|
|
{stations.filter((station) => station.isActive).map((station) => (
|
|
|
|
|
|
|
|
<option key={station.id} value={station.id}>
|
|
|
|
|
|
|
|
{station.code} - {station.name}
|
|
|
|
|
|
|
|
</option>
|
|
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
</select>
|
|
|
|
|
|
|
|
<button type="button" onClick={() => void applyBatchReschedule()} disabled={isBatchRescheduling || !batchRescheduleStart} className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
|
|
|
|
|
|
|
{isBatchRescheduling ? "Applying..." : "Apply batch"}
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted">
|
|
|
|
|
|
|
|
{selectedOperations.slice(0, 6).map((record) => (
|
|
|
|
|
|
|
|
<span key={record.id} className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-text">
|
|
|
|
|
|
|
|
{record.title}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
{selectedOperations.length > 6 ? <span className="rounded-full border border-line/70 bg-surface px-2 py-1">+{selectedOperations.length - 6} more</span> : null}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
) : null}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="grid gap-3 xl:grid-cols-10">
|
|
|
|
<section className="grid gap-3 xl:grid-cols-10">
|
|
|
|
@@ -469,9 +862,9 @@ export function WorkbenchPage() {
|
|
|
|
<button key={exception.id} type="button" onClick={() => setSelectedFocusId(exceptionTargetId(exception.id))} className={`block w-full rounded-[18px] border px-2 py-2 text-left transition hover:bg-page/80 ${selectedFocusId === exceptionTargetId(exception.id) ? "border-brand bg-brand/10" : "border-line/70 bg-page/60"}`}>
|
|
|
|
<button key={exception.id} type="button" onClick={() => setSelectedFocusId(exceptionTargetId(exception.id))} className={`block w-full rounded-[18px] border px-2 py-2 text-left transition hover:bg-page/80 ${selectedFocusId === exceptionTargetId(exception.id) ? "border-brand bg-brand/10" : "border-line/70 bg-page/60"}`}>
|
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
|
<div>
|
|
|
|
<div>
|
|
|
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{exception.kind === "PROJECT" ? "Project" : "Work Order"}</div>
|
|
|
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{exception.kind === "PROJECT" ? "Project" : "Work Order"}</div>
|
|
|
|
<div className="mt-1 font-semibold text-text">{exception.title}</div>
|
|
|
|
<div className="mt-1 font-semibold text-text">{exception.title}</div>
|
|
|
|
<div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div>
|
|
|
|
<div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">{exception.status.replaceAll("_", " ")}</span>
|
|
|
|
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">{exception.status.replaceAll("_", " ")}</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@@ -511,6 +904,7 @@ export function WorkbenchPage() {
|
|
|
|
stationLoads={stationLoads}
|
|
|
|
stationLoads={stationLoads}
|
|
|
|
groupMode={workbenchGroup}
|
|
|
|
groupMode={workbenchGroup}
|
|
|
|
onSelect={setSelectedFocusId}
|
|
|
|
onSelect={setSelectedFocusId}
|
|
|
|
|
|
|
|
selectedOperationIds={selectedOperationIds}
|
|
|
|
selectedId={selectedFocus?.id ?? null}
|
|
|
|
selectedId={selectedFocus?.id ?? null}
|
|
|
|
draggingOperation={draggingOperation}
|
|
|
|
draggingOperation={draggingOperation}
|
|
|
|
dropStationId={dropStationId}
|
|
|
|
dropStationId={dropStationId}
|
|
|
|
@@ -521,7 +915,7 @@ export function WorkbenchPage() {
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
{workbenchMode === "heatmap" ? <HeatmapBoard heatmap={heatmap} selectedDate={selectedHeatmapDate} onSelectDate={setSelectedHeatmapDate} /> : null}
|
|
|
|
{workbenchMode === "heatmap" ? <HeatmapBoard heatmap={heatmap} selectedDate={selectedHeatmapDate} onSelectDate={setSelectedHeatmapDate} /> : null}
|
|
|
|
{workbenchMode === "agenda" ? <AgendaBoard records={agendaItems} onSelect={setSelectedFocusId} selectedId={selectedFocus?.id ?? null} /> : null}
|
|
|
|
{workbenchMode === "agenda" ? <AgendaBoard records={agendaItems} onSelect={setSelectedFocusId} selectedOperationIds={selectedOperationIds} selectedId={selectedFocus?.id ?? null} /> : null}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<aside className="space-y-3">
|
|
|
|
<aside className="space-y-3">
|
|
|
|
@@ -533,6 +927,9 @@ export function WorkbenchPage() {
|
|
|
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{selectedFocus.kind}</div>
|
|
|
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{selectedFocus.kind}</div>
|
|
|
|
<div className="mt-2 text-base font-bold text-text">{selectedFocus.title}</div>
|
|
|
|
<div className="mt-2 text-base font-bold text-text">{selectedFocus.title}</div>
|
|
|
|
<div className="mt-2 text-xs text-muted">{selectedFocus.ownerLabel ?? "No context label"}</div>
|
|
|
|
<div className="mt-2 text-xs text-muted">{selectedFocus.ownerLabel ?? "No context label"}</div>
|
|
|
|
|
|
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
|
|
|
|
|
|
<RecordSignals record={selectedFocus} queued={queuedWorkOrderIds.includes(selectedFocus.workOrderId ?? selectedFocus.id)} selected={selectedOperationIds.includes(selectedFocus.id)} />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
|
|
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
|
|
|
<div className="flex items-center justify-between gap-3"><span className="text-muted">Status</span><span className="font-semibold text-text">{selectedFocus.status.replaceAll("_", " ")}</span></div>
|
|
|
|
<div className="flex items-center justify-between gap-3"><span className="text-muted">Status</span><span className="font-semibold text-text">{selectedFocus.status.replaceAll("_", " ")}</span></div>
|
|
|
|
@@ -633,6 +1030,21 @@ export function WorkbenchPage() {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
|
|
{canQueueRelease(selectedFocus) ? (
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
|
|
onClick={() => addRecordToQueue(selectedFocus)}
|
|
|
|
|
|
|
|
disabled={queuedWorkOrderIds.includes(selectedFocus.workOrderId ?? selectedFocus.id)}
|
|
|
|
|
|
|
|
className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
{queuedWorkOrderIds.includes(selectedFocus.workOrderId ?? selectedFocus.id) ? "Queued" : "Queue release"}
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
{selectedFocus.kind === "OPERATION" ? (
|
|
|
|
|
|
|
|
<button type="button" onClick={() => toggleOperationSelection(selectedFocus)} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
|
|
|
|
|
|
|
{selectedOperationIds.includes(selectedFocus.id) ? "Deselect op" : "Select op"}
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
) : null}
|
|
|
|
{selectedFocus.actions.map((action, index) => (
|
|
|
|
{selectedFocus.actions.map((action, index) => (
|
|
|
|
<button
|
|
|
|
<button
|
|
|
|
key={`${action.kind}-${index}`}
|
|
|
|
key={`${action.kind}-${index}`}
|
|
|
|
@@ -654,8 +1066,8 @@ export function WorkbenchPage() {
|
|
|
|
<section className="surface-panel">
|
|
|
|
<section className="surface-panel">
|
|
|
|
<p className="section-kicker">{workbenchMode === "heatmap" ? "SELECTED DAY" : "UPCOMING AGENDA"}</p>
|
|
|
|
<p className="section-kicker">{workbenchMode === "heatmap" ? "SELECTED DAY" : "UPCOMING AGENDA"}</p>
|
|
|
|
{workbenchMode === "heatmap"
|
|
|
|
{workbenchMode === "heatmap"
|
|
|
|
? (selectedHeatmapCell ? <SelectedDayPanel cell={selectedHeatmapCell} onSelect={setSelectedFocusId} selectedId={selectedFocus?.id ?? null} /> : <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">Select a day in the heatmap to inspect its load.</div>)
|
|
|
|
? (selectedHeatmapCell ? <SelectedDayPanel cell={selectedHeatmapCell} onSelect={setSelectedFocusId} selectedOperationIds={selectedOperationIds} selectedId={selectedFocus?.id ?? null} /> : <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">Select a day in the heatmap to inspect its load.</div>)
|
|
|
|
: <AgendaBoard records={agendaItems.slice(0, 8)} onSelect={setSelectedFocusId} selectedId={selectedFocus?.id ?? null} compact />}
|
|
|
|
: <AgendaBoard records={agendaItems.slice(0, 8)} onSelect={setSelectedFocusId} selectedOperationIds={selectedOperationIds} selectedId={selectedFocus?.id ?? null} compact />}
|
|
|
|
</section>
|
|
|
|
</section>
|
|
|
|
</aside>
|
|
|
|
</aside>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@@ -672,11 +1084,27 @@ function MetricCard({ label, value }: { label: string; value: string | number })
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function RecordSignals({ record, queued = false, selected = false }: { record: FocusRecord; queued?: boolean; selected?: boolean }) {
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
<>
|
|
|
|
|
|
|
|
<span className={`rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${readinessTone(record)}`}>
|
|
|
|
|
|
|
|
{readinessLabel(record)}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
{selected ? <span className="rounded-full border border-cyan-300/60 bg-cyan-400/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-cyan-300">SELECTED</span> : null}
|
|
|
|
|
|
|
|
{queued ? <span className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-text">QUEUED</span> : null}
|
|
|
|
|
|
|
|
{record.releaseReady ? <span className="rounded-full border border-emerald-300/60 bg-emerald-500/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-300">RELEASE</span> : null}
|
|
|
|
|
|
|
|
{record.totalShortageQuantity > 0 ? <span className="rounded-full border border-orange-300/60 bg-orange-400/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-orange-300">SHORT {record.totalShortageQuantity}</span> : null}
|
|
|
|
|
|
|
|
{record.blockedReason ? <span className="rounded-full border border-amber-300/60 bg-amber-400/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-300">HOLD</span> : null}
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function OverviewBoard({
|
|
|
|
function OverviewBoard({
|
|
|
|
focusRecords,
|
|
|
|
focusRecords,
|
|
|
|
stationLoads,
|
|
|
|
stationLoads,
|
|
|
|
groupMode,
|
|
|
|
groupMode,
|
|
|
|
onSelect,
|
|
|
|
onSelect,
|
|
|
|
|
|
|
|
selectedOperationIds,
|
|
|
|
selectedId,
|
|
|
|
selectedId,
|
|
|
|
draggingOperation,
|
|
|
|
draggingOperation,
|
|
|
|
dropStationId,
|
|
|
|
dropStationId,
|
|
|
|
@@ -689,6 +1117,7 @@ function OverviewBoard({
|
|
|
|
stationLoads: PlanningStationLoadDto[];
|
|
|
|
stationLoads: PlanningStationLoadDto[];
|
|
|
|
groupMode: WorkbenchGroup;
|
|
|
|
groupMode: WorkbenchGroup;
|
|
|
|
onSelect: (id: string) => void;
|
|
|
|
onSelect: (id: string) => void;
|
|
|
|
|
|
|
|
selectedOperationIds: string[];
|
|
|
|
selectedId: string | null;
|
|
|
|
selectedId: string | null;
|
|
|
|
draggingOperation: DraggingOperation | null;
|
|
|
|
draggingOperation: DraggingOperation | null;
|
|
|
|
dropStationId: string | null;
|
|
|
|
dropStationId: string | null;
|
|
|
|
@@ -734,10 +1163,13 @@ function OverviewBoard({
|
|
|
|
<div>
|
|
|
|
<div>
|
|
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
|
|
<div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No owner context"}</div>
|
|
|
|
<div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No owner context"}</div>
|
|
|
|
|
|
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
|
|
|
|
|
|
<RecordSignals record={record} selected={selectedOperationIds.includes(record.id)} />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="text-right text-xs text-muted">
|
|
|
|
<div className="text-right text-xs text-muted">
|
|
|
|
<div>{record.readinessState}</div>
|
|
|
|
|
|
|
|
<div>{record.progress}% progress</div>
|
|
|
|
<div>{record.progress}% progress</div>
|
|
|
|
|
|
|
|
<div>{formatDate(record.end)}</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
@@ -752,6 +1184,9 @@ function OverviewBoard({
|
|
|
|
<div>
|
|
|
|
<div>
|
|
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
|
|
<div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No parent work order"}</div>
|
|
|
|
<div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No parent work order"}</div>
|
|
|
|
|
|
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
|
|
|
|
|
|
<RecordSignals record={record} selected={selectedOperationIds.includes(record.id)} />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="text-right text-xs text-muted">
|
|
|
|
<div className="text-right text-xs text-muted">
|
|
|
|
<div>{record.stationCode ?? "No station"}</div>
|
|
|
|
<div>{record.stationCode ?? "No station"}</div>
|
|
|
|
@@ -865,9 +1300,11 @@ function OverviewBoard({
|
|
|
|
<div>
|
|
|
|
<div>
|
|
|
|
<div className="font-semibold text-text">{record.ownerLabel ?? record.title}</div>
|
|
|
|
<div className="font-semibold text-text">{record.ownerLabel ?? record.title}</div>
|
|
|
|
<div className="mt-1 text-xs text-muted">{formatDate(record.start, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}</div>
|
|
|
|
<div className="mt-1 text-xs text-muted">{formatDate(record.start, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}</div>
|
|
|
|
|
|
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
|
|
|
|
|
|
<RecordSignals record={record} selected={selectedOperationIds.includes(record.id)} />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="text-right text-xs text-muted">
|
|
|
|
<div className="text-right text-xs text-muted">
|
|
|
|
<div>{record.readinessState}</div>
|
|
|
|
|
|
|
|
<div>{record.utilizationPercent ?? station.utilizationPercent}% util</div>
|
|
|
|
<div>{record.utilizationPercent ?? station.utilizationPercent}% util</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@@ -891,10 +1328,12 @@ function OverviewBoard({
|
|
|
|
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`flex w-full items-center justify-between gap-3 rounded-[16px] border px-2 py-2 text-left transition hover:bg-surface ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-surface/80"}`}>
|
|
|
|
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`flex w-full items-center justify-between gap-3 rounded-[16px] border px-2 py-2 text-left transition hover:bg-surface ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-surface/80"}`}>
|
|
|
|
<div>
|
|
|
|
<div>
|
|
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
|
|
<div className="mt-1 text-xs text-muted">{record.readinessState} - shortage {record.totalShortageQuantity}</div>
|
|
|
|
<div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No context"}</div>
|
|
|
|
|
|
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
|
|
|
|
|
|
<RecordSignals record={record} />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="text-right text-xs text-muted">
|
|
|
|
<div className="text-right text-xs text-muted">
|
|
|
|
<div>{record.ownerLabel ?? "No context"}</div>
|
|
|
|
|
|
|
|
<div>{record.overdue ? "Overdue" : "Open"}</div>
|
|
|
|
<div>{record.overdue ? "Overdue" : "Open"}</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
@@ -908,8 +1347,11 @@ function OverviewBoard({
|
|
|
|
{workOrders.map((record) => (
|
|
|
|
{workOrders.map((record) => (
|
|
|
|
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`rounded-[16px] border px-2 py-2 text-left transition hover:bg-surface ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-surface/80"}`}>
|
|
|
|
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`rounded-[16px] border px-2 py-2 text-left transition hover:bg-surface ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-surface/80"}`}>
|
|
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
|
|
|
|
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
|
|
|
|
|
|
<RecordSignals record={record} />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
<div className="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
|
|
|
|
<div className="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
|
|
|
|
<span>{record.readinessState}</span>
|
|
|
|
<span>{record.ownerLabel ?? "No context"}</span>
|
|
|
|
<span>{record.progress}%</span>
|
|
|
|
<span>{record.progress}%</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
@@ -963,7 +1405,7 @@ function HeatmapBoard({ heatmap, selectedDate, onSelectDate }: { heatmap: Heatma
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function AgendaBoard({ records, onSelect, selectedId, compact = false }: { records: FocusRecord[]; onSelect: (id: string) => void; selectedId: string | null; compact?: boolean }) {
|
|
|
|
function AgendaBoard({ records, onSelect, selectedOperationIds, selectedId, compact = false }: { records: FocusRecord[]; onSelect: (id: string) => void; selectedOperationIds: string[]; selectedId: string | null; compact?: boolean }) {
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<div className={compact ? "mt-3 space-y-2" : "space-y-3"}>
|
|
|
|
<div className={compact ? "mt-3 space-y-2" : "space-y-3"}>
|
|
|
|
{!compact ? (
|
|
|
|
{!compact ? (
|
|
|
|
@@ -977,6 +1419,9 @@ function AgendaBoard({ records, onSelect, selectedId, compact = false }: { recor
|
|
|
|
<div>
|
|
|
|
<div>
|
|
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
|
|
<div className="font-semibold text-text">{record.title}</div>
|
|
|
|
<div className="mt-1 text-xs text-muted">{record.kind} - {record.ownerLabel ?? "No context"}</div>
|
|
|
|
<div className="mt-1 text-xs text-muted">{record.kind} - {record.ownerLabel ?? "No context"}</div>
|
|
|
|
|
|
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
|
|
|
|
|
|
<RecordSignals record={record} selected={selectedOperationIds.includes(record.id)} />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="text-right text-xs text-muted">
|
|
|
|
<div className="text-right text-xs text-muted">
|
|
|
|
<div>{formatDate(record.end)}</div>
|
|
|
|
<div>{formatDate(record.end)}</div>
|
|
|
|
@@ -989,7 +1434,7 @@ function AgendaBoard({ records, onSelect, selectedId, compact = false }: { recor
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SelectedDayPanel({ cell, onSelect, selectedId }: { cell: HeatmapCell; onSelect: (id: string) => void; selectedId: string | null }) {
|
|
|
|
function SelectedDayPanel({ cell, onSelect, selectedOperationIds, selectedId }: { cell: HeatmapCell; onSelect: (id: string) => void; selectedOperationIds: string[]; selectedId: string | null }) {
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<div className="mt-3 space-y-2">
|
|
|
|
<div className="mt-3 space-y-2">
|
|
|
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
|
|
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
|
|
|
@@ -1005,6 +1450,9 @@ function SelectedDayPanel({ cell, onSelect, selectedId }: { cell: HeatmapCell; o
|
|
|
|
<button key={task.id} type="button" onClick={() => onSelect(task.id)} className={`block w-full rounded-[16px] border px-2 py-2 text-left transition hover:bg-page/80 ${selectedId === task.id ? "border-brand bg-brand/10" : "border-line/70 bg-page/60"}`}>
|
|
|
|
<button key={task.id} type="button" onClick={() => onSelect(task.id)} className={`block w-full rounded-[16px] border px-2 py-2 text-left transition hover:bg-page/80 ${selectedId === task.id ? "border-brand bg-brand/10" : "border-line/70 bg-page/60"}`}>
|
|
|
|
<div className="font-semibold text-text">{task.title}</div>
|
|
|
|
<div className="font-semibold text-text">{task.title}</div>
|
|
|
|
<div className="mt-1 text-xs text-muted">{task.status.replaceAll("_", " ")} - {task.ownerLabel ?? "No context"}</div>
|
|
|
|
<div className="mt-1 text-xs text-muted">{task.status.replaceAll("_", " ")} - {task.ownerLabel ?? "No context"}</div>
|
|
|
|
|
|
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
|
|
|
|
|
|
<RecordSignals record={task} selected={selectedOperationIds.includes(task.id)} />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
))}
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|