From 1198aaa37393e7f03b6e0c87cff63248ff60034f Mon Sep 17 00:00:00 2001 From: Lorentz Hinrichsen Date: Tue, 17 Feb 2026 09:42:39 -0500 Subject: [PATCH] feat: add coil-by-coil report, job status by plant, job traveler + PDFs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements C-010 (Coil-by-Coil), C-011 (Job Status by Plant), and C-012 (Job Traveler + PDF) completing 12/16 Phase 2 core features. C-010: Job number search → detailed coil-level breakdown showing materials used and produced with independent search/sort/CSV export. Embeds full 782-line CoilByCoil.sql query in service layer. C-011: Auto-loading job status view grouped by plant using OpenOrdersQueryV3 stored procedure. Includes web table with search/sort/CSV export and landscape PDF download. C-012: Multi-section job traveler with header info, shipping schedule, raw materials, operations, and Part_UD paint specifications. Includes job number search page, detail page, and PDF export with Vorteq branding. Deduplicates flat SQL results into structured data. Co-Authored-By: Claude Opus 4.6 --- TASKS.md | 40 +- .../coil-activity/coil-by-coil/page.tsx | 113 ++++ .../(portal)/jobs/[jobNum]/traveler/page.tsx | 76 +++ src/app/(portal)/jobs/status/page.tsx | 71 +++ src/app/(portal)/jobs/traveler/page.tsx | 103 ++++ .../api/coil-activity/coil-by-coil/route.ts | 35 ++ .../api/jobs/[jobNum]/traveler/pdf/route.ts | 41 ++ src/app/api/jobs/[jobNum]/traveler/route.ts | 38 ++ src/app/api/jobs/status/pdf/route.ts | 119 ++++ src/app/api/jobs/status/route.ts | 28 + .../coil-activity/coil-by-coil-table.tsx | 547 ++++++++++++++++++ src/components/jobs/job-status-table.tsx | 214 +++++++ src/components/jobs/job-traveler-detail.tsx | 270 +++++++++ src/components/layout/portal-sidebar.tsx | 5 +- src/lib/pdf-templates/job-traveler.ts | 274 +++++++++ src/services/coil-activity.ts | 305 +++++++++- src/services/jobs.ts | 319 ++++++++++ src/types/coil-activity.ts | 39 ++ src/types/jobs.ts | 93 +++ 19 files changed, 2712 insertions(+), 18 deletions(-) create mode 100644 src/app/(portal)/coil-activity/coil-by-coil/page.tsx create mode 100644 src/app/(portal)/jobs/[jobNum]/traveler/page.tsx create mode 100644 src/app/(portal)/jobs/status/page.tsx create mode 100644 src/app/(portal)/jobs/traveler/page.tsx create mode 100644 src/app/api/coil-activity/coil-by-coil/route.ts create mode 100644 src/app/api/jobs/[jobNum]/traveler/pdf/route.ts create mode 100644 src/app/api/jobs/[jobNum]/traveler/route.ts create mode 100644 src/app/api/jobs/status/pdf/route.ts create mode 100644 src/app/api/jobs/status/route.ts create mode 100644 src/components/coil-activity/coil-by-coil-table.tsx create mode 100644 src/components/jobs/job-status-table.tsx create mode 100644 src/components/jobs/job-traveler-detail.tsx create mode 100644 src/lib/pdf-templates/job-traveler.ts create mode 100644 src/services/jobs.ts create mode 100644 src/types/jobs.ts diff --git a/TASKS.md b/TASKS.md index 8fc7c2d..017f7f9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -128,7 +128,7 @@ ## Phase 2: Core Features (Est. 80-110 hrs) -**Progress:** 9/16 tasks complete +**Progress:** 12/16 tasks complete ### C-001: Dashboard - [x] Dashboard page at `/(portal)/dashboard/page.tsx` @@ -230,25 +230,33 @@ - **Deps:** F-006, F-009 | **Est:** 4 hrs | **Status:** ✅ Complete ### C-010: Coil-by-Coil Report -- [ ] `/(portal)/coil-activity/coil-by-coil/page.tsx` -- [ ] Job number search input -- [ ] Service: `getCoilByCoil(jobNum, customer)` using `CoilByCoil.sql` -- [ ] Detail display -- **Deps:** F-006 | **Est:** 3 hrs +- [x] `/(portal)/coil-activity/coil-by-coil/page.tsx` — job number search → coil detail display +- [x] Job number search input with search button +- [x] Service: `getCoilByCoil(jobNum, customer)` using full 782-line `CoilByCoil.sql` embedded in service +- [x] Header info card (Job#, Customer, PO, Part, Plant, etc.) +- [x] Two-section table: Materials Used + Materials Produced with independent search/sort/CSV export +- [x] HDC→HDM customer ID mapping +- **Deps:** F-006 | **Est:** 3 hrs | **Status:** ✅ Complete ### C-011: Job Status by Plant -- [ ] `/(portal)/jobs/status/page.tsx` -- [ ] Service: `getJobStatusByPlantByCustomer(custId, dbName)` using `JobStatusByPlantByCustomer.sql` -- [ ] Note: requires DBNAME cross-database parameter -- [ ] Data table grouped by plant -- **Deps:** F-006 | **Est:** 3 hrs +- [x] `/(portal)/jobs/status/page.tsx` — auto-loads on mount +- [x] Service: `getJobStatusByPlant(custId)` using `OpenOrdersQueryV3` stored procedure +- [x] DBNAME cross-database parameter via `getPortalDbName()` +- [x] Data table grouped by plant with plant section header rows +- [x] Search, sort, CSV export +- [x] PDF export: `/api/jobs/status/pdf` (landscape Letter, grouped by plant) +- [x] HDC→HDM customer ID mapping +- **Deps:** F-006 | **Est:** 3 hrs | **Status:** ✅ Complete ### C-012: Job Traveler + PDF -- [ ] `/(portal)/jobs/[jobNum]/traveler/page.tsx` -- [ ] Service: `getJobTraveler(jobNum)` using `jobTraveler.sql` -- [ ] Detailed routing/operations view -- [ ] PDF export -- **Deps:** F-006 | **Est:** 4 hrs +- [x] `/(portal)/jobs/traveler/page.tsx` — job number search page +- [x] `/(portal)/jobs/[jobNum]/traveler/page.tsx` — multi-section detail page +- [x] Service: `getJobTraveler(jobNum)` using full `jobTraveler.sql` with Part_UD paint data +- [x] Structured data: header, shipping schedule, raw materials, operations, paint specifications (deduplicated from flat SQL results) +- [x] Detail view: Job info card, Schedule dates, Shipping schedule table, Raw materials table, Operations table, Part specifications card +- [x] PDF export: `/api/jobs/{jobNum}/traveler/pdf` (landscape Letter, full Vorteq branding) +- [x] Sidebar navigation: Job Traveler link under Jobs section +- **Deps:** F-006 | **Est:** 4 hrs | **Status:** ✅ Complete ### C-013: Shipment Request Cart Workflow - [ ] `/(portal)/shipment-requests/page.tsx` — list existing requests diff --git a/src/app/(portal)/coil-activity/coil-by-coil/page.tsx b/src/app/(portal)/coil-activity/coil-by-coil/page.tsx new file mode 100644 index 0000000..718100f --- /dev/null +++ b/src/app/(portal)/coil-activity/coil-by-coil/page.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import type { CoilByCoilRow } from '@/types/coil-activity'; +import { CoilByCoilTable } from '@/components/coil-activity/coil-by-coil-table'; +import { Card, CardContent } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Search } from 'lucide-react'; + +function LoadingSkeleton() { + return ( + + +
+ {[...Array(10)].map((_, i) => ( +
+ ))} +
+ + + ); +} + +export default function CoilByCoilPage() { + const [jobNum, setJobNum] = useState(''); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSearch = useCallback(async () => { + if (!jobNum.trim()) return; + setLoading(true); + setError(null); + try { + const res = await fetch( + `/api/coil-activity/coil-by-coil?jobNum=${encodeURIComponent( + jobNum.trim() + )}` + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + const json = await res.json(); + setData(json); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [jobNum]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSearch(); + }; + + return ( +
+
+

Coil-by-Coil Report

+

+ View coil-level material breakdown for a specific job +

+
+ +
+
+ + setJobNum(e.target.value)} + onKeyDown={handleKeyDown} + className="pl-8" + /> +
+ +
+ + {error ? ( + + + Failed to load coil-by-coil data: {error} + + + ) : loading ? ( + + ) : data !== null ? ( + data.length === 0 ? ( + + + No coil-by-coil data found for job {jobNum} + + + ) : ( + + ) + ) : ( + + + Enter a job number to view coil-by-coil details + + + )} +
+ ); +} diff --git a/src/app/(portal)/jobs/[jobNum]/traveler/page.tsx b/src/app/(portal)/jobs/[jobNum]/traveler/page.tsx new file mode 100644 index 0000000..62382f0 --- /dev/null +++ b/src/app/(portal)/jobs/[jobNum]/traveler/page.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useParams } from 'next/navigation'; +import type { JobTravelerData } from '@/types/jobs'; +import { JobTravelerDetail } from '@/components/jobs/job-traveler-detail'; +import { Card, CardContent } from '@/components/ui/card'; + +function LoadingSkeleton() { + return ( + + +
+ {[...Array(8)].map((_, i) => ( +
+ ))} +
+ + + ); +} + +export default function JobTravelerDetailPage() { + const params = useParams(); + const jobNum = params.jobNum as string; + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/jobs/${encodeURIComponent(jobNum)}/traveler`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + const json = await res.json(); + setData(json); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [jobNum]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return ( +
+
+

Job Traveler — {jobNum}

+

+ Detailed job routing and operations information +

+
+ + {error ? ( + + + {error.includes('not found') + ? `No job traveler found for job ${jobNum}` + : `Failed to load job traveler: ${error}`} + + + ) : loading || data === null ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/(portal)/jobs/status/page.tsx b/src/app/(portal)/jobs/status/page.tsx new file mode 100644 index 0000000..cdfbf56 --- /dev/null +++ b/src/app/(portal)/jobs/status/page.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import type { JobStatusRow } from '@/types/jobs'; +import { JobStatusTable } from '@/components/jobs/job-status-table'; +import { Card, CardContent } from '@/components/ui/card'; + +function LoadingSkeleton() { + return ( + + +
+ {[...Array(10)].map((_, i) => ( +
+ ))} +
+ + + ); +} + +export default function JobStatusPage() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch('/api/jobs/status'); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + const json = await res.json(); + setData(json); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return ( +
+
+

Job Status

+

+ View all open jobs grouped by plant +

+
+ + {error ? ( + + + Failed to load job status: {error} + + + ) : loading || data === null ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/(portal)/jobs/traveler/page.tsx b/src/app/(portal)/jobs/traveler/page.tsx new file mode 100644 index 0000000..8db75ab --- /dev/null +++ b/src/app/(portal)/jobs/traveler/page.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import type { JobTravelerData } from '@/types/jobs'; +import { JobTravelerDetail } from '@/components/jobs/job-traveler-detail'; +import { Card, CardContent } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Search } from 'lucide-react'; + +function LoadingSkeleton() { + return ( + + +
+ {[...Array(8)].map((_, i) => ( +
+ ))} +
+ + + ); +} + +export default function JobTravelerPage() { + const [jobNum, setJobNum] = useState(''); + const [searchedJobNum, setSearchedJobNum] = useState(''); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSearch = useCallback(async () => { + const trimmed = jobNum.trim(); + if (!trimmed) return; + setLoading(true); + setError(null); + setSearchedJobNum(trimmed); + try { + const res = await fetch(`/api/jobs/${encodeURIComponent(trimmed)}/traveler`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + const json = await res.json(); + setData(json); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [jobNum]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSearch(); + }; + + return ( +
+
+

Job Traveler

+

+ View detailed job routing and operations information +

+
+ +
+
+ + setJobNum(e.target.value)} + onKeyDown={handleKeyDown} + className="pl-8" + /> +
+ +
+ + {error ? ( + + + {error.includes('not found') + ? `No job traveler found for job ${searchedJobNum}` + : `Failed to load job traveler: ${error}`} + + + ) : loading ? ( + + ) : data !== null ? ( + + ) : ( + + + Enter a job number to view the job traveler + + + )} +
+ ); +} diff --git a/src/app/api/coil-activity/coil-by-coil/route.ts b/src/app/api/coil-activity/coil-by-coil/route.ts new file mode 100644 index 0000000..bbcb385 --- /dev/null +++ b/src/app/api/coil-activity/coil-by-coil/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany } from '@/lib/permissions'; +import { getCoilByCoil } from '@/services/coil-activity'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const jobNum = searchParams.get('jobNum'); + + if (!jobNum) { + return NextResponse.json({ error: 'jobNum is required' }, { status: 400 }); + } + + try { + const data = await getCoilByCoil(jobNum, activeCompany.epicor_cust_id); + return NextResponse.json(data); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + let details = ''; + if (err && typeof err === 'object' && 'originalError' in err) { + const originalError = (err as { originalError: unknown }).originalError; + details = originalError instanceof Error ? originalError.message : String(originalError); + } + console.error('Error:', err); + return NextResponse.json({ error: message, details }, { status: 500 }); + } +} diff --git a/src/app/api/jobs/[jobNum]/traveler/pdf/route.ts b/src/app/api/jobs/[jobNum]/traveler/pdf/route.ts new file mode 100644 index 0000000..e9a5a71 --- /dev/null +++ b/src/app/api/jobs/[jobNum]/traveler/pdf/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getJobTraveler } from '@/services/jobs'; +import { getQuestSession, getActiveCompany } from '@/lib/permissions'; +import { generatePdfFromHtml } from '@/lib/pdf'; +import { renderJobTravelerHtml } from '@/lib/pdf-templates/job-traveler'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ jobNum: string }> } +) { + const { jobNum } = await params; + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + try { + const data = await getJobTraveler(jobNum); + if (!data) { + return NextResponse.json({ error: 'Job not found' }, { status: 404 }); + } + + const html = renderJobTravelerHtml(data); + const pdfBuffer = await generatePdfFromHtml(html, { format: 'Letter', landscape: true }); + + return new NextResponse(new Uint8Array(pdfBuffer), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="job-traveler-${jobNum}.pdf"`, + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('Job traveler PDF error:', err); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/jobs/[jobNum]/traveler/route.ts b/src/app/api/jobs/[jobNum]/traveler/route.ts new file mode 100644 index 0000000..810f8b2 --- /dev/null +++ b/src/app/api/jobs/[jobNum]/traveler/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany } from '@/lib/permissions'; +import { getJobTraveler } from '@/services/jobs'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ jobNum: string }> } +) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + const { jobNum } = await params; + + try { + const data = await getJobTraveler(jobNum); + + if (data === null) { + return NextResponse.json({ error: 'Job not found' }, { status: 404 }); + } + + return NextResponse.json(data); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + let details = ''; + if (err && typeof err === 'object' && 'originalError' in err) { + const originalError = (err as { originalError: unknown }).originalError; + details = originalError instanceof Error ? originalError.message : String(originalError); + } + console.error('Error:', err); + return NextResponse.json({ error: message, details }, { status: 500 }); + } +} diff --git a/src/app/api/jobs/status/pdf/route.ts b/src/app/api/jobs/status/pdf/route.ts new file mode 100644 index 0000000..835f730 --- /dev/null +++ b/src/app/api/jobs/status/pdf/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getJobStatusByPlant } from '@/services/jobs'; +import { getQuestSession, getActiveCompany } from '@/lib/permissions'; +import { generatePdfFromHtml } from '@/lib/pdf'; + +export const dynamic = 'force-dynamic'; + +function fmtDate(iso: string | null): string { + if (!iso) return ''; + const d = new Date(iso); + return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`; +} + +function renderJobStatusHtml(data: import('@/types/jobs').JobStatusRow[]): string { + // Group by plant + const grouped: Record = {}; + for (const row of data) { + const p = row.plant_name || 'Unknown'; + if (!grouped[p]) grouped[p] = []; + grouped[p].push(row); + } + + let plantSections = ''; + for (const [plant, rows] of Object.entries(grouped)) { + const dataRows = rows.map((r, i) => ` + + ${r.job_num} + ${r.job_released ? 'Yes' : 'No'} + ${fmtDate(r.due_date)} + ${r.part_num} + ${r.customer_part_num || ''} + ${r.part_description} + ${r.prod_qty.toLocaleString()} + ${r.qty_completed.toLocaleString()} + ${r.order_num ?? ''} + ${r.order_line ?? ''} + ${r.order_rel_num ?? ''} + ${r.po_num || ''} + + `).join(''); + + plantSections += ` + + + ${plant} (${rows.length} jobs) + + + ${dataRows} + `; + } + + return ` + + + + + + +
+
Job Status by Plant
+
Generated ${new Date().toLocaleDateString()}
+
+ + + + + + + + + + + + + + + + + + + ${plantSections} + +
Job#ReleasedDue DatePart#Cust Part#DescriptionProd QtyQty CompleteOrderLineRelPO#
+
+ Total: ${data.length} jobs across ${Object.keys(grouped).length} plants +
+ +`; +} + +export async function GET() { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + try { + const data = await getJobStatusByPlant(activeCompany.epicor_cust_id); + const html = renderJobStatusHtml(data); + const pdfBuffer = await generatePdfFromHtml(html, { format: 'Letter', landscape: true }); + + return new NextResponse(new Uint8Array(pdfBuffer), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="job-status-${new Date().toISOString().split('T')[0]}.pdf"`, + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('Job status PDF error:', err); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/jobs/status/route.ts b/src/app/api/jobs/status/route.ts new file mode 100644 index 0000000..d17562f --- /dev/null +++ b/src/app/api/jobs/status/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany } from '@/lib/permissions'; +import { getJobStatusByPlant } from '@/services/jobs'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + try { + const data = await getJobStatusByPlant(activeCompany.epicor_cust_id); + return NextResponse.json(data); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + let details = ''; + if (err && typeof err === 'object' && 'originalError' in err) { + const originalError = (err as { originalError: unknown }).originalError; + details = originalError instanceof Error ? originalError.message : String(originalError); + } + console.error('Error:', err); + return NextResponse.json({ error: message, details }, { status: 500 }); + } +} diff --git a/src/components/coil-activity/coil-by-coil-table.tsx b/src/components/coil-activity/coil-by-coil-table.tsx new file mode 100644 index 0000000..f9edcc1 --- /dev/null +++ b/src/components/coil-activity/coil-by-coil-table.tsx @@ -0,0 +1,547 @@ +'use client'; + +import { useState } from 'react'; +import type { CoilByCoilRow } from '@/types/coil-activity'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Download, Search } from 'lucide-react'; +import { + SortableTableHead, + useSortableTable, +} from '@/components/ui/sortable-table-head'; +import { formatDate } from '@/lib/utils'; + +type Props = { + data: CoilByCoilRow[]; +}; + +export function CoilByCoilTable({ data }: Props) { + const [usedSearchTerm, setUsedSearchTerm] = useState(''); + const [producedSearchTerm, setProducedSearchTerm] = useState(''); + + // Get header info from first row + const header = data.length > 0 ? data[0] : null; + + // Split into Used and Produced + const usedRows = data.filter((r) => r.tran_type === 'Used'); + const producedRows = data.filter((r) => r.tran_type === 'Produced'); + + // Filter Used rows + const filteredUsedRows = usedRows.filter((row) => { + if (!usedSearchTerm) return true; + const term = usedSearchTerm.toLowerCase(); + return ( + row.lot_used?.toLowerCase().includes(term) || + row.part_num_used?.toLowerCase().includes(term) || + row.part_desc_used?.toLowerCase().includes(term) || + row.tran_reference?.toLowerCase().includes(term) + ); + }); + + // Filter Produced rows + const filteredProducedRows = producedRows.filter((row) => { + if (!producedSearchTerm) return true; + const term = producedSearchTerm.toLowerCase(); + return ( + row.prod_lot?.toLowerCase().includes(term) || + row.prod_skid?.toLowerCase().includes(term) + ); + }); + + // Sort Used rows + const { + sortedData: sortedUsedRows, + sortKey: usedSortKey, + sortDirection: usedSortDirection, + handleSort: handleUsedSort, + } = useSortableTable(filteredUsedRows, 'lot_used'); + + // Sort Produced rows + const { + sortedData: sortedProducedRows, + sortKey: producedSortKey, + sortDirection: producedSortDirection, + handleSort: handleProducedSort, + } = useSortableTable(filteredProducedRows, 'prod_lot'); + + // Export Used to CSV + const handleExportUsed = () => { + const headers = [ + 'Lot#', + 'Part#', + 'Description', + 'Start Wt', + 'RTS Good', + 'RTS Reject', + 'Total Used', + 'Reference', + ]; + const rows = sortedUsedRows.map((row) => [ + row.lot_used || '', + row.part_num_used || '', + row.part_desc_used || '', + row.start_wt?.toString() || '', + row.rts_wt_good?.toString() || '', + row.rts_reject?.toString() || '', + row.total_wt_used?.toString() || '', + row.tran_reference || '', + ]); + + const csv = [headers, ...rows].map((r) => r.join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `coil-by-coil-used-${header?.job_num || 'export'}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + // Export Produced to CSV + const handleExportProduced = () => { + const headers = [ + 'Lot#', + 'Good Wt', + 'Hold Wt', + 'Reject Wt', + 'Theo Wt', + 'Length', + 'Skid#', + 'Date', + ]; + const rows = sortedProducedRows.map((row) => [ + row.prod_lot || '', + row.prod_wt_good?.toString() || '', + row.prod_wt_hold?.toString() || '', + row.prod_wt_reject?.toString() || '', + row.prod_theoretical_wt?.toString() || '', + row.prod_length?.toString() || '', + row.prod_skid || '', + row.prod_date ? formatDate(new Date(row.prod_date)) : '', + ]); + + const csv = [headers, ...rows].map((r) => r.join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `coil-by-coil-produced-${header?.job_num || 'export'}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+ {/* Header Info Card */} + {header && ( + + +
+
+ + Job #: + {' '} + {header.job_num} +
+
+ + Customer: + {' '} + {header.customer_name} +
+
+ + PO: + {' '} + {header.cust_po || '-'} +
+
+ + Part: + {' '} + {header.part_num} +
+
+ + Description: + {' '} + {header.part_description} +
+
+ + Prod Type: + {' '} + {header.prod_type || '-'} +
+
+ + Order/Line: + {' '} + {header.order_num || '-'}/{header.order_line || '-'} +
+
+ + Completion: + {' '} + {header.completion_date + ? formatDate(new Date(header.completion_date)) + : '-'} +
+
+ + Ship To: + {' '} + {header.ship_to_name || '-'} +
+
+ + Cust Part: + {' '} + {header.cust_part || '-'} +
+
+ + Plant: + {' '} + {header.plant_name} +
+
+ + Mfg Lot: + {' '} + {header.mfg_lot || '-'} +
+
+
+
+ )} + + {/* Materials Used Table */} + + +
+
+ Materials Used + + {sortedUsedRows.length}{' '} + {sortedUsedRows.length === 1 ? 'record' : 'records'} + +
+
+
+ + setUsedSearchTerm(e.target.value)} + className="w-64 pl-8" + /> +
+ +
+
+
+ +
+ + + + + Lot# + + + Part# + + + Description + + + Start Wt + + + RTS Good + + + RTS Reject + + + Total Used + + + Reference + + + + + {sortedUsedRows.length === 0 ? ( + + + No materials used found + + + ) : ( + sortedUsedRows.map((row, idx) => ( + + {row.lot_used || '-'} + {row.part_num_used || '-'} + {row.part_desc_used || '-'} + + {row.start_wt?.toLocaleString() || '-'} + + + {row.rts_wt_good?.toLocaleString() || '-'} + + + {row.rts_reject?.toLocaleString() || '-'} + + + {row.total_wt_used?.toLocaleString() || '-'} + + {row.tran_reference || '-'} + + )) + )} + +
+
+
+
+ + {/* Materials Produced Table */} + + +
+
+ Materials Produced + + {sortedProducedRows.length}{' '} + {sortedProducedRows.length === 1 ? 'record' : 'records'} + +
+
+
+ + setProducedSearchTerm(e.target.value)} + className="w-64 pl-8" + /> +
+ +
+
+
+ +
+ + + + + Lot# + + + Good Wt + + + Hold Wt + + + Reject Wt + + + Theo Wt + + + Length + + + Skid# + + + Date + + + + + {sortedProducedRows.length === 0 ? ( + + + No materials produced found + + + ) : ( + sortedProducedRows.map((row, idx) => ( + + {row.prod_lot || '-'} + + {row.prod_wt_good?.toLocaleString() || '-'} + + + {row.prod_wt_hold?.toLocaleString() || '-'} + + + {row.prod_wt_reject?.toLocaleString() || '-'} + + + {row.prod_theoretical_wt?.toLocaleString() || '-'} + + + {row.prod_length?.toLocaleString() || '-'} + + {row.prod_skid || '-'} + + {row.prod_date + ? formatDate(new Date(row.prod_date)) + : '-'} + + + )) + )} + +
+
+
+
+
+ ); +} diff --git a/src/components/jobs/job-status-table.tsx b/src/components/jobs/job-status-table.tsx new file mode 100644 index 0000000..a610020 --- /dev/null +++ b/src/components/jobs/job-status-table.tsx @@ -0,0 +1,214 @@ +'use client'; + +import { Fragment, useState } from 'react'; +import type { JobStatusRow } from '@/types/jobs'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableRow, +} from '@/components/ui/table'; +import { Download, Search } from 'lucide-react'; +import { + SortableTableHead, + useSortableTable, +} from '@/components/ui/sortable-table-head'; +import { formatDate } from '@/lib/utils'; + +type Props = { + data: JobStatusRow[]; +}; + +export function JobStatusTable({ data }: Props) { + const [searchTerm, setSearchTerm] = useState(''); + + const filteredData = data.filter((row) => { + const s = searchTerm.toLowerCase(); + return ( + row.plant_name.toLowerCase().includes(s) || + row.job_num.toLowerCase().includes(s) || + row.part_num.toLowerCase().includes(s) || + (row.customer_part_num || '').toLowerCase().includes(s) || + row.part_description.toLowerCase().includes(s) || + (row.po_num || '').toLowerCase().includes(s) + ); + }); + + const { sortKey, sortDirection, handleSort, sortedData } = + useSortableTable(filteredData); + + // Group by plant_name + const groupedByPlant: Record = {}; + for (const row of sortedData) { + const plant = row.plant_name || 'Unknown'; + if (!groupedByPlant[plant]) groupedByPlant[plant] = []; + groupedByPlant[plant].push(row); + } + + const handleExportCSV = () => { + const headers = [ + 'Plant', + 'Job#', + 'Released', + 'Due Date', + 'Part#', + 'Cust Part#', + 'Description', + 'Prod Qty', + 'Qty Complete', + 'Order', + 'Line', + 'Rel', + 'PO#', + ]; + const rows = sortedData.map((row) => [ + row.plant_name, + row.job_num, + row.job_released ? 'Yes' : 'No', + row.due_date ? new Date(row.due_date).toLocaleDateString() : '', + row.part_num, + row.customer_part_num || '', + row.part_description, + row.prod_qty, + row.qty_completed, + row.order_num ?? '', + row.order_line ?? '', + row.order_rel_num ?? '', + row.po_num || '', + ]); + const csvContent = [headers, ...rows] + .map((row) => row.map((cell) => `"${cell}"`).join(',')) + .join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `job-status-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + window.URL.revokeObjectURL(url); + }; + + return ( + + + Job Status by Plant + Showing {sortedData.length} open jobs + + +
+
+ + setSearchTerm(e.target.value)} + className="pl-8" + /> +
+ +
+ +
+ + + + + Job# + + + Released + + + Due Date + + + Part# + + + Cust Part# + + + Description + + + Prod Qty + + + Qty Complete + + + Order + + + Line + + + Rel + + + PO# + + + + + {sortedData.length === 0 ? ( + + + No open jobs found + + + ) : ( + // Render grouped by plant + Object.entries(groupedByPlant).map(([plant, rows]) => ( + + {/* Plant section header */} + + + {plant} ({rows.length} jobs) + + + {rows.map((row, i) => ( + + {row.job_num} + {row.job_released ? 'Yes' : 'No'} + {row.due_date ? formatDate(new Date(row.due_date)) : '-'} + {row.part_num || '-'} + {row.customer_part_num || '-'} + {row.part_description || '-'} + {row.prod_qty.toLocaleString()} + {row.qty_completed.toLocaleString()} + {row.order_num ?? '-'} + {row.order_line ?? '-'} + {row.order_rel_num ?? '-'} + {row.po_num || '-'} + + ))} + + )) + )} + +
+
+ +
+ Showing {sortedData.length} of {data.length} jobs across {Object.keys(groupedByPlant).length} plants +
+
+
+ ); +} diff --git a/src/components/jobs/job-traveler-detail.tsx b/src/components/jobs/job-traveler-detail.tsx new file mode 100644 index 0000000..e092811 --- /dev/null +++ b/src/components/jobs/job-traveler-detail.tsx @@ -0,0 +1,270 @@ +'use client'; + +import type { JobTravelerData } from '@/types/jobs'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Download } from 'lucide-react'; +import { formatDate } from '@/lib/utils'; + +type Props = { + data: JobTravelerData; +}; + +export function JobTravelerDetail({ data }: Props) { + const { header, shipping_schedule, materials, operations, paint_data } = data; + + return ( +
+ {/* Download PDF button */} + + + {/* Job Header */} + + + Job: {header.job_num} + + +
+
Plant: {header.plant}
+
Customer: {header.customer_name}
+
PO Number: {header.po_num || '-'}
+
Part: {header.part_num}
+
Description: {header.part_description}
+
Rev: {header.revision || '-'}
+
Cust Part: {header.cust_part || '-'}
+
Warehouse: {header.warehouse || '-'}
+
+
+
+ + {/* Schedule Dates */} + + + Schedule + + +
+
Total Qty: {header.prod_qty.toLocaleString()} {header.ium}
+
Start Date: {header.start_date ? formatDate(new Date(header.start_date)) : '-'}
+
Due Date: {header.due_date ? formatDate(new Date(header.due_date)) : '-'}
+
Req. By: {header.req_due_date ? formatDate(new Date(header.req_due_date)) : '-'}
+
+ {header.comment_text && ( +
+ Job Comments: {header.comment_text} +
+ )} +
+
+ + {/* Shipping Schedule */} + {shipping_schedule.length > 0 && ( + + + Shipping Schedule + + +
+ + + + Date + Status + SO + Line + Rel + Order Qty + UM + Qty from Job + Ship Via + Ship To + + + + {shipping_schedule.map((line, i) => ( + + {line.need_by_date ? formatDate(new Date(line.need_by_date)) : '-'} + + + {line.line_status || '-'} + + + {line.order_num ?? '-'} + {line.order_line ?? '-'} + {line.order_rel_num ?? '-'} + {line.order_qty?.toLocaleString() ?? '-'} + {line.sales_um || '-'} + {line.qty_from_job?.toLocaleString() ?? '-'} + {line.ship_via || '-'} + {line.ship_to || '-'} + + ))} + +
+
+
+
+ )} + + {/* Raw Material Components */} + {materials.length > 0 && ( + + + Raw Material Components + + +
+ + + + Seq + Part# + Description + Req Qty + UM + Whse + Rel OP + Status + + + + {materials.map((mtl, i) => ( + + {mtl.mtl_seq} + {mtl.part_num || '-'} + {mtl.description || '-'} + {mtl.required_qty?.toLocaleString() ?? '-'} + {mtl.ium || '-'} + {mtl.warehouse_code || '-'} + {mtl.related_operation ?? '-'} + + + {mtl.issue_status || '-'} + + + + ))} + +
+
+
+
+ )} + + {/* Operations */} + {operations.length > 0 && ( + + + Operations + + +
+ + + + Seq + Code + Description + Qty + Res. + Setup Hrs + Prod Hrs + Standard + Start + Due + Status + + + + {operations.map((op, i) => ( + + {op.opr_seq} + {op.op_code || '-'} + {op.op_desc || '-'} + {op.run_qty?.toLocaleString() ?? '-'} + {op.crew_size ?? '-'} + {op.est_setup_hours?.toFixed(2) ?? '-'} + {op.est_prod_hours?.toFixed(2) ?? '-'} + {op.prod_standard?.toFixed(2) ?? '-'} + {op.start_date ? formatDate(new Date(op.start_date)) : '-'} + {op.due_date ? formatDate(new Date(op.due_date)) : '-'} + + + {op.status || '-'} + + + + ))} + +
+
+
+
+ )} + + {/* Paint/Part Specifications */} + {paint_data && Object.keys(paint_data).length > 0 && ( + + + Part Specifications + + +
+ {Object.entries(paint_data) + .filter(([, v]) => v != null && String(v).trim() !== '' && String(v) !== '0' && String(v) !== '0.00000000') + .map(([key, value]) => ( +
+ {formatPaintKey(key)}:{' '} + {String(value)} +
+ ))} +
+
+
+ )} +
+ ); +} + +/** Format paint_data key: remove _c suffix, split on camelCase */ +function formatPaintKey(key: string): string { + return key + .replace(/_c$/, '') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} diff --git a/src/components/layout/portal-sidebar.tsx b/src/components/layout/portal-sidebar.tsx index 061b127..ad4dd2b 100644 --- a/src/components/layout/portal-sidebar.tsx +++ b/src/components/layout/portal-sidebar.tsx @@ -134,7 +134,10 @@ export function PortalSidebar({ href: '/jobs', icon: , permission: 'view_jobs', - children: [{ label: 'Job Status', href: '/jobs/status', icon: null }], + children: [ + { label: 'Job Status', href: '/jobs/status', icon: null }, + { label: 'Job Traveler', href: '/jobs/traveler', icon: null }, + ], }, ]; diff --git a/src/lib/pdf-templates/job-traveler.ts b/src/lib/pdf-templates/job-traveler.ts new file mode 100644 index 0000000..1058ce5 --- /dev/null +++ b/src/lib/pdf-templates/job-traveler.ts @@ -0,0 +1,274 @@ +import type { JobTravelerData } from '@/types/jobs'; + +const VORTEQ_LOGO_SVG = ` + + + + +`; + +function fmtDate(iso: string | null): string { + if (!iso) return ''; + const d = new Date(iso); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const yyyy = d.getFullYear(); + return `${mm}/${dd}/${yyyy}`; +} + +function fmtQty(qty: number | null | undefined): string { + if (qty == null) return '0.00'; + return qty.toLocaleString('en-US', { minimumFractionDigits: 2 }); +} + +export function renderJobTravelerHtml(data: JobTravelerData): string { + const { header, shipping_schedule, materials, operations, paint_data } = data; + + // Build shipping schedule rows + const shippingRows = shipping_schedule.map((s, i) => ` + + ${fmtDate(s.need_by_date)} + ${s.line_status || ''} + ${s.order_num || ''} + ${s.order_line ?? ''} + ${s.order_rel_num ?? ''} + ${fmtQty(s.order_qty)} + ${s.sales_um || ''} + ${fmtQty(s.qty_from_job)} + ${s.ship_via || ''} + ${s.ship_to || ''} + + `).join(''); + + // Build materials rows + const materialsRows = materials.map((m, i) => ` + + ${m.mtl_seq ?? ''} + ${m.part_num || ''} + ${m.description || ''} + ${fmtQty(m.required_qty)} + ${m.ium || ''} + ${m.warehouse_code || ''} + ${m.related_operation ?? ''} + ${m.issue_status || ''} + + `).join(''); + + // Build operations rows + const operationsRows = operations.map((op, i) => ` + + ${op.opr_seq ?? ''} + ${op.op_code || ''} + ${op.op_desc || ''} + ${fmtQty(op.run_qty)} + ${op.crew_size || ''} + ${op.est_setup_hours?.toFixed(2) || '0.00'} + ${op.est_prod_hours?.toFixed(2) || '0.00'} + ${op.prod_standard?.toFixed(4) || '0.0000'} + ${fmtDate(op.start_date)} + ${fmtDate(op.due_date)} + ${op.status || ''} + + `).join(''); + + // Build part specifications grid + const specs = paint_data || {}; + const specFields = [ + { label: 'Part Color', key: 'ShortChar01' }, + { label: 'Paint Type', key: 'ShortChar02' }, + { label: 'Substrate', key: 'ShortChar03' }, + { label: 'Gloss Level', key: 'ShortChar04' }, + { label: 'Bend Dir', key: 'ShortChar05' }, + { label: 'Texture', key: 'ShortChar06' }, + { label: 'Finish', key: 'ShortChar07' }, + { label: 'Coverage', key: 'ShortChar08' }, + { label: 'Adhesion', key: 'ShortChar09' }, + { label: 'QC Notes', key: 'ShortChar10' }, + ]; + + const specRows = specFields + .filter(f => specs[f.key as keyof typeof specs]) + .map(f => ` + + ${f.label} + ${specs[f.key as keyof typeof specs] || ''} + + `).join(''); + + const specsSection = specRows ? ` +
+
Part Specifications
+ + ${specRows} +
+
+ ` : ''; + + return ` + + + + + + +
+
+
${VORTEQ_LOGO_SVG}
+
+
1234 Industrial Way
+
Manufacturing City, ST 12345
+
Phone: (555) 123-4567
+
+
+
+
JOB TRAVELER
+
+ Job #: ${header.job_num}    Plant: ${header.plant || ''} +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Job Information
Customer${header.customer_name || ''}
PO Number${header.po_num || ''}
Part Number${header.part_num || ''}
Description${header.part_description || ''}
Revision${header.revision || ''}
Customer Part${header.cust_part || ''}
+
+ +
+ + + + + + + + + + + + + + + + + + + + +
Schedule
Total Qty${fmtQty(header.prod_qty)}
Start Date${fmtDate(header.start_date)}
Due Date${fmtDate(header.due_date)}
Req Date${fmtDate(header.req_due_date)}
+
+
+ + ${header.comment_text ? ` +
+
Job Comments
+
${header.comment_text}
+
+ ` : ''} + +
+ + + + + + + + + + + + + + + + + ${shippingRows || ''} + +
DateStatusSOLineRelOrder QtyUMQty From JobShip ViaShip To
No shipping schedule
+
+ +
+
Raw Material Components
+ + + + + + + + + + + + + + + ${materialsRows || ''} + +
SeqPart NumberDescriptionReq QtyUMWhseRel OPStatus
No materials
+
+ +
+
Operations
+ + + + + + + + + + + + + + + + + + ${operationsRows || ''} + +
SeqCodeDescriptionQtyResSetup HrsProd HrsStandardStartDueStatus
No operations
+
+ + ${specsSection} + +
+ Generated ${new Date().toLocaleString()} +
+ +`; +} diff --git a/src/services/coil-activity.ts b/src/services/coil-activity.ts index 66b6da1..b41527f 100644 --- a/src/services/coil-activity.ts +++ b/src/services/coil-activity.ts @@ -6,7 +6,7 @@ */ import { execQuery } from '@/lib/epicor'; -import type { CoilUsageRow, CoilReceiptRow } from '@/types/coil-activity'; +import type { CoilUsageRow, CoilReceiptRow, CoilByCoilRow } from '@/types/coil-activity'; // ============================================================================= // Coil Usage @@ -256,3 +256,306 @@ function mapCoilReceiptRow(raw: Record): CoilReceiptRow { mill_order_num: raw.MillOrderNum ? String(raw.MillOrderNum) : null, }; } + +// ============================================================================= +// Coil-by-Coil +// ============================================================================= + +/** + * Get coil-by-coil tracking data for a specific job and customer. + * Returns detailed production and usage data for each coil lot. + * + * HDC customer exception: maps 'HDC' → 'HDM' for the query. + */ +export async function getCoilByCoil(jobNum: string, custId: string): Promise { + // HDC → HDM mapping + const queryCustId = custId === 'HDC' ? 'HDM' : custId; + + const sql = ` +SELECT + PartLot.MfgLot, + [Company].[Name] [Company_Name], + Erp.Plant.Name AS [Calculated_PlantName], + [JobHead].[JobNum] [JobHead_JobNum], + [Customer].[Name] [Customer_Name], + [ShipTo].[Name] [ShipTo_Name], + (CASE WHEN [OrderHed].[PONum] <> ' ' THEN [OrderHed].[PONum] + ELSE [SubQuery2].[OrderHed1_PONum] + END) [Calculated_CustPo], + (CASE WHEN [OrderDtl].[XPartNum] > ' ' THEN [OrderDtl].[XPartNum] + ELSE [CustXPrt].[XPartNum] + END) [Calculated_CustPart], + [JobHead].[PartNum] [JobHead_PartNum], + [JobHead].[PartDescription] [JobHead_PartDescription], + (CASE WHEN [JobHead].[PartNum] = [PartPlant].[PartNum] + THEN (CASE WHEN [PartPlant_UD].[RcvType_c] = 'BOS' THEN 'BOS' + ELSE (CASE WHEN [PartPlant_UD].[RcvType_c] = 'BOP' THEN 'BOP' ELSE ' ' END) + END) + END) [Calculated_ProdType], + [JobProd].[OrderNum] [JobProd_OrderNum], + [JobProd].[OrderLine] [JobProd_OrderLine], + [JobHead].[JobCompletionDate] [JobHead_JobCompletionDate], + (CASE WHEN [PartTran].[TranType] = 'STK-MTL' AND [JobHead].[JobNum] = [PartTran].[JobNum] + THEN [PartTran].[PartNum] ELSE ' ' + END) [Calculated_PartNumUsed], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Used' THEN [PartTran].[PartDescription] ELSE ' ' + END) [Calculated_PartDescUsed], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Used' THEN [PartTran].[LotNum] ELSE ' ' + END) [Calculated_LotUsed], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Used' AND [PartTran].[TranQty] > 0 THEN [PartTran].[TranQty] + ELSE (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Used' AND [PartLot_UD].[MasterLotNum_c] = [PartTran].[LotNum] + THEN [PartTran].[TranQty] ELSE '0' + END) + END) [Calculated_StartWt], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Used' AND [PartTran].[TranQty] < 0 + AND (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Used' + AND [PartTran].[WareHouseCode] = '12' OR [PartTran].[WareHouseCode] = '22' + OR [PartTran].[WareHouseCode] = '32' OR [PartTran].[WareHouseCode] = '42' + OR [PartTran].[WareHouseCode] = '52' OR [PartTran].[WareHouseCode] = '62' + THEN ABS([PartTran].[TranQty]) ELSE '0' + END) = 0 THEN ABS([PartTran].[TranQty]) + ELSE '0' + END) [Calculated_RtsWtGood], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Used' + AND [PartTran].[WareHouseCode] = '12' OR [PartTran].[WareHouseCode] = '22' + OR [PartTran].[WareHouseCode] = '32' OR [PartTran].[WareHouseCode] = '42' + OR [PartTran].[WareHouseCode] = '52' OR [PartTran].[WareHouseCode] = '62' + THEN ABS([PartTran].[TranQty]) ELSE '0' + END) [Calculated_RtsReject], + [PartTran].[TranReference] [PartTran_TranReference], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Used' AND [PartTran].[TranQty] > 0 THEN [PartTran].[TranQty] + ELSE (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Used' AND [PartLot_UD].[MasterLotNum_c] = [PartTran].[LotNum] + THEN [PartTran].[TranQty] ELSE '0' + END) + END) + - (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Used' AND [PartTran].[TranQty] < 0 + AND (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Used' + AND [PartTran].[WareHouseCode] = '12' OR [PartTran].[WareHouseCode] = '22' + OR [PartTran].[WareHouseCode] = '32' OR [PartTran].[WareHouseCode] = '42' + OR [PartTran].[WareHouseCode] = '52' OR [PartTran].[WareHouseCode] = '62' + THEN ABS([PartTran].[TranQty]) ELSE '0' + END) = 0 THEN ABS([PartTran].[TranQty]) + ELSE '0' + END) + - (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Used' + AND [PartTran].[WareHouseCode] = '12' OR [PartTran].[WareHouseCode] = '22' + OR [PartTran].[WareHouseCode] = '32' OR [PartTran].[WareHouseCode] = '42' + OR [PartTran].[WareHouseCode] = '52' OR [PartTran].[WareHouseCode] = '62' + THEN ABS([PartTran].[TranQty]) ELSE '0' + END) [Calculated_TotalWtUsed], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Produced' + AND [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] + AND [PartTran].[LotNum] = [PartLot].[LotNum] + THEN (CASE WHEN ([UD01].[CheckBox01] = 0 AND [UD01].[CheckBox02] = 0) + AND ([PartPlant_UD].[RcvType_c] = 'BOP') + THEN ([PartTran].[LotNum] + '#') + ELSE [PartTran].[LotNum] + END) + END) [Calculated_ProdLot], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Produced' + THEN (CASE WHEN [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] + THEN (CASE WHEN [UD01].[CheckBox01] = 0 AND [UD01].[CheckBox02] = 0 + THEN [PartLot_UD].[CoilWeight_c] ELSE 0 + END) + END) + END) [Calculated_ProdGoodWt], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Produced' + THEN (CASE WHEN [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] + THEN (CASE WHEN [UD01].[CheckBox01] = 1 THEN [PartLot_UD].[CoilWeight_c] ELSE '0' END) + END) + END) [Calculated_HoldWt], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Produced' + THEN (CASE WHEN [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] + THEN (CASE WHEN [UD01].[CheckBox02] = 1 THEN [PartLot_UD].[CoilWeight_c] ELSE '0' END) + END) + END) [Calculated_RejWt], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Produced' + THEN (CASE WHEN [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] + THEN [PartLot_UD].[TheoreticalWeight_c] ELSE NULL + END) + END) [Calculated_TheoWt], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Produced' + AND [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] + THEN [PartLot_UD].[Length_c] ELSE NULL + END) [Calculated_LinealFt], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Produced' + AND [JobHead].[JobNum] = [PartTran].[JobNum] + AND [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] + THEN [PartLot_UD].[SkidNum_c] ELSE ' ' + END) [Calculated_SkidNum], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) = 'Produced' + AND [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] + THEN [PartTran].[TranDate] ELSE NULL + END) [Calculated_ProdDate], + (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' + ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) + END) [Calculated_ProdOrUsed], + [PartTran].[LotNum] [PartTran_LotNum], + [PartTran].[TranQty] [PartTran_TranQty] +FROM [Erp].[JobHead] + LEFT OUTER JOIN [Erp].[JobProd] ON [Erp].[JobHead].[Company] = [Erp].[JobProd].[Company] AND [Erp].[JobHead].[JobNum] = [Erp].[JobProd].[JobNum] + LEFT OUTER JOIN [Erp].[OrderHed] ON [Erp].[OrderHed].[Company] = [Erp].[JobProd].[Company] AND [Erp].[OrderHed].[OrderNum] = [Erp].[JobProd].[OrderNum] + LEFT OUTER JOIN [Erp].[Part] ON [Erp].[JobHead].[Company] = [Erp].[Part].[Company] AND [Erp].[JobHead].[PartNum] = [Erp].[Part].[PartNum] + LEFT OUTER JOIN [Erp].[Customer] ON [Erp].[Part].[Company] = [Erp].[Customer].[Company] + LEFT OUTER JOIN [Erp].[CustXPrt] ON [Erp].[JobHead].[Company] = [Erp].[CustXPrt].[Company] AND [Erp].[JobHead].[PartNum] = [Erp].[CustXPrt].[PartNum] + LEFT OUTER JOIN [Erp].[ShipTo] ON [Erp].[OrderHed].[Company] = [Erp].[ShipTo].[Company] AND [Erp].[OrderHed].[CustNum] = [Erp].[ShipTo].[CustNum] AND [Erp].[OrderHed].[ShipToNum] = [Erp].[ShipTo].[ShipToNum] + LEFT OUTER JOIN [Erp].[Company] ON [Erp].[Company].[Company] = [Erp].[JobHead].[Company] + LEFT OUTER JOIN [Erp].[OrderDtl] ON [Erp].[JobProd].[Company] = [Erp].[OrderDtl].[Company] AND [Erp].[JobProd].[OrderNum] = [Erp].[OrderDtl].[OrderNum] AND [Erp].[JobProd].[OrderLine] = [Erp].[OrderDtl].[OrderLine] + LEFT OUTER JOIN ( + SELECT [JobHead1].[Plant] [JobHead1_Plant], [JobHead1].[JobNum] [JobHead1_JobNum], [JobProd1].[JobNum] [JobProd1_JobNum], + [JobProd1].[OrderLine] [JobProd1_OrderLine], [JobProd1].[OrderNum] [JobProd1_OrderNum], [JobProd1].[OrderRelNum] [JobProd1_OrderRelNum], + [JobProd1].[Plant] [JobProd1_Plant], [OrderHed1].[PONum] [OrderHed1_PONum], + [JobHead1].[PartNum] [JobHead1_PartNum], [JobHead1].[PartDescription] [JobHead1_PartDescription] + FROM [Erp].[JobHead] [JobHead1] + INNER JOIN [Erp].[JobProd] [JobProd1] ON [JobHead1].[Company] = [JobProd1].[Company] AND [JobHead1].[JobNum] = [JobProd1].[JobNum] + INNER JOIN [Erp].[OrderHed] [OrderHed1] ON [JobProd1].[Company] = [OrderHed1].[Company] AND [JobProd1].[OrderNum] = [OrderHed1].[OrderNum] + ) [SubQuery2] ON [Erp].[JobProd].[TargetJobNum] = [SubQuery2].[JobHead1_JobNum] + LEFT OUTER JOIN ([Erp].[PartPlant] INNER JOIN [Erp].[PartPlant_UD] ON [Erp].[PartPlant].[SysRowID] = [Erp].[PartPlant_UD].[ForeignSysRowID]) + ON [Erp].[PartPlant].[Company] = [Erp].[JobHead].[Company] AND [Erp].[PartPlant].[Plant] = [Erp].[JobHead].[Plant] AND [Erp].[PartPlant].[PartNum] = [Erp].[JobHead].[PartNum] + LEFT OUTER JOIN [Erp].[PartTran] ON ([Erp].[JobHead].[Company] = [Erp].[PartTran].[Company] AND [Erp].[JobHead].[JobNum] = [Erp].[PartTran].[JobNum] AND [Erp].[JobHead].[Plant] = [Erp].[PartTran].[Plant] AND [Erp].[JobHead].[IUM] = [Erp].[PartTran].[UM]) + AND ([PartTran].[TranType] = N'STK-MTL' OR [PartTran].[TranType] = N'MFG-STK' AND NOT ([PartTran].[UM] = N'GA')) + LEFT OUTER JOIN ([Erp].[PartLot] INNER JOIN [Erp].[PartLot_UD] ON [Erp].[PartLot].[SysRowID] = [Erp].[PartLot_UD].[ForeignSysRowID]) + ON [Erp].[PartTran].[Company] = [Erp].[PartLot].[Company] AND [Erp].[PartTran].[PartNum] = [Erp].[PartLot].[PartNum] AND [Erp].[PartTran].[LotNum] = [Erp].[PartLot].[LotNum] + LEFT OUTER JOIN [Ice].[UD01] ON [Erp].[PartLot].[Company] = [Ice].[UD01].[Company] AND [Erp].[PartLot].[PartNum] = [Ice].[UD01].[ShortChar02] AND [Erp].[PartLot_UD].[SkidNum_c] = [Ice].[UD01].[Key1] + JOIN Erp.Plant ON Erp.Plant.Plant = [JobHead].[Plant] +WHERE [JobHead].[IUM] = N'LB' + AND [JobHead].[JobNum] = @JobNum + AND ([Customer].[CustID] = @Customer OR ('HDM' = @Customer2 AND [Customer].[CustID] = 'HDC' AND [JobHead].[JobNum] = @JobNum2)) + AND ([PartPlant].[Plant] IS NULL + OR EXISTS (SELECT * FROM (SELECT 1 [c1] FROM [Ice].[SysUserComp] [SysUserComp] + WHERE [SysUserComp].[Company] = [PartPlant].[Company] + AND Ice.lookup(PartPlant.Plant, SysUserComp.PlantList, '~') > 0) _PlantPartPlantInner)) +GROUP BY [Company].[Name], Erp.Plant.Name, [JobHead].[JobNum], [Customer].[Name], [ShipTo].[Name], + (CASE WHEN [OrderHed].[PONum] <> ' ' THEN [OrderHed].[PONum] ELSE [SubQuery2].[OrderHed1_PONum] END), + (CASE WHEN [OrderDtl].[XPartNum] > ' ' THEN [OrderDtl].[XPartNum] ELSE [CustXPrt].[XPartNum] END), + [JobHead].[PartNum], [JobHead].[PartDescription], + (CASE WHEN [JobHead].[PartNum] = [PartPlant].[PartNum] THEN (CASE WHEN [PartPlant_UD].[RcvType_c] = 'BOS' THEN 'BOS' ELSE (CASE WHEN [PartPlant_UD].[RcvType_c] = 'BOP' THEN 'BOP' ELSE ' ' END) END) END), + [JobProd].[OrderNum], [JobProd].[OrderLine], [JobHead].[JobCompletionDate], + (CASE WHEN [PartTran].[TranType] = 'STK-MTL' AND [JobHead].[JobNum] = [PartTran].[JobNum] THEN [PartTran].[PartNum] ELSE ' ' END), + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Used' THEN [PartTran].[PartDescription] ELSE ' ' END), + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Used' THEN [PartTran].[LotNum] ELSE ' ' END), + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Used' AND [PartTran].[TranQty] > 0 THEN [PartTran].[TranQty] ELSE (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Used' AND [PartLot_UD].[MasterLotNum_c] = [PartTran].[LotNum] THEN [PartTran].[TranQty] ELSE '0' END) END), + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Used' AND [PartTran].[TranQty] < 0 AND (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Used' AND [PartTran].[WareHouseCode] = '12' OR [PartTran].[WareHouseCode] = '22' OR [PartTran].[WareHouseCode] = '32' OR [PartTran].[WareHouseCode] = '42' OR [PartTran].[WareHouseCode] = '52' OR [PartTran].[WareHouseCode] = '62' THEN ABS([PartTran].[TranQty]) ELSE '0' END) = 0 THEN ABS([PartTran].[TranQty]) ELSE '0' END), + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Used' AND [PartTran].[WareHouseCode] = '12' OR [PartTran].[WareHouseCode] = '22' OR [PartTran].[WareHouseCode] = '32' OR [PartTran].[WareHouseCode] = '42' OR [PartTran].[WareHouseCode] = '52' OR [PartTran].[WareHouseCode] = '62' THEN ABS([PartTran].[TranQty]) ELSE '0' END), + [PartTran].[TranReference], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Used' AND [PartTran].[TranQty] > 0 THEN [PartTran].[TranQty] ELSE (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Used' AND [PartLot_UD].[MasterLotNum_c] = [PartTran].[LotNum] THEN [PartTran].[TranQty] ELSE '0' END) END) + - (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Used' AND [PartTran].[TranQty] < 0 AND (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Used' AND [PartTran].[WareHouseCode] = '12' OR [PartTran].[WareHouseCode] = '22' OR [PartTran].[WareHouseCode] = '32' OR [PartTran].[WareHouseCode] = '42' OR [PartTran].[WareHouseCode] = '52' OR [PartTran].[WareHouseCode] = '62' THEN ABS([PartTran].[TranQty]) ELSE '0' END) = 0 THEN ABS([PartTran].[TranQty]) ELSE '0' END) + - (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Used' AND [PartTran].[WareHouseCode] = '12' OR [PartTran].[WareHouseCode] = '22' OR [PartTran].[WareHouseCode] = '32' OR [PartTran].[WareHouseCode] = '42' OR [PartTran].[WareHouseCode] = '52' OR [PartTran].[WareHouseCode] = '62' THEN ABS([PartTran].[TranQty]) ELSE '0' END), + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Produced' AND [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] AND [PartTran].[LotNum] = [PartLot].[LotNum] THEN (CASE WHEN ([UD01].[CheckBox01] = 0 AND [UD01].[CheckBox02] = 0) AND ([PartPlant_UD].[RcvType_c] = 'BOP') THEN ([PartTran].[LotNum] + '#') ELSE [PartTran].[LotNum] END) END), + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Produced' THEN (CASE WHEN [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] THEN (CASE WHEN [UD01].[CheckBox01] = 0 AND [UD01].[CheckBox02] = 0 THEN [PartLot_UD].[CoilWeight_c] ELSE 0 END) END) END), + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Produced' THEN (CASE WHEN [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] THEN (CASE WHEN [UD01].[CheckBox01] = 1 THEN [PartLot_UD].[CoilWeight_c] ELSE '0' END) END) END), + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Produced' THEN (CASE WHEN [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] THEN (CASE WHEN [UD01].[CheckBox02] = 1 THEN [PartLot_UD].[CoilWeight_c] ELSE '0' END) END) END), + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Produced' THEN (CASE WHEN [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] THEN [PartLot_UD].[TheoreticalWeight_c] ELSE NULL END) END), + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Produced' AND [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] THEN [PartLot_UD].[Length_c] ELSE NULL END), + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Produced' AND [JobHead].[JobNum] = [PartTran].[JobNum] AND [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] THEN [PartLot_UD].[SkidNum_c] ELSE ' ' END), + [UD01].[ShortChar04], [UD01].[Character02], + (CASE WHEN (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END) = 'Produced' AND [PartTran].[TranQty] = [PartLot_UD].[CoilWeight_c] THEN [PartTran].[TranDate] ELSE NULL END), + (CASE WHEN [PartTran].[TranType] = 'MFG-STK' THEN 'Produced' ELSE (CASE WHEN [PartTran].[TranType] = 'STK-MTL' THEN 'Used' ELSE ' ' END) END), + [OrderDtl].[POLine], [OrderDtl].[Reference], [Customer].[CustID], + [PartTran].[TranType], [JobHead].[Plant], [SubQuery2].[JobHead1_Plant], [PartTran].[Plant], + [SubQuery2].[JobHead1_JobNum], [PartTran].[JobNum], [UD01].[ShortChar01], + [SubQuery2].[JobHead1_PartNum], [PartPlant].[PartNum], [PartTran].[PartNum], + [SubQuery2].[JobHead1_PartDescription], [PartTran].[PartDescription], + [PartTran].[LotNum], [PartLot].[LotNum], [PartTran].[TranQty], + [PartLot_UD].[Length_c], [PartLot_UD].[TheoreticalWeight_c], [PartLot_UD].[SkidNum_c], + [PartLot_UD].[MasterLotNum_c], [PartTran].[TranDate], [PartTran].[WareHouseCode], + [SubQuery2].[OrderHed1_PONum], [SubQuery2].[JobProd1_OrderNum], + [SubQuery2].[JobProd1_OrderLine], [SubQuery2].[JobProd1_OrderRelNum], + (CASE WHEN [JobProd].[TargetJobNum] > 0 THEN [JobProd].[TargetJobNum] ELSE [JobHead].[JobNum] END), + [JobProd].[TargetJobNum], [PartTran].[UM], [PartPlant_UD].[RcvType_c], PartLot.MfgLot +ORDER BY [JobHead_JobNum], [PartTran_LotNum] ASC, [Calculated_StartWt] DESC + `; + + const rawRows = await execQuery[]>(sql, { + JobNum: jobNum, + JobNum2: jobNum, + Customer: queryCustId, + Customer2: queryCustId, + }); + + return rawRows.map((r) => mapCoilByCoilRow(r)); +} + +/** + * Map raw SQL result to CoilByCoilRow + */ +function mapCoilByCoilRow(raw: Record): CoilByCoilRow { + const str = (val: unknown) => (val != null && String(val).trim() !== '') ? String(val).trim() : null; + const num = (val: unknown) => val != null ? Number(val) : 0; + const numOrNull = (val: unknown) => val != null ? Number(val) : null; + const date = (val: unknown) => val ? new Date(val as string).toISOString() : null; + + return { + mfg_lot: str(raw.MfgLot), + company_name: String(raw.Company_Name ?? ''), + plant_name: String(raw.Calculated_PlantName ?? ''), + job_num: String(raw.JobHead_JobNum ?? ''), + customer_name: String(raw.Customer_Name ?? ''), + ship_to_name: str(raw.ShipTo_Name), + cust_po: str(raw.Calculated_CustPo), + cust_part: str(raw.Calculated_CustPart), + part_num: String(raw.JobHead_PartNum ?? ''), + part_description: String(raw.JobHead_PartDescription ?? ''), + prod_type: str(raw.Calculated_ProdType), + order_num: raw.JobProd_OrderNum != null ? Number(raw.JobProd_OrderNum) : null, + order_line: raw.JobProd_OrderLine != null ? Number(raw.JobProd_OrderLine) : null, + completion_date: date(raw.JobHead_JobCompletionDate), + tran_type: String(raw.Calculated_ProdOrUsed ?? '').trim(), + part_num_used: str(raw.Calculated_PartNumUsed), + part_desc_used: str(raw.Calculated_PartDescUsed), + lot_used: str(raw.Calculated_LotUsed), + start_wt: num(raw.Calculated_StartWt), + rts_wt_good: num(raw.Calculated_RtsWtGood), + rts_reject: num(raw.Calculated_RtsReject), + total_wt_used: num(raw.Calculated_TotalWtUsed), + tran_reference: str(raw.PartTran_TranReference), + prod_lot: str(raw.Calculated_ProdLot), + prod_wt_good: numOrNull(raw.Calculated_ProdGoodWt), + prod_wt_hold: numOrNull(raw.Calculated_HoldWt), + prod_wt_reject: numOrNull(raw.Calculated_RejWt), + prod_theoretical_wt: numOrNull(raw.Calculated_TheoWt), + prod_length: numOrNull(raw.Calculated_LinealFt), + prod_skid: str(raw.Calculated_SkidNum), + prod_date: date(raw.Calculated_ProdDate), + }; +} diff --git a/src/services/jobs.ts b/src/services/jobs.ts new file mode 100644 index 0000000..7d53275 --- /dev/null +++ b/src/services/jobs.ts @@ -0,0 +1,319 @@ +import { execQuery, execStoredProc, getPortalDbName } from '@/lib/epicor'; +import type { + JobStatusRow, + JobTravelerData, + JobTravelerHeader, + JobTravelerShipLine, + JobTravelerMaterial, + JobTravelerOperation, + JobTravelerPaintData, +} from '@/types/jobs'; + +/** + * Get job status for a customer across all plants + * Uses OpenOrdersQueryV3 stored procedure + */ +export async function getJobStatusByPlant( + custId: string +): Promise { + // HDC → HDM customer mapping + const mappedCustId = custId === 'HDC' ? 'HDM' : custId; + const dbName = getPortalDbName(); + + type StoredProcResult = { + Plant_Name: string; + Customer_Name: string; + JobHead_JobNum: string; + JobHead_JobReleased: boolean; + JobHead_DueDate: string | null; + CustomerPartNumber: string | null; + JobHead_PartNum: string; + JobHead_PartDescription: string; + JobHead_ProdQty: number; + JobOper_QtyCompleted: number; + JobProd_OrderNum: number | null; + JobProd_OrderLine: number | null; + JobProd_OrderRelNum: number | null; + OrderHed_PONum: string | null; + }; + + const results = await execStoredProc( + 'OpenOrdersQueryV3', + { + CUSTID: mappedCustId, + DBNAME: dbName, + } + ); + + return results.map((row) => ({ + plant_name: row.Plant_Name, + customer_name: row.Customer_Name, + job_num: row.JobHead_JobNum, + job_released: row.JobHead_JobReleased, + due_date: row.JobHead_DueDate, + customer_part_num: row.CustomerPartNumber, + part_num: row.JobHead_PartNum, + part_description: row.JobHead_PartDescription, + prod_qty: row.JobHead_ProdQty, + qty_completed: row.JobOper_QtyCompleted, + order_num: row.JobProd_OrderNum, + order_line: row.JobProd_OrderLine, + order_rel_num: row.JobProd_OrderRelNum, + po_num: row.OrderHed_PONum, + })); +} + +/** + * Get job traveler details for a specific job + * Returns structured data with header, shipping schedule, materials, operations, and paint data + */ +export async function getJobTraveler( + jobNum: string +): Promise { + const sql = ` +select +[JobHead].[Plant] as [Plant], +[JobHead].[JobNum] as [JobNum], +[Customer].[Name] as [CustomerName], +[OrderHed].[PONum] as [PONum], +[JobHead].[PartNum] as [PartNum], +[JobHead].[PartDescription] as [PartDescription], +[JobHead].[RevisionNum] as [RevisionNum], +[JobHead].[XRefPartNum] as [CustPart], +[JobHead].[ProdQty] as [JobHead_ProdQty], +[JobHead].[IUM] as [JobHead_IUM], +[JobHead].[StartDate] as [StartDate], +[JobHead].[DueDate] as [DueDate], +[JobHead].[ReqDueDate] as [ReqDueDate], +[OrderDtl].[NeedByDate] as [NeedByDate], +OrderDtl.LineStatus, +[JobProd].[OrderNum] as [SO], +[JobProd].[OrderLine] as [OrderLine], +[JobProd].[OrderRelNum] as [OrderRelNum], +[OrderDtl].[SellingQuantity] as [OrderQty], +[OrderDtl].[SalesUM] as [SalesUM], +[JobProd].[ProdQty] as [QtyFromJob], +('0.00') as [Calculated_QtyFromStk], +[ShipVia].[Description] as [ShipVia], +[ShipTo].[Name] as [ShipToName], +[JobHead].[CommentText] as [JobHead_CommentText], +[JobMtl].[MtlSeq] as MtlSeq, +[JobMtl].[PartNum] as [JobMtlPart], +[JobMtl].[Description] as [JobMtlDescription], +[JobMtl].[RequiredQty] as [JobMtlRequiredQty], +[JobMtl].[IUM] as [JobMtl_IUM], +[JobMtl].[WarehouseCode] as [JobMtlWhse], +[JobMtl].[RelatedOperation] as [JobMtlRelOp], +((case when JobMtl.IssuedComplete = 1 then 'Issued Complete' else +(case when JobMtl.IssuedComplete = 0 and JobMtl.IssuedQty = '0' then 'Open' else +(case when JobMtl.IssuedQty > 0 then 'Issued Partial' end)end)end)) as [Calculated_MtlIssueStatus], +[JobOper].[OprSeq] as [JobOperSeq], +[JobOper].[OpCode] as [JobOperCode], +[JobOper].[OpDesc] as [JobOperDescription], +[JobOper].[RunQty] as [JobOperQty], +[JobOper].[ProdCrewSize] as [JobOperRes], +[JobOper].[EstSetHoursPerMch] as [JobOperSetUpHours], +[JobOper].[EstProdHours] as [JobOperProdHours], +[JobOper].[ProdStandard] as [JobOperStandard], +[JobOper].[StartDate] as [JobOperStart], +[JobOper].[DueDate] as [JobOperDue], +((case when JobOper.JobComplete = 'true' then 'CMPL' else 'OPEN' end)) as [Calculated_JobComplete], +(SELECT TOP 1 Erp.OrderRel.WarehouseCode FROM Erp.OrderRel WHERE Erp.OrderRel.OrderNum = OrderHed.OrderNum) AS Warehouse, +Part_UD.A6A_c, Part_UD.A6B_c, Part_UD.BackcoatFilmThick_c, Part_UD.BackcoatFilmTol_c, +Part_UD.BackcoatPaintCode_c, Part_UD.BackcoatPaintDesc_c, Part_UD.BandCoil_c, Part_UD.BandSkid_c, +Part_UD.BaseMetalTV_c, Part_UD.CardboardBottom_c, Part_UD.CardboardTop_c, Part_UD.CoilID_c, +Part_UD.CoilOD_c, Part_UD.CoilsperSkid_c, Part_UD.CoilWeight_c, Part_UD.Cores_c, Part_UD.Cut_c, +Part_UD.DateCodeReqd_c, Part_UD.DFT_c, Part_UD.Draw_c, Part_UD.Drop_c, Part_UD.EndUsage_c, +Part_UD.Hardness_c, Part_UD.LineSpeed_c, Part_UD.MaxSkidWeight_c, Part_UD.MetalThickness_c, +Part_UD.MetalWidth_c, Part_UD.MetalWidthTolerance_c, Part_UD.NA_c, Part_UD.NCCA_c, +Part_UD.PcntSolidbyVolume_c, Part_UD.PcntSolidbyWeight_c, Part_UD.PcntSolventbyVolume_c, +Part_UD.PcntSolventbyWeight_c, Part_UD.PcntWaterbyVolume_c, Part_UD.PcntWaterbyWeight_c, +Part_UD.PlasticWrap_c, Part_UD.PrimBackFilmDesc_c, Part_UD.PrimBackFilmTol_c, +Part_UD.PrimBackPaintCode_c, Part_UD.PrimBackPaintDesc_c, Part_UD.PrimTopFilmThick_c, +Part_UD.PrimTopFilmTol_c, Part_UD.PrimTopPaintCode_c, Part_UD.PrimTopPaIntDesc_c, +Part_UD.RawDescription_c, Part_UD.RawPartNum_c, Part_UD.SkidType_c, +Part_UD.Slit1Cuts_c, Part_UD.Slit1Tol_c, Part_UD.Slit1Width_c, +Part_UD.Slit2Cuts_c, Part_UD.Slit2Tol_c, Part_UD.Slit2Width_c, +Part_UD.Slit3Cuts_c, Part_UD.Slit3Tol_c, Part_UD.Slit3Width_c, +Part_UD.Slit4Cuts_c, Part_UD.Slit4Tol_c, Part_UD.Slit4Width_c, +Part_UD.Spacers_c, Part_UD.SqFtperLB_c, Part_UD.TBend_c, Part_UD.TechComments_c, +Part_UD.ThicknessTolerance_c, Part_UD.TopcoatFilmThick_c, Part_UD.TopcoatFilmTol_c, +Part_UD.TopcoatPaintCode_c, Part_UD.TopcoatPaintDesc_c, Part_UD.VOCGal_c, +Part_UD.WeightperGallon_c, Part_UD.CoilWeightC_c, Part_UD.CoilODC_c +from Erp.JobHead as JobHead +left join Erp.JobProd as JobProd on JobHead.Company = JobProd.Company And JobHead.JobNum = JobProd.JobNum +left join Erp.OrderDtl as OrderDtl on JobProd.Company = OrderDtl.Company And JobProd.OrderNum = OrderDtl.OrderNum And JobProd.OrderLine = OrderDtl.OrderLine +left join Erp.OrderHed as OrderHed on OrderDtl.Company = OrderHed.Company And OrderDtl.OrderNum = OrderHed.OrderNum +left join Erp.ShipVia as ShipVia on OrderHed.Company = ShipVia.Company And OrderHed.ShipViaCode = ShipVia.ShipViaCode +left join Erp.Customer as Customer on OrderHed.Company = Customer.Company And OrderHed.BTCustNum = Customer.CustNum +left outer join Erp.JobMtl as JobMtl on JobHead.Company = JobMtl.Company And JobHead.JobNum = JobMtl.JobNum +left join Erp.JobOper as JobOper on JobHead.Company = JobOper.Company And JobHead.JobNum = JobOper.JobNum +left join Erp.ShipTo as ShipTo on OrderHed.CustNum = ShipTo.CustNum And OrderHed.ShipToNum = ShipTo.ShipToNum +JOIN Erp.Part ON Part.PartNum = JobHead.PartNum +JOIN Erp.Part_UD ON Part_UD.ForeignSysRowID = Part.SysRowID +where (JobHead.JobNum = @JobNum or JobHead.JobNum = @JobNum1) + `.trim(); + + type QueryResult = { + Plant: string; + JobNum: string; + CustomerName: string; + PONum: string | null; + PartNum: string; + PartDescription: string; + RevisionNum: string; + CustPart: string | null; + JobHead_ProdQty: number; + JobHead_IUM: string; + StartDate: string | null; + DueDate: string | null; + ReqDueDate: string | null; + NeedByDate: string | null; + LineStatus: string | null; + SO: number | null; + OrderLine: number | null; + OrderRelNum: number | null; + OrderQty: number | null; + SalesUM: string | null; + QtyFromJob: number | null; + Calculated_QtyFromStk: string; + ShipVia: string | null; + ShipToName: string | null; + JobHead_CommentText: string | null; + MtlSeq: number | null; + JobMtlPart: string | null; + JobMtlDescription: string | null; + JobMtlRequiredQty: number | null; + JobMtl_IUM: string | null; + JobMtlWhse: string | null; + JobMtlRelOp: number | null; + Calculated_MtlIssueStatus: string | null; + JobOperSeq: number | null; + JobOperCode: string | null; + JobOperDescription: string | null; + JobOperQty: number | null; + JobOperRes: number | null; + JobOperSetUpHours: number | null; + JobOperProdHours: number | null; + JobOperStandard: number | null; + JobOperStart: string | null; + JobOperDue: string | null; + Calculated_JobComplete: string | null; + Warehouse: string | null; + [key: string]: unknown; // For Part_UD fields ending in _c + }; + + const results = await execQuery(sql, { + JobNum: jobNum, + JobNum1: jobNum, + }); + + const firstRow = results[0]; + if (!firstRow) { + return null; + } + + // Extract header from first row + const header: JobTravelerHeader = { + plant: firstRow.Plant, + job_num: firstRow.JobNum, + customer_name: firstRow.CustomerName, + po_num: firstRow.PONum, + part_num: firstRow.PartNum, + part_description: firstRow.PartDescription, + revision: firstRow.RevisionNum, + cust_part: firstRow.CustPart, + prod_qty: firstRow.JobHead_ProdQty, + ium: firstRow.JobHead_IUM, + start_date: firstRow.StartDate ? new Date(firstRow.StartDate).toISOString() : null, + due_date: firstRow.DueDate ? new Date(firstRow.DueDate).toISOString() : null, + req_due_date: firstRow.ReqDueDate ? new Date(firstRow.ReqDueDate).toISOString() : null, + comment_text: firstRow.JobHead_CommentText, + warehouse: firstRow.Warehouse, + }; + + // Deduplicate shipping schedule by OrderNum+OrderLine+OrderRelNum + const shippingSet = new Set(); + const shipping_schedule: JobTravelerShipLine[] = []; + + for (const row of results) { + if (row.SO != null && row.OrderLine != null && row.OrderRelNum != null) { + const key = `${row.SO}-${row.OrderLine}-${row.OrderRelNum}`; + if (!shippingSet.has(key)) { + shippingSet.add(key); + shipping_schedule.push({ + need_by_date: row.NeedByDate ? new Date(row.NeedByDate).toISOString() : null, + line_status: row.LineStatus, + order_num: row.SO, + order_line: row.OrderLine, + order_rel_num: row.OrderRelNum, + order_qty: row.OrderQty, + sales_um: row.SalesUM, + qty_from_job: row.QtyFromJob, + qty_from_stock: row.Calculated_QtyFromStk, + ship_via: row.ShipVia, + ship_to: row.ShipToName, + }); + } + } + } + + // Deduplicate materials by MtlSeq + const materialsSet = new Set(); + const materials: JobTravelerMaterial[] = []; + + for (const row of results) { + if (row.MtlSeq != null && !materialsSet.has(row.MtlSeq)) { + materialsSet.add(row.MtlSeq); + materials.push({ + mtl_seq: row.MtlSeq, + part_num: row.JobMtlPart, + description: row.JobMtlDescription, + required_qty: row.JobMtlRequiredQty, + ium: row.JobMtl_IUM, + warehouse_code: row.JobMtlWhse, + related_operation: row.JobMtlRelOp, + issue_status: row.Calculated_MtlIssueStatus, + }); + } + } + + // Deduplicate operations by JobOperSeq + const operationsSet = new Set(); + const operations: JobTravelerOperation[] = []; + + for (const row of results) { + if (row.JobOperSeq != null && !operationsSet.has(row.JobOperSeq)) { + operationsSet.add(row.JobOperSeq); + operations.push({ + opr_seq: row.JobOperSeq, + op_code: row.JobOperCode, + op_desc: row.JobOperDescription, + run_qty: row.JobOperQty, + crew_size: row.JobOperRes, + est_setup_hours: row.JobOperSetUpHours, + est_prod_hours: row.JobOperProdHours, + prod_standard: row.JobOperStandard, + start_date: row.JobOperStart ? new Date(row.JobOperStart).toISOString() : null, + due_date: row.JobOperDue ? new Date(row.JobOperDue).toISOString() : null, + status: row.Calculated_JobComplete, + }); + } + } + + // Extract paint data (all Part_UD fields ending with _c) + const paint_data: JobTravelerPaintData = {}; + for (const key of Object.keys(firstRow)) { + if (key.endsWith('_c')) { + paint_data[key] = firstRow[key]; + } + } + + return { + header, + shipping_schedule, + materials, + operations, + paint_data, + }; +} diff --git a/src/types/coil-activity.ts b/src/types/coil-activity.ts index 1526f89..8e1aa18 100644 --- a/src/types/coil-activity.ts +++ b/src/types/coil-activity.ts @@ -31,3 +31,42 @@ export type CoilReceiptRow = { supplier_name: string | null; // PartLot.PartLotDescription or view SupplierName mill_order_num: string | null; // PartLot.Batch or view MillOrderNum }; + +// C-010: Coil-by-Coil Report +export type CoilByCoilRow = { + // Header fields + mfg_lot: string | null; + company_name: string; + plant_name: string; + job_num: string; + customer_name: string; + ship_to_name: string | null; + cust_po: string | null; + cust_part: string | null; + part_num: string; + part_description: string; + prod_type: string | null; + order_num: number | null; + order_line: number | null; + completion_date: string | null; + // Transaction type + tran_type: string; // 'Used' or 'Produced' + // Used fields + part_num_used: string | null; + part_desc_used: string | null; + lot_used: string | null; + start_wt: number; + rts_wt_good: number; + rts_reject: number; + total_wt_used: number; + tran_reference: string | null; + // Produced fields + prod_lot: string | null; + prod_wt_good: number | null; + prod_wt_hold: number | null; + prod_wt_reject: number | null; + prod_theoretical_wt: number | null; + prod_length: number | null; + prod_skid: string | null; + prod_date: string | null; +}; diff --git a/src/types/jobs.ts b/src/types/jobs.ts new file mode 100644 index 0000000..754bded --- /dev/null +++ b/src/types/jobs.ts @@ -0,0 +1,93 @@ +/** + * Job Type Definitions + * + * Types for Job Status by Plant (C-011) and Job Traveler (C-012) reports. + */ + +// C-011: Job Status by Plant (from OpenOrdersQueryV3 stored proc) +export type JobStatusRow = { + plant_name: string; + customer_name: string; + job_num: string; + job_released: boolean; + due_date: string | null; + customer_part_num: string | null; + part_num: string; + part_description: string; + prod_qty: number; + qty_completed: number; + order_num: number | null; + order_line: number | null; + order_rel_num: number | null; + po_num: string | null; +}; + +// C-012: Job Traveler +export type JobTravelerHeader = { + plant: string; + job_num: string; + customer_name: string; + po_num: string | null; + part_num: string; + part_description: string; + revision: string | null; + cust_part: string | null; + prod_qty: number; + ium: string; + start_date: string | null; + due_date: string | null; + req_due_date: string | null; + comment_text: string | null; + warehouse: string | null; +}; + +export type JobTravelerShipLine = { + need_by_date: string | null; + line_status: string | null; + order_num: number | null; + order_line: number | null; + order_rel_num: number | null; + order_qty: number | null; + sales_um: string | null; + qty_from_job: number | null; + qty_from_stock: string; + ship_via: string | null; + ship_to: string | null; +}; + +export type JobTravelerMaterial = { + mtl_seq: number; + part_num: string | null; + description: string | null; + required_qty: number | null; + ium: string | null; + warehouse_code: string | null; + related_operation: number | null; + issue_status: string | null; +}; + +export type JobTravelerOperation = { + opr_seq: number; + op_code: string | null; + op_desc: string | null; + run_qty: number | null; + crew_size: number | null; + est_setup_hours: number | null; + est_prod_hours: number | null; + prod_standard: number | null; + start_date: string | null; + due_date: string | null; + status: string | null; +}; + +export type JobTravelerPaintData = { + [key: string]: unknown; +}; + +export type JobTravelerData = { + header: JobTravelerHeader; + shipping_schedule: JobTravelerShipLine[]; + materials: JobTravelerMaterial[]; + operations: JobTravelerOperation[]; + paint_data: JobTravelerPaintData | null; +};