2026-10-16 09:31:45 -06:00
import { useCallback , useEffect , useRef , useState , type FormEvent , type ReactNode } from "react" ;
2026-10-16 22:46:56 -06:00
import { Link , useNavigate , useParams , useSearchParams } from "react-router" ;
2026-10-16 09:31:45 -06:00
import { assetsApi } from "../api/assets" ;
2026-10-16 22:46:56 -06:00
import { ApiError , errorCode } from "../api/client" ;
2026-10-16 09:31:45 -06:00
import { packetsApi } from "../api/packets" ;
import type { PacketHeader , PacketRead , TransitionBody } from "../api/types" ;
import { homePath } from "../auth/roles" ;
import { useAuth } from "../auth/useAuth" ;
2026-10-16 22:46:56 -06:00
import { usePacketPresence } from "../auth/useEvents" ;
2026-10-16 09:31:45 -06:00
import { useGuard } from "../components/useGuard" ;
import { useChanged } from "../lib/changes" ;
import { errorText } from "../lib/format" ;
import { dueInfo , flagClass , jobName , pillClass , statusLabel , targetHours } from "../lib/packets" ;
import { DrawingViewer } from "./DrawingViewer" ;
import { MoveSheet , SendToDeptSheet } from "./sheets" ;
import { DocumentsTab , HistoryTab , MaterialsTab , WorkOrdersTab , type Mutate } from "./tabs" ;
2026-10-16 14:45:38 -06:00
import { coSummary , coToReview } from "../lib/changeOrders" ;
2026-10-16 22:46:56 -06:00
import { VisualBox } from "./visual" ;
import { ChangesTab } from "./ChangesTab" ;
import { ChecklistTab } from "./ChecklistTab" ;
import { HeatMapTab } from "./HeatMapTab" ;
import { ShopBar } from "./ShopActions" ;
2026-10-17 20:08:53 -06:00
import { HoldCallout } from "./HoldCallout" ;
import { QcBar } from "./QcActions" ;
import { QcRejectedCallout } from "./QcRejectedCallout" ;
import { showsQcRejected } from "../lib/qc" ;
import { QcTab } from "./QcTab" ;
2026-10-16 22:46:56 -06:00
import { WeldMapTab } from "./WeldMapTab" ;
2026-10-16 17:13:31 -06:00
import type { Write } from "../warehouse/PullTable" ;
import { packetMessage , pillClassFor , WH_STAGES , pillText , progress , whLine , whStage } from "../warehouse/stages" ;
import { useWhMove } from "../warehouse/useWhMove" ;
2026-10-16 09:31:45 -06:00
2026-10-17 20:08:53 -06:00
type Tab = "drawing" | "heat" | "materials" | "checklist" | "welds" | "qc" | "workorders" | "changes" | "docs" | "history" ;
const TABS : readonly Tab [] = [ "drawing" , "heat" , "materials" , "checklist" , "welds" , "qc" , "workorders" , "changes" , "docs" , "history" ];
2026-10-16 22:46:56 -06:00
function isTab ( v : string | null ) : v is Tab {
return v !== null && ( TABS as readonly string []). includes ( v );
}
const TOAST_MS = 5000 ;
2026-10-16 09:31:45 -06:00
/** The rule's signature meaning for attaching a drawing by hand. */
const SIGN_ATTACH = "Attach a drawing by hand" ;
const SIGN_FILE = "File in the data book" ;
/** One step button. Hidden when the user can neither do it nor is blocked from it. Blocked shows the reason under it. */
function Act ({
p ,
action ,
busy ,
onClick ,
className = "btn" ,
children ,
} : {
p : PacketRead ;
action : string ;
busy : boolean ;
onClick : () => void ;
className? : string ;
children : ReactNode ;
}) {
const can = p . can . includes ( action );
const why = p . blocked [ action ];
if ( ! can && ! why ) return null ;
const reason = why ? . join ( " " ) ?? "" ;
return (
< div className = "act" >
< button className = { className } type = "button" disabled = { ! can || busy } title = { ! can ? reason : undefined } onClick = { onClick }>
{ children }
</ button >
{ ! can && reason ? < div className = "why small" >{ reason }</ div > : null }
</ div >
);
}
/** Title, customer, WPS, drawing and rev, edited in place with a field-level base. */
function HeaderEdit ({ p , mutate , busy , onDone } : { p : PacketRead ; mutate : Mutate ; busy : boolean ; onDone : () => void }) {
const head = p . can . includes ( "edit_header" );
const dwg = p . can . includes ( "edit_drawing_ref" );
const [ v , setV ] = useState ({ title : p.title ?? "" , customer : p.customer ?? "" , wps : p.wps ?? "" , drawing : p.drawing ?? "" , rev : p.rev ?? "" });
const field = ( k : keyof typeof v , label : string , max : number , on : boolean ) =>
on ? (
< label className = "f" >
{ label }
< input className = "in" value = { v [ k ]} maxLength = { max } onChange = {( e ) => setV (( o ) => ({ ... o , [ k ] : e . target . value }))} />
</ label >
) : null ;
const save = async ( e : FormEvent ) => {
e . preventDefault ();
const set : Partial < PacketHeader > = {};
const base : Partial < PacketHeader > = {};
for ( const k of Object . keys ( v ) as Array < keyof typeof v >) {
const now = v [ k ]. trim () || null ;
const was = p [ k ] ?? null ;
if ( now !== was ) {
set [ k ] = now ;
base [ k ] = was ;
}
}
if ( Object . keys ( set ). length === 0 ) {
onDone ();
return ;
}
if ( await mutate (( g ) => packetsApi . patch ( p . id , { set , base , ... g }), { ok : "Saved" })) onDone ();
};
return (
< form className = "panel stack" onSubmit = { save } noValidate aria-label = "Edit details" >
< div className = "frow" >
{ field ( "title" , "Title" , 200 , head )}
{ field ( "customer" , "Customer" , 120 , head )}
{ field ( "wps" , "WPS" , 40 , head )}
{ field ( "drawing" , "Drawing no." , 60 , dwg )}
{ field ( "rev" , "Rev" , 10 , dwg )}
</ div >
< div className = "sheet-acts start" >
< button className = "btn pri" type = "submit" disabled = { busy }>
Save
</ button >
< button className = "btn ghost" type = "button" onClick = { onDone }>
Cancel
</ button >
</ div >
</ form >
);
}
/** /packets/:id: the packet for every signed-in role. Controls come from `can` and `blocked`. */
export function PacketPage() {
const params = useParams ();
const id = Number ( params . id );
const navigate = useNavigate ();
const { me } = useAuth ();
const { run , askReason , sheet : guardSheet } = useGuard ();
const [ packet , setPacket ] = useState < PacketRead | null >( null );
const [ loadError , setLoadError ] = useState < string | null >( null );
const [ msg , setMsg ] = useState < { ok : boolean ; text : string } | null > ( null );
const [ busy , setBusy ] = useState ( false );
2026-10-16 22:46:56 -06:00
const [ search ] = useSearchParams ();
const wantTab = search . get ( "tab" );
const [ tab , setTab ] = useState < Tab | null >( isTab ( wantTab ) ? wantTab : null );
2026-10-16 09:31:45 -06:00
const [ sheet , setSheet ] = useState < "dept" | "move" | null > ( null );
const [ editing , setEditing ] = useState ( false );
2026-10-16 22:46:56 -06:00
const [ toast , setToast ] = useState < string | null >( null );
const others = usePacketPresence ( Number . isFinite ( id ) ? id : null );
2026-10-16 09:31:45 -06:00
const versionRef = useRef < number | null >( null );
const fileRef = useRef < HTMLInputElement >( null );
2026-10-16 17:13:31 -06:00
// A read that was sent before a write committed can answer after the write's own answer: never go back.
const take = useCallback (( p : PacketRead ) => setPacket (( cur ) => ( cur && cur . id === p . id && cur . version > p . version ? cur : p )), []);
2026-10-16 09:31:45 -06:00
const reload = useCallback ( async () => {
try {
const p = await packetsApi . get ( id );
2026-10-16 17:13:31 -06:00
take ( p );
2026-10-16 09:31:45 -06:00
setLoadError ( null );
} catch ( err ) {
setLoadError ( errorText ( err ));
}
2026-10-16 17:13:31 -06:00
}, [ id , take ]);
2026-10-16 09:31:45 -06:00
useEffect (() => {
setPacket ( null );
2026-10-16 22:46:56 -06:00
setTab ( isTab ( wantTab ) ? wantTab : null );
2026-10-16 09:31:45 -06:00
void reload ();
2026-10-16 22:46:56 -06:00
}, [ reload , wantTab ]);
2026-10-16 09:31:45 -06:00
useEffect (() => {
versionRef . current = packet ? . version ?? null ;
}, [ packet ]);
2026-10-16 22:46:56 -06:00
useEffect (() => {
if ( ! toast ) return ;
const t = window . setTimeout (() => setToast ( null ), TOAST_MS );
return () => window . clearTimeout ( t );
}, [ toast ]);
2026-10-16 09:31:45 -06:00
// Live refresh: another screen changed this packet.
useChanged (() => void reload (), {
delayMs : 500 ,
filter : ( ev ) => Number ( ev . packet_id ) === id && ( ev . version === undefined || ev . version !== versionRef . current ),
});
const job = packet ? jobName ( packet ) : "" ;
2026-10-16 17:13:31 -06:00
// Line writes: the answer replaces the packet; the error goes back to the row that made it.
const write : Write = useCallback (
async ( fn ) => {
try {
const r = await run ( fn , { sub : job });
if ( r === undefined ) return { ok : false };
if ( r && typeof r === "object" && "id" in r ) take ( r as PacketRead );
else await reload ();
return { ok : true };
} catch ( err ) {
return { ok : false , err };
}
},
[ run , reload , job , take ],
);
const wh = useWhMove ({
run ,
onDone : setPacket ,
onError : ( err ) => {
if ( err instanceof ApiError && err . status === 409 ) void reload ();
setMsg ({ ok : false , text : errorText ( err ) });
},
});
2026-10-16 09:31:45 -06:00
const mutate : Mutate = useCallback (
async ( fn , opts = {}) => {
setBusy ( true );
setMsg ( null );
try {
const r = await run ( fn , { sign : opts.sign , sub : job });
if ( r === undefined ) return false ;
2026-10-16 17:13:31 -06:00
if ( r && typeof r === "object" && "id" in r ) take ( r as PacketRead );
2026-10-16 09:31:45 -06:00
else await reload ();
if ( opts . ok ) setMsg ({ ok : true , text : opts.ok });
return true ;
} catch ( err ) {
// stale_step, field_conflict, row_conflict: someone else moved first. Show their state.
if ( err instanceof ApiError && ( err . status === 409 || err . status === 412 )) await reload ();
2026-10-16 22:46:56 -06:00
// A step someone else already took: the page shows their state and says so once, as a toast.
if ( errorCode ( err ) === "stale_step" ) setToast ( errorText ( err ));
else setMsg ({ ok : false , text : errorText ( err ) });
2026-10-16 09:31:45 -06:00
return false ;
} finally {
setBusy ( false );
}
},
2026-10-16 17:13:31 -06:00
[ run , reload , job , take ],
2026-10-16 09:31:45 -06:00
);
if ( ! packet ) {
return loadError ? < div className = "callout warn" >{ loadError }</ div > : < div className = "empty slim" > Loading </ div >;
}
const p = packet ;
const can = ( a : string ) => p . can . includes ( a );
const draft = p . status === "draft" ;
const transition = ( action : string , extra : Omit < TransitionBody , "action" | "expect" > = {}, ok? : string ) =>
mutate (( g ) => packetsApi . transition ( p . id , { action , expect : { status : p.status , dept : p.dept.code }, ... extra , ... g }), { ok });
const setHeatMap = ( v : boolean ) =>
mutate (( g ) => packetsApi . patch ( p . id , { set : { heat_map : v }, base : { heat_map : p.heat_map }, ... g }));
const attach = async ( file : File ) => {
setBusy ( true );
setMsg ( null );
let asset ;
try {
asset = await assetsApi . upload ( file , { packet_id : p.id , purpose : "drawing" });
} catch ( err ) {
setMsg ({ ok : false , text : errorText ( err ) });
setBusy ( false );
return ;
} finally {
if ( fileRef . current ) fileRef . current . value = "" ;
}
setBusy ( false );
if ( asset . mime !== "application/pdf" ) {
setMsg ({ ok : false , text : "The drawing must be a PDF." });
return ;
}
const pages = asset . pages ?? 0 ;
await mutate (
( g ) =>
packetsApi . attachPages ( p . id , {
asset_id : asset.id ,
type : "drawing" ,
...( p . drawing ? { dwg : p.drawing } : {}),
...( p . rev ? { rev : p.rev } : {}),
... g ,
}),
{ sign : SIGN_ATTACH , ok : pages ? `Drawing attached ( ${ pages } page ${ pages === 1 ? "" : "s" } ).` : "Drawing attached." },
);
};
const sendToQc = async () => {
const note = await askReason ({
title : "Send straight to Q.C." ,
sub : job ,
label : "Note (optional, Q.C. will see it)" ,
note : "Skips the warehouse and the shop. Q.C. gets it for inspection." ,
min : 0 ,
submitLabel : "Send to Q.C." ,
});
if ( note === null ) return ;
await transition ( "send_draft_to_qc" , note ? { note } : {}, "Sent to Q.C." );
};
const remove = async () => {
const reason = await askReason ({
title : "Remove packet" ,
sub : job ,
note : "The packet leaves every board. It stays in the record with your reason." ,
min : 8 ,
danger : true ,
submitLabel : "Remove packet" ,
});
if ( reason === null ) return ;
setBusy ( true );
setMsg ( null );
try {
const r = await run (( g ) => packetsApi . remove ( p . id , { reason , ... g }), { sub : job });
if ( r !== undefined ) navigate ( `/clerical/units/ ${ p . unit . id } ` );
} catch ( err ) {
if ( err instanceof ApiError && err . status === 409 ) await reload ();
setMsg ({ ok : false , text : errorText ( err ) });
} finally {
setBusy ( false );
}
};
const removeDrawing = async ( n : number ) => {
await mutate (( g ) => packetsApi . removePages ( p . id , n , g ), { ok : "Drawing removed." });
};
const fileIt = () => mutate (( g ) => packetsApi . transition ( p . id , { action : "file" , expect : { status : p.status , dept : p.dept.code }, ... g }), { sign : SIGN_FILE , ok : "Filed in the data book." });
const drawingPages = p . pages . filter (( x ) => x . type === "drawing" );
2026-10-16 14:45:38 -06:00
const review = coToReview ( p );
2026-10-17 20:08:53 -06:00
const hasQc = !! p . qc || p . qc_history . length > 0 ;
const hidden = ( tab === "changes" && ! ( p . change_orders ?? []). length ) || ( tab === "qc" && ! hasQc );
const shownTab : Tab = hidden ? "materials" : ( tab ?? ( drawingPages . length ? "drawing" : "materials" ));
2026-10-16 09:31:45 -06:00
// The warehouse short-parts exception and the data book date are not edits of the packet itself.
const passive = new Set ([ "wh_receive_short" , "set_databook_at" ]);
const viewOnly =
p . can . every (( a ) => passive . has ( a )) && Object . keys ( p . blocked ). every (( a ) => passive . has ( a ));
const project = p . unit . project ;
const di = dueInfo ( p . unit . due_effective , project . status === "closed" );
const target = targetHours ( p );
const lotQty = Math . max ( 0 , ... p . work_orders . map (( w ) => Number ( w . qty ) || 0 ));
const backTo = me ? . screens . includes ( "clerical" ) ? `/clerical/units/ ${ p . unit . id } ` : homePath ( me ? . screens ?? []);
2026-10-16 17:13:31 -06:00
const atWh = p . status === "warehouse" ;
const whCan = atWh && can ( "set_wh_stage" );
const stage = whStage ( p );
const whMsg = whCan ? packetMessage ( p ) : null ;
const whTarget = { id : p.id , stage , job , progress : progress ( p ) };
const sendBack = async () => {
const reason = await askReason ({
title : "Send back to Clerical" ,
sub : job ,
note : "The packet goes back to Clerical as a draft. Say what needs fixing." ,
min : 8 ,
submitLabel : "Send back to Clerical" ,
});
if ( reason === null ) return ;
await transition ( "wh_send_back" , { reason }, "Sent back to Clerical." );
};
2026-10-16 09:31:45 -06:00
const hasDraftControls =
draft && [ "edit_header" , "send_to_warehouse" , "release_to_shop" , "send_draft_to_qc" , "edit_document_set" , "move_unit" , "remove" ]. some (( a ) => can ( a ) || p . blocked [ a ]);
2026-10-16 22:46:56 -06:00
const cos = ( p . change_orders ?? []). length ;
const ckOpen = p . checklist_items . filter (( i ) => ! p . stage ? . checklist ? .[ i . key ] ? . done ). length ;
2026-10-16 09:31:45 -06:00
const tabs : Array < { id : Tab ; label : string ; count? : number } > = [
{ id : "drawing" , label : "Drawing" , count : drawingPages.length },
2026-10-16 22:46:56 -06:00
{ id : "heat" , label : "Heat map" },
2026-10-16 09:31:45 -06:00
{ id : "materials" , label : "Materials" , count : p.bom.length },
2026-10-16 22:46:56 -06:00
{ id : "checklist" , label : "Checklist" , ...( p . stage ? { count : ckOpen } : {}) },
{ id : "welds" , label : "Weld map" , count : p.welds.length },
2026-10-17 20:08:53 -06:00
...( hasQc ? [{ id : "qc" as const , label : "Q.C." }] : []),
2026-10-16 09:31:45 -06:00
{ id : "workorders" , label : "Work orders" , count : p.work_orders.length },
2026-10-16 22:46:56 -06:00
...( cos ? [{ id : "changes" as const , label : "Changes" , count : cos }] : []),
2026-10-17 20:08:53 -06:00
{ id : "docs" , label : "Documents" , count : p.docs.filter (( d ) => ! d . removed ). length },
2026-10-16 09:31:45 -06:00
{ id : "history" , label : "History" },
];
2026-10-16 22:46:56 -06:00
const onTabError = ( text : string ) => setMsg ({ ok : false , text });
2026-10-16 09:31:45 -06:00
return (
<>
< Link className = "back" to = { backTo }>
← Back
</ Link >
< div className = "dhead" >
< div className = "grow0" >
< div className = "eyebrow" >
{ p . op ?? "" } · { statusLabel ( p . status )}
</ div >
< h1 >{ job }</ h1 >
{ p . title ? < div className = "dtitle" >{ p . title }</ div > : null }
< div className = "small crumbs2" >
Project < b >{ project . number }</ b > { project . name ?? "" } < span className = "muted" > › </ span > Unit < b >{ p . unit . number }</ b > { p . unit . name ?? "" }
{ project . po ? (
<>
{ " " }
· PO < span className = "mono" >{ project . po }</ span >
</>
) : null }{ " " }
· < span className = { di . cls }>{ di . txt }</ span >
</ div >
< div className = "facts" >
{ lotQty ? (
< div >
< span > Lot qty </ span >
< b >{ lotQty }</ b >
</ div >
) : null }
< div >
< span > Customer </ span >
< b >{ p . customer || "-" }</ b >
</ div >
< div >
< span > Drawing </ span >
< b >{ p . drawing ? ` ${ p . drawing } R ${ p . rev || "-" } ` : "-" }</ b >
</ div >
< div >
< span > WPS </ span >
< b >{ p . wps || "-" }</ b >
</ div >
< div >
< span > Department </ span >
< b >
{ p . dept . code } { p . dept . name }
</ b >
</ div >
< div >
< span > Target </ span >
< b >{ target . toFixed ( 1 )} h </ b >
</ div >
< div >
< span > Heat map </ span >
< b >{ p . heat_map === true ? "Yes" : p . heat_map === false ? "Not required" : "Not answered" }</ b >
</ div >
{ p . for_sup ? (
< div >
< span > For </ span >
< b >{ p . for_sup . display_name }</ b >
</ div >
) : null }
{ p . station ? (
< div >
< span > Station </ span >
< b >{ p . station . name }</ b >
</ div >
) : null }
{ p . welder ? (
< div >
< span > Welder </ span >
< b >
{ p . welder . display_name }
{ p . welder . stamp ? ` [ ${ p . welder . stamp } ]` : "" }
</ b >
</ div >
) : null }
</ div >
</ div >
< div className = "pacts" >
< span className = { pillClass ( p . status )} data-testid = "status-pill" >
{ statusLabel ( p . status )}
</ span >
{ can ( "edit_header" ) || can ( "edit_drawing_ref" ) ? (
< button className = "btn sm" type = "button" onClick = {() => setEditing (( e ) => ! e )} aria-expanded = { editing }>
Edit details
</ button >
) : null }
{ can ( "move_unit" ) ? (
< button className = "btn sm" type = "button" disabled = { busy } onClick = {() => setSheet ( "move" )}>
Move
</ button >
) : null }
</ div >
</ div >
{ p . flag ? < div className = { `small flagline ${ flagClass ( p . flag ) } ` }> ⚑ { p . flag }</ div > : null }
{ p . removed ? (
< div className = "callout warn" >
< b > Removed </ b >
{ p . removed . by ? ` by ${ p . removed . by . display_name } ` : "" }. { p . removed . reason ?? "" }
</ div >
) : null }
2026-10-17 20:08:53 -06:00
< QcRejectedCallout p = { p } />
{ p . return_note && ! ( showsQcRejected ( p ) && p . return_note === p . qc_last ? . note ) ? (
2026-10-16 09:31:45 -06:00
< div className = "callout warn" >
< b > Returned : </ b > { p . return_note }
</ div >
) : null }
{ viewOnly && ! p . removed ? (
< div className = "viewonly" >
< span aria-hidden = "true" > ● </ span >
< div >
< b > View only .</ b > This packet is with < b >{ p . holder }</ b >. You can follow its progress . Changes open up only when it is sent to you or sent back .
</ div >
</ div >
) : null }
2026-10-16 22:46:56 -06:00
{ others . length ? (
< div className = "presence small" data-testid = "presence" role = "status" >
< span className = "pdot" aria-hidden = "true" />
Also here : { others . map (( u ) => u . display_name ). join ( ", " )}
</ div >
) : null }
2026-10-16 09:31:45 -06:00
{ msg ? (
< div className = { msg . ok ? "callout" : "callout warn" } role = { msg . ok ? "status" : "alert" }>
{ msg . text }
</ div >
) : null }
2026-10-16 14:45:38 -06:00
{ review && ( me ? . roles ?? []). some (( r ) => r === "clerical" || r === "clerical_supervisor" || r === "admin" ) ? (
< div className = "callout warn" role = "status" >
2026-10-16 22:46:56 -06:00
< b > Change order CO - { review . co_no } : a change from VISUAL is waiting for your approval </ b > - { coSummary ( review )}. Nothing has gone to the shop packet yet .
2026-10-16 14:45:38 -06:00
{ review . hold ? < b > Shop packet on hold .</ b > : null }{ " " }
< button className = "btn sm pri" type = "button" onClick = {() => setTab ( "changes" )}>
Review change
</ button >
</ div >
) : null }
< VisualBox p = { p } roles = { me ? . roles ?? []} sub = { job } mutate = { mutate } busy = { busy } askReason = { askReason } onError = {( text ) => setMsg ({ ok : false , text })} />
2026-10-16 09:31:45 -06:00
{ editing ? < HeaderEdit key = { p . version } p = { p } mutate = { mutate } busy = { busy } onDone = {() => setEditing ( false )} /> : null }
{ hasDraftControls ? (
< section className = "panel actbox" aria-label = "Draft steps" >
< div >
< b > Ready to release .</ b > Check the BOM and work orders , attach the drawing , and say whether it needs a heat map . Then send it to the warehouse for the
parts pull , or straight to a department or Q . C .
</ div >
{ can ( "edit_header" ) ? (
< div className = "hmq" role = "group" aria-label = "Heat map required?" >
< b > Heat map required ? </ b >
< button type = "button" className = "chip sm" aria-pressed = { p . heat_map === true } disabled = { busy } onClick = {() => void setHeatMap ( true )}>
Yes
</ button >
< button type = "button" className = "chip sm" aria-pressed = { p . heat_map === false } disabled = { busy } onClick = {() => void setHeatMap ( false )}>
No
</ button >
{ p . heat_map === null ? < span className = "due soon small" > choose before sending </ span > : null }
</ div >
) : null }
< div className = "acts" >
{ can ( "edit_document_set" ) ? (
< div className = "act" >
< label className = { busy ? "btn dim" : "btn" }>
{ drawingPages . length ? "Attach another drawing" : "Attach drawing" }
< input
ref = { fileRef }
className = "sr"
type = "file"
accept = "application/pdf"
aria-label = "Attach drawing (PDF)"
disabled = { busy }
onChange = {( e ) => {
const f = e . target . files ? .[ 0 ];
if ( f ) void attach ( f );
}}
/>
</ label >
</ div >
) : null }
< Act p = { p } action = "send_to_warehouse" busy = { busy } className = "btn pri" onClick = {() => void transition ( "send_to_warehouse" , {}, "Sent to the warehouse." )}>
Send to warehouse
</ Act >
< Act p = { p } action = "release_to_shop" busy = { busy } onClick = {() => setSheet ( "dept" )}>
Send to a department
</ Act >
< Act p = { p } action = "send_draft_to_qc" busy = { busy } onClick = {() => void sendToQc ()}>
Send to Q . C .
</ Act >
< Act p = { p } action = "remove" busy = { busy } className = "btn rejb" onClick = {() => void remove ()}>
Remove
</ Act >
</ div >
</ section >
) : null }
2026-10-16 17:13:31 -06:00
{ atWh ? (
< section className = { whCan ? "panel actbox" : "callout" } aria-label = "Warehouse" >
< div className = "whstep" >
< span className = { pillClassFor ( stage )}>{ pillText ( stage )}</ span >
< div className = { `whline ${ WH_STAGES [ stage ]. cls } ` }>{ whLine ( p )}</ div >
</ div >
{ whMsg ? (
< div >
< b >{ whMsg . head }</ b > { whMsg . rest }
</ div >
) : null }
{ whCan || can ( "wh_send_back" ) || can ( "wh_release" ) || p . blocked . wh_release ? (
< div className = "acts" >
< Act p = { p } action = "wh_send_back" busy = { busy } className = "btn ghost" onClick = {() => void sendBack ()}>
Send back to Clerical
</ Act >
{ whCan && stage === "incoming" ? (
< div className = "act" >
< button className = "btn" type = "button" disabled = { busy || wh . busy } onClick = {() => wh . move ( whTarget , "pulling" )}>
Start pulling
</ button >
</ div >
) : null }
{ whCan && stage === "hold" ? (
< div className = "act" >
< button className = "btn" type = "button" disabled = { busy || wh . busy } onClick = {() => wh . move ( whTarget , "pulling" )}>
Resume pulling
</ button >
</ div >
) : null }
{ whCan && stage === "pulling" ? (
< div className = "act" >
< button className = "btn" type = "button" disabled = { busy || wh . busy } onClick = {() => wh . move ( whTarget , "pulled" )}>
Mark pulled
</ button >
</ div >
) : null }
{ whCan && stage === "pulled" ? (
< div className = "act" >
< button className = "btn" type = "button" disabled = { busy || wh . busy } onClick = {() => wh . move ( whTarget , "pulling" )}>
Back to pulling
</ button >
</ div >
) : null }
{ whCan && ( stage === "incoming" || stage === "pulling" ) ? (
< div className = "act" >
< button className = "btn" type = "button" disabled = { busy || wh . busy } onClick = {() => wh . move ( whTarget , "hold" )}>
Hold
</ button >
</ div >
) : null }
< Act
p = { p }
action = "wh_release"
busy = { busy || wh . busy }
className = "btn pri"
onClick = {() => void transition ( "wh_release" , {}, "Released to supervisor." )}
>
Release to supervisor { p . open_shorts ? ` ( ${ p . open_shorts } short)` : "" }
</ Act >
</ div >
) : null }
</ section >
) : null }
2026-10-16 09:31:45 -06:00
{ ! draft && ( can ( "set_vclosed" ) || can ( "file" ) || p . blocked . file ) ? (
< section className = "panel actbox" aria-label = "Close and file" >
< div >
{ p . vclosed ? (
<>
< b > Ready for the data book .</ b > Job closed in VISUAL by { p . vclosed . by ? . display_name ?? "Clerical" }. Mark it filed when the data book is done .
</>
) : (
<>
< b > Back from the shop , ready to close .</ b > Close the job in VISUAL , then click below . The packet moves on to the data book .
</>
)}
</ div >
< div className = "acts" >
{ ! p . vclosed ? (
< Act p = { p } action = "set_vclosed" busy = { busy } className = "btn pri" onClick = {() => void mutate (( g ) => packetsApi . setVclosed ( p . id , true , g ), { ok : "Marked closed in VISUAL." })}>
Job closed in VISUAL
</ Act >
) : (
< Act p = { p } action = "set_vclosed" busy = { busy } className = "btn ghost" onClick = {() => void mutate (( g ) => packetsApi . setVclosed ( p . id , false , g ))}>
Not closed in VISUAL , move back
</ Act >
)}
< Act p = { p } action = "file" busy = { busy } onClick = {() => void fileIt ()}>
Mark filed
</ Act >
</ div >
</ section >
) : null }
2026-10-16 22:46:56 -06:00
< ShopBar p = { p } busy = { busy } mutate = { mutate } askReason = { askReason } sub = { job } goTab = {( t ) => setTab ( t )} />
2026-10-17 20:08:53 -06:00
< QcBar p = { p } busy = { busy } mutate = { mutate } sub = { job } goQc = {() => setTab ( "qc" )} />
< HoldCallout p = { p } qcScreen = { me ? . screens . includes ( "qc" ) ?? false } />
2026-10-16 22:46:56 -06:00
2026-10-16 09:31:45 -06:00
< div className = "tabs" role = "tablist" aria-label = "Packet" >
{ tabs . map (( t ) => (
< button
key = { t . id }
type = "button"
role = "tab"
id = { `ptab- ${ t . id } ` }
aria-selected = { shownTab === t . id }
aria-controls = { `ppanel- ${ t . id } ` }
onClick = {() => setTab ( t . id )}
>
{ t . label }
{ t . count !== undefined ? < span className = "cnt" >{ t . count }</ span > : null }
</ button >
))}
</ div >
< div role = "tabpanel" id = { `ppanel- ${ shownTab } ` } aria-labelledby = { `ptab- ${ shownTab } ` }>
{ shownTab === "drawing" ? (
< div className = "stack" >
{ draft && can ( "edit_document_set" ) && drawingPages . length ? (
< div className = "sec-h flush" >
< span className = "small muted" >
{ drawingPages . length } drawing page { drawingPages . length === 1 ? "" : "s" } attached by hand .
</ span >
< span className = "grow" />
< button className = "btn sm ghost" type = "button" disabled = { busy } onClick = {() => void removeDrawing ( drawingPages [ 0 ] ? . n ?? 1 )}>
Remove drawing
</ button >
</ div >
) : null }
< DrawingViewer pages = { drawingPages } />
</ div >
) : null }
2026-10-16 22:46:56 -06:00
{ shownTab === "heat" ? < HeatMapTab key = { p . id } p = { p } write = { write } reload = { reload } sub = { job } onError = { onTabError } /> : null }
2026-10-16 17:13:31 -06:00
{ shownTab === "materials" ? < MaterialsTab p = { p } mutate = { mutate } busy = { busy } write = { write } reload = { reload } /> : null }
2026-10-16 22:46:56 -06:00
{ shownTab === "checklist" ? < ChecklistTab p = { p } write = { write } mutate = { mutate } reload = { reload } onError = { onTabError } /> : null }
{ shownTab === "welds" ? (
< WeldMapTab key = { p . id } p = { p } write = { write } mutate = { mutate } reload = { reload } askReason = { askReason } sub = { job } myId = { me ? . user . id ?? null } onError = { onTabError } />
) : null }
2026-10-17 20:08:53 -06:00
{ shownTab === "qc" ? < QcTab p = { p } write = { write } mutate = { mutate } reload = { reload } onError = { onTabError } /> : null }
2026-10-16 09:31:45 -06:00
{ shownTab === "workorders" ? < WorkOrdersTab p = { p } /> : null }
2026-10-16 14:45:38 -06:00
{ shownTab === "changes" ? < ChangesTab p = { p } roles = { me ? . roles ?? []} sub = { job } mutate = { mutate } busy = { busy } askReason = { askReason } /> : null }
2026-10-17 20:08:53 -06:00
{ shownTab === "docs" ? < DocumentsTab p = { p } mutate = { mutate } busy = { busy } write = { write } askReason = { askReason } sub = { job } /> : null }
2026-10-16 09:31:45 -06:00
{ shownTab === "history" ? < HistoryTab p = { p } /> : null }
</ div >
{ sheet === "dept" ? (
< SendToDeptSheet
p = { p }
sub = { job }
onCancel = {() => setSheet ( null )}
onSend = {( dept , note ) => {
setSheet ( null );
void transition ( "release_to_shop" , { dept , ...( note ? { note } : {}) }, "Sent to the department." );
}}
/>
) : null }
{ sheet === "move" ? (
< MoveSheet
p = { p }
sub = { job }
onCancel = {() => setSheet ( null )}
onMove = {( unitId , reason ) => {
setSheet ( null );
void mutate (( g ) => packetsApi . move ( p . id , { unit_id : unitId , ...( reason ? { reason } : {}), ... g }), { ok : "Packet moved." });
}}
/>
) : null }
2026-10-16 17:13:31 -06:00
{ wh . sheet }
2026-10-16 09:31:45 -06:00
{ guardSheet }
2026-10-16 22:46:56 -06:00
{ toast ? (
< div className = "toast" role = "status" data-testid = "toast" >
{ toast }
</ div >
) : null }
2026-10-16 09:31:45 -06:00
</>
);
}