feat: add coil-by-coil report, job status by plant, job traveler + PDFs
Some checks failed
Build and Deploy / build (push) Successful in 3m43s
Build and Deploy / deploy (push) Failing after 2s

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 <noreply@anthropic.com>
This commit is contained in:
Lorentz Hinrichsen 2026-02-17 09:42:39 -05:00
parent 53c35cc273
commit 1198aaa373
19 changed files with 2712 additions and 18 deletions

View file

@ -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

View file

@ -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 (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(10)].map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</CardContent>
</Card>
);
}
export default function CoilByCoilPage() {
const [jobNum, setJobNum] = useState('');
const [data, setData] = useState<CoilByCoilRow[] | null>(null);
const [error, setError] = useState<string | null>(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 (
<div>
<div className="mb-6">
<h1 className="mb-2 text-3xl font-bold">Coil-by-Coil Report</h1>
<p className="text-muted-foreground">
View coil-level material breakdown for a specific job
</p>
</div>
<div className="mb-6 flex items-center gap-4">
<div className="relative max-w-md flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Enter job number..."
value={jobNum}
onChange={(e) => setJobNum(e.target.value)}
onKeyDown={handleKeyDown}
className="pl-8"
/>
</div>
<Button onClick={handleSearch} disabled={!jobNum.trim() || loading}>
Search
</Button>
</div>
{error ? (
<Card>
<CardContent className="p-6 text-center text-destructive">
Failed to load coil-by-coil data: {error}
</CardContent>
</Card>
) : loading ? (
<LoadingSkeleton />
) : data !== null ? (
data.length === 0 ? (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
No coil-by-coil data found for job {jobNum}
</CardContent>
</Card>
) : (
<CoilByCoilTable data={data} />
)
) : (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
Enter a job number to view coil-by-coil details
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -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 (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-12 w-full animate-pulse rounded bg-muted" />
))}
</div>
</CardContent>
</Card>
);
}
export default function JobTravelerDetailPage() {
const params = useParams();
const jobNum = params.jobNum as string;
const [data, setData] = useState<JobTravelerData | null>(null);
const [error, setError] = useState<string | null>(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 (
<div>
<div className="mb-6">
<h1 className="mb-2 text-3xl font-bold">Job Traveler {jobNum}</h1>
<p className="text-muted-foreground">
Detailed job routing and operations information
</p>
</div>
{error ? (
<Card>
<CardContent className="p-6 text-center text-destructive">
{error.includes('not found')
? `No job traveler found for job ${jobNum}`
: `Failed to load job traveler: ${error}`}
</CardContent>
</Card>
) : loading || data === null ? (
<LoadingSkeleton />
) : (
<JobTravelerDetail data={data} />
)}
</div>
);
}

View file

@ -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 (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(10)].map((_, i) => (
<div key={i} className="h-12 w-full animate-pulse rounded bg-muted" />
))}
</div>
</CardContent>
</Card>
);
}
export default function JobStatusPage() {
const [data, setData] = useState<JobStatusRow[] | null>(null);
const [error, setError] = useState<string | null>(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 (
<div>
<div className="mb-6">
<h1 className="mb-2 text-3xl font-bold">Job Status</h1>
<p className="text-muted-foreground">
View all open jobs grouped by plant
</p>
</div>
{error ? (
<Card>
<CardContent className="p-6 text-center text-destructive">
Failed to load job status: {error}
</CardContent>
</Card>
) : loading || data === null ? (
<LoadingSkeleton />
) : (
<JobStatusTable data={data} />
)}
</div>
);
}

View file

@ -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 (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-12 w-full animate-pulse rounded bg-muted" />
))}
</div>
</CardContent>
</Card>
);
}
export default function JobTravelerPage() {
const [jobNum, setJobNum] = useState('');
const [searchedJobNum, setSearchedJobNum] = useState('');
const [data, setData] = useState<JobTravelerData | null>(null);
const [error, setError] = useState<string | null>(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 (
<div>
<div className="mb-6">
<h1 className="mb-2 text-3xl font-bold">Job Traveler</h1>
<p className="text-muted-foreground">
View detailed job routing and operations information
</p>
</div>
<div className="mb-6 flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Enter job number..."
value={jobNum}
onChange={(e) => setJobNum(e.target.value)}
onKeyDown={handleKeyDown}
className="pl-8"
/>
</div>
<Button onClick={handleSearch} disabled={!jobNum.trim() || loading}>
Search
</Button>
</div>
{error ? (
<Card>
<CardContent className="p-6 text-center text-destructive">
{error.includes('not found')
? `No job traveler found for job ${searchedJobNum}`
: `Failed to load job traveler: ${error}`}
</CardContent>
</Card>
) : loading ? (
<LoadingSkeleton />
) : data !== null ? (
<JobTravelerDetail data={data} />
) : (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
Enter a job number to view the job traveler
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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<string, typeof data> = {};
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) => `
<tr style="border-top: 1px solid #eee;${i % 2 === 0 ? ' background: #fafafa;' : ''}">
<td style="padding: 3px 6px;">${r.job_num}</td>
<td style="padding: 3px 6px;">${r.job_released ? 'Yes' : 'No'}</td>
<td style="padding: 3px 6px;">${fmtDate(r.due_date)}</td>
<td style="padding: 3px 6px;">${r.part_num}</td>
<td style="padding: 3px 6px;">${r.customer_part_num || ''}</td>
<td style="padding: 3px 6px;">${r.part_description}</td>
<td style="padding: 3px 6px; text-align: right;">${r.prod_qty.toLocaleString()}</td>
<td style="padding: 3px 6px; text-align: right;">${r.qty_completed.toLocaleString()}</td>
<td style="padding: 3px 6px;">${r.order_num ?? ''}</td>
<td style="padding: 3px 6px;">${r.order_line ?? ''}</td>
<td style="padding: 3px 6px;">${r.order_rel_num ?? ''}</td>
<td style="padding: 3px 6px;">${r.po_num || ''}</td>
</tr>
`).join('');
plantSections += `
<tr>
<td colspan="12" style="padding: 6px; background: #e0f2f1; font-weight: bold; color: #0d9488; font-size: 11px;">
${plant} (${rows.length} jobs)
</td>
</tr>
${dataRows}
`;
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, sans-serif; font-size: 9px; color: #333; line-height: 1.3; }
table { border-collapse: collapse; }
</style>
</head>
<body>
<div style="text-align: center; margin-bottom: 12px;">
<div style="font-size: 14px; font-weight: bold; color: #0d9488;">Job Status by Plant</div>
<div style="font-size: 10px; color: #666;">Generated ${new Date().toLocaleDateString()}</div>
</div>
<table style="width: 100%; border: 1px solid #999;">
<thead>
<tr style="background: #0d9488; color: white;">
<th style="padding: 4px 6px; text-align: left;">Job#</th>
<th style="padding: 4px 6px; text-align: left;">Released</th>
<th style="padding: 4px 6px; text-align: left;">Due Date</th>
<th style="padding: 4px 6px; text-align: left;">Part#</th>
<th style="padding: 4px 6px; text-align: left;">Cust Part#</th>
<th style="padding: 4px 6px; text-align: left;">Description</th>
<th style="padding: 4px 6px; text-align: right;">Prod Qty</th>
<th style="padding: 4px 6px; text-align: right;">Qty Complete</th>
<th style="padding: 4px 6px; text-align: left;">Order</th>
<th style="padding: 4px 6px; text-align: left;">Line</th>
<th style="padding: 4px 6px; text-align: left;">Rel</th>
<th style="padding: 4px 6px; text-align: left;">PO#</th>
</tr>
</thead>
<tbody>
${plantSections}
</tbody>
</table>
<div style="margin-top: 8px; text-align: right; font-size: 8px; color: #999;">
Total: ${data.length} jobs across ${Object.keys(grouped).length} plants
</div>
</body>
</html>`;
}
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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 (
<div className="space-y-6">
{/* Header Info Card */}
{header && (
<Card className="mb-6">
<CardContent className="pt-6">
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm md:grid-cols-4">
<div>
<span className="font-semibold text-muted-foreground">
Job #:
</span>{' '}
{header.job_num}
</div>
<div>
<span className="font-semibold text-muted-foreground">
Customer:
</span>{' '}
{header.customer_name}
</div>
<div>
<span className="font-semibold text-muted-foreground">
PO:
</span>{' '}
{header.cust_po || '-'}
</div>
<div>
<span className="font-semibold text-muted-foreground">
Part:
</span>{' '}
{header.part_num}
</div>
<div>
<span className="font-semibold text-muted-foreground">
Description:
</span>{' '}
{header.part_description}
</div>
<div>
<span className="font-semibold text-muted-foreground">
Prod Type:
</span>{' '}
{header.prod_type || '-'}
</div>
<div>
<span className="font-semibold text-muted-foreground">
Order/Line:
</span>{' '}
{header.order_num || '-'}/{header.order_line || '-'}
</div>
<div>
<span className="font-semibold text-muted-foreground">
Completion:
</span>{' '}
{header.completion_date
? formatDate(new Date(header.completion_date))
: '-'}
</div>
<div>
<span className="font-semibold text-muted-foreground">
Ship To:
</span>{' '}
{header.ship_to_name || '-'}
</div>
<div>
<span className="font-semibold text-muted-foreground">
Cust Part:
</span>{' '}
{header.cust_part || '-'}
</div>
<div>
<span className="font-semibold text-muted-foreground">
Plant:
</span>{' '}
{header.plant_name}
</div>
<div>
<span className="font-semibold text-muted-foreground">
Mfg Lot:
</span>{' '}
{header.mfg_lot || '-'}
</div>
</div>
</CardContent>
</Card>
)}
{/* Materials Used Table */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Materials Used</CardTitle>
<CardDescription>
{sortedUsedRows.length}{' '}
{sortedUsedRows.length === 1 ? 'record' : 'records'}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search..."
value={usedSearchTerm}
onChange={(e) => setUsedSearchTerm(e.target.value)}
className="w-64 pl-8"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={handleExportUsed}
disabled={sortedUsedRows.length === 0}
>
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-teal-700 hover:bg-teal-700">
<SortableTableHead
sortKey="lot_used"
currentSortKey={usedSortKey}
currentDirection={usedSortDirection}
onSort={handleUsedSort}
className="text-white"
>
Lot#
</SortableTableHead>
<SortableTableHead
sortKey="part_num_used"
currentSortKey={usedSortKey}
currentDirection={usedSortDirection}
onSort={handleUsedSort}
className="text-white"
>
Part#
</SortableTableHead>
<SortableTableHead
sortKey="part_desc_used"
currentSortKey={usedSortKey}
currentDirection={usedSortDirection}
onSort={handleUsedSort}
className="text-white"
>
Description
</SortableTableHead>
<SortableTableHead
sortKey="start_wt"
currentSortKey={usedSortKey}
currentDirection={usedSortDirection}
onSort={handleUsedSort}
className="text-right text-white"
>
Start Wt
</SortableTableHead>
<SortableTableHead
sortKey="rts_wt_good"
currentSortKey={usedSortKey}
currentDirection={usedSortDirection}
onSort={handleUsedSort}
className="text-right text-white"
>
RTS Good
</SortableTableHead>
<SortableTableHead
sortKey="rts_reject"
currentSortKey={usedSortKey}
currentDirection={usedSortDirection}
onSort={handleUsedSort}
className="text-right text-white"
>
RTS Reject
</SortableTableHead>
<SortableTableHead
sortKey="total_wt_used"
currentSortKey={usedSortKey}
currentDirection={usedSortDirection}
onSort={handleUsedSort}
className="text-right text-white"
>
Total Used
</SortableTableHead>
<SortableTableHead
sortKey="tran_reference"
currentSortKey={usedSortKey}
currentDirection={usedSortDirection}
onSort={handleUsedSort}
className="text-white"
>
Reference
</SortableTableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedUsedRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
className="text-center text-muted-foreground"
>
No materials used found
</TableCell>
</TableRow>
) : (
sortedUsedRows.map((row, idx) => (
<TableRow
key={idx}
className={idx % 2 === 0 ? 'bg-muted/50' : ''}
>
<TableCell>{row.lot_used || '-'}</TableCell>
<TableCell>{row.part_num_used || '-'}</TableCell>
<TableCell>{row.part_desc_used || '-'}</TableCell>
<TableCell className="text-right">
{row.start_wt?.toLocaleString() || '-'}
</TableCell>
<TableCell className="text-right">
{row.rts_wt_good?.toLocaleString() || '-'}
</TableCell>
<TableCell className="text-right">
{row.rts_reject?.toLocaleString() || '-'}
</TableCell>
<TableCell className="text-right">
{row.total_wt_used?.toLocaleString() || '-'}
</TableCell>
<TableCell>{row.tran_reference || '-'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Materials Produced Table */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Materials Produced</CardTitle>
<CardDescription>
{sortedProducedRows.length}{' '}
{sortedProducedRows.length === 1 ? 'record' : 'records'}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search..."
value={producedSearchTerm}
onChange={(e) => setProducedSearchTerm(e.target.value)}
className="w-64 pl-8"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={handleExportProduced}
disabled={sortedProducedRows.length === 0}
>
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-teal-700 hover:bg-teal-700">
<SortableTableHead
sortKey="prod_lot"
currentSortKey={producedSortKey}
currentDirection={producedSortDirection}
onSort={handleProducedSort}
className="text-white"
>
Lot#
</SortableTableHead>
<SortableTableHead
sortKey="prod_wt_good"
currentSortKey={producedSortKey}
currentDirection={producedSortDirection}
onSort={handleProducedSort}
className="text-right text-white"
>
Good Wt
</SortableTableHead>
<SortableTableHead
sortKey="prod_wt_hold"
currentSortKey={producedSortKey}
currentDirection={producedSortDirection}
onSort={handleProducedSort}
className="text-right text-white"
>
Hold Wt
</SortableTableHead>
<SortableTableHead
sortKey="prod_wt_reject"
currentSortKey={producedSortKey}
currentDirection={producedSortDirection}
onSort={handleProducedSort}
className="text-right text-white"
>
Reject Wt
</SortableTableHead>
<SortableTableHead
sortKey="prod_theoretical_wt"
currentSortKey={producedSortKey}
currentDirection={producedSortDirection}
onSort={handleProducedSort}
className="text-right text-white"
>
Theo Wt
</SortableTableHead>
<SortableTableHead
sortKey="prod_length"
currentSortKey={producedSortKey}
currentDirection={producedSortDirection}
onSort={handleProducedSort}
className="text-right text-white"
>
Length
</SortableTableHead>
<SortableTableHead
sortKey="prod_skid"
currentSortKey={producedSortKey}
currentDirection={producedSortDirection}
onSort={handleProducedSort}
className="text-white"
>
Skid#
</SortableTableHead>
<SortableTableHead
sortKey="prod_date"
currentSortKey={producedSortKey}
currentDirection={producedSortDirection}
onSort={handleProducedSort}
className="text-white"
>
Date
</SortableTableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedProducedRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
className="text-center text-muted-foreground"
>
No materials produced found
</TableCell>
</TableRow>
) : (
sortedProducedRows.map((row, idx) => (
<TableRow
key={idx}
className={idx % 2 === 0 ? 'bg-muted/50' : ''}
>
<TableCell>{row.prod_lot || '-'}</TableCell>
<TableCell className="text-right">
{row.prod_wt_good?.toLocaleString() || '-'}
</TableCell>
<TableCell className="text-right">
{row.prod_wt_hold?.toLocaleString() || '-'}
</TableCell>
<TableCell className="text-right">
{row.prod_wt_reject?.toLocaleString() || '-'}
</TableCell>
<TableCell className="text-right">
{row.prod_theoretical_wt?.toLocaleString() || '-'}
</TableCell>
<TableCell className="text-right">
{row.prod_length?.toLocaleString() || '-'}
</TableCell>
<TableCell>{row.prod_skid || '-'}</TableCell>
<TableCell>
{row.prod_date
? formatDate(new Date(row.prod_date))
: '-'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -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<string, JobStatusRow[]> = {};
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 (
<Card>
<CardHeader>
<CardTitle>Job Status by Plant</CardTitle>
<CardDescription>Showing {sortedData.length} open jobs</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search jobs, parts, PO numbers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Button onClick={handleExportCSV} variant="outline">
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<div className="overflow-hidden rounded-md border">
<Table>
<thead>
<tr className="bg-teal-700 text-white">
<SortableTableHead sortKey="job_num" currentSortKey={sortKey} currentDirection={sortDirection} onSort={handleSort}>
Job#
</SortableTableHead>
<SortableTableHead sortKey="job_released" currentSortKey={sortKey} currentDirection={sortDirection} onSort={handleSort}>
Released
</SortableTableHead>
<SortableTableHead sortKey="due_date" currentSortKey={sortKey} currentDirection={sortDirection} onSort={handleSort}>
Due Date
</SortableTableHead>
<SortableTableHead sortKey="part_num" currentSortKey={sortKey} currentDirection={sortDirection} onSort={handleSort}>
Part#
</SortableTableHead>
<SortableTableHead sortKey="customer_part_num" currentSortKey={sortKey} currentDirection={sortDirection} onSort={handleSort}>
Cust Part#
</SortableTableHead>
<SortableTableHead sortKey="part_description" currentSortKey={sortKey} currentDirection={sortDirection} onSort={handleSort}>
Description
</SortableTableHead>
<SortableTableHead sortKey="prod_qty" currentSortKey={sortKey} currentDirection={sortDirection} onSort={handleSort} className="text-right">
Prod Qty
</SortableTableHead>
<SortableTableHead sortKey="qty_completed" currentSortKey={sortKey} currentDirection={sortDirection} onSort={handleSort} className="text-right">
Qty Complete
</SortableTableHead>
<SortableTableHead sortKey="order_num" currentSortKey={sortKey} currentDirection={sortDirection} onSort={handleSort}>
Order
</SortableTableHead>
<SortableTableHead sortKey="order_line" currentSortKey={sortKey} currentDirection={sortDirection} onSort={handleSort}>
Line
</SortableTableHead>
<SortableTableHead sortKey="order_rel_num" currentSortKey={sortKey} currentDirection={sortDirection} onSort={handleSort}>
Rel
</SortableTableHead>
<SortableTableHead sortKey="po_num" currentSortKey={sortKey} currentDirection={sortDirection} onSort={handleSort}>
PO#
</SortableTableHead>
</tr>
</thead>
<TableBody>
{sortedData.length === 0 ? (
<TableRow>
<TableCell colSpan={12} className="text-center text-muted-foreground">
No open jobs found
</TableCell>
</TableRow>
) : (
// Render grouped by plant
Object.entries(groupedByPlant).map(([plant, rows]) => (
<Fragment key={plant}>
{/* Plant section header */}
<TableRow>
<TableCell
colSpan={12}
className="bg-teal-50 font-semibold text-teal-800"
>
{plant} ({rows.length} jobs)
</TableCell>
</TableRow>
{rows.map((row, i) => (
<TableRow key={`${plant}-${i}`} className={i % 2 === 0 ? 'bg-muted/30' : ''}>
<TableCell className="font-medium">{row.job_num}</TableCell>
<TableCell>{row.job_released ? 'Yes' : 'No'}</TableCell>
<TableCell>{row.due_date ? formatDate(new Date(row.due_date)) : '-'}</TableCell>
<TableCell>{row.part_num || '-'}</TableCell>
<TableCell>{row.customer_part_num || '-'}</TableCell>
<TableCell>{row.part_description || '-'}</TableCell>
<TableCell className="text-right">{row.prod_qty.toLocaleString()}</TableCell>
<TableCell className="text-right">{row.qty_completed.toLocaleString()}</TableCell>
<TableCell>{row.order_num ?? '-'}</TableCell>
<TableCell>{row.order_line ?? '-'}</TableCell>
<TableCell>{row.order_rel_num ?? '-'}</TableCell>
<TableCell>{row.po_num || '-'}</TableCell>
</TableRow>
))}
</Fragment>
))
)}
</TableBody>
</Table>
</div>
<div className="mt-4 text-sm text-muted-foreground">
Showing {sortedData.length} of {data.length} jobs across {Object.keys(groupedByPlant).length} plants
</div>
</CardContent>
</Card>
);
}

View file

@ -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 (
<div className="space-y-6">
{/* Download PDF button */}
<div className="flex justify-end">
<a href={`/api/jobs/${header.job_num}/traveler/pdf`} target="_blank" rel="noopener noreferrer">
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
</a>
</div>
{/* Job Header */}
<Card>
<CardHeader>
<CardTitle>Job: {header.job_num}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm md:grid-cols-4">
<div><span className="font-semibold text-muted-foreground">Plant:</span> {header.plant}</div>
<div><span className="font-semibold text-muted-foreground">Customer:</span> {header.customer_name}</div>
<div><span className="font-semibold text-muted-foreground">PO Number:</span> {header.po_num || '-'}</div>
<div><span className="font-semibold text-muted-foreground">Part:</span> {header.part_num}</div>
<div><span className="font-semibold text-muted-foreground">Description:</span> {header.part_description}</div>
<div><span className="font-semibold text-muted-foreground">Rev:</span> {header.revision || '-'}</div>
<div><span className="font-semibold text-muted-foreground">Cust Part:</span> {header.cust_part || '-'}</div>
<div><span className="font-semibold text-muted-foreground">Warehouse:</span> {header.warehouse || '-'}</div>
</div>
</CardContent>
</Card>
{/* Schedule Dates */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Schedule</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm md:grid-cols-4">
<div><span className="font-semibold text-muted-foreground">Total Qty:</span> {header.prod_qty.toLocaleString()} {header.ium}</div>
<div><span className="font-semibold text-muted-foreground">Start Date:</span> {header.start_date ? formatDate(new Date(header.start_date)) : '-'}</div>
<div><span className="font-semibold text-muted-foreground">Due Date:</span> {header.due_date ? formatDate(new Date(header.due_date)) : '-'}</div>
<div><span className="font-semibold text-muted-foreground">Req. By:</span> {header.req_due_date ? formatDate(new Date(header.req_due_date)) : '-'}</div>
</div>
{header.comment_text && (
<div className="mt-4 rounded-md border bg-muted/30 p-3 text-sm">
<span className="font-semibold">Job Comments:</span> {header.comment_text}
</div>
)}
</CardContent>
</Card>
{/* Shipping Schedule */}
{shipping_schedule.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Shipping Schedule</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-teal-700 text-white">
<TableHead className="text-white">Date</TableHead>
<TableHead className="text-white">Status</TableHead>
<TableHead className="text-white">SO</TableHead>
<TableHead className="text-white">Line</TableHead>
<TableHead className="text-white">Rel</TableHead>
<TableHead className="text-right text-white">Order Qty</TableHead>
<TableHead className="text-white">UM</TableHead>
<TableHead className="text-right text-white">Qty from Job</TableHead>
<TableHead className="text-white">Ship Via</TableHead>
<TableHead className="text-white">Ship To</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{shipping_schedule.map((line, i) => (
<TableRow key={i} className={i % 2 === 0 ? 'bg-muted/30' : ''}>
<TableCell>{line.need_by_date ? formatDate(new Date(line.need_by_date)) : '-'}</TableCell>
<TableCell>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
line.line_status === 'Open'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{line.line_status || '-'}
</span>
</TableCell>
<TableCell>{line.order_num ?? '-'}</TableCell>
<TableCell>{line.order_line ?? '-'}</TableCell>
<TableCell>{line.order_rel_num ?? '-'}</TableCell>
<TableCell className="text-right">{line.order_qty?.toLocaleString() ?? '-'}</TableCell>
<TableCell>{line.sales_um || '-'}</TableCell>
<TableCell className="text-right">{line.qty_from_job?.toLocaleString() ?? '-'}</TableCell>
<TableCell>{line.ship_via || '-'}</TableCell>
<TableCell>{line.ship_to || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* Raw Material Components */}
{materials.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Raw Material Components</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-teal-700 text-white">
<TableHead className="text-white">Seq</TableHead>
<TableHead className="text-white">Part#</TableHead>
<TableHead className="text-white">Description</TableHead>
<TableHead className="text-right text-white">Req Qty</TableHead>
<TableHead className="text-white">UM</TableHead>
<TableHead className="text-white">Whse</TableHead>
<TableHead className="text-white">Rel OP</TableHead>
<TableHead className="text-white">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materials.map((mtl, i) => (
<TableRow key={i} className={i % 2 === 0 ? 'bg-muted/30' : ''}>
<TableCell>{mtl.mtl_seq}</TableCell>
<TableCell>{mtl.part_num || '-'}</TableCell>
<TableCell>{mtl.description || '-'}</TableCell>
<TableCell className="text-right">{mtl.required_qty?.toLocaleString() ?? '-'}</TableCell>
<TableCell>{mtl.ium || '-'}</TableCell>
<TableCell>{mtl.warehouse_code || '-'}</TableCell>
<TableCell>{mtl.related_operation ?? '-'}</TableCell>
<TableCell>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
mtl.issue_status === 'Open'
? 'bg-green-100 text-green-800'
: mtl.issue_status === 'Issued Complete'
? 'bg-gray-100 text-gray-800'
: 'bg-blue-100 text-blue-800'
}`}>
{mtl.issue_status || '-'}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* Operations */}
{operations.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Operations</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-teal-700 text-white">
<TableHead className="text-white">Seq</TableHead>
<TableHead className="text-white">Code</TableHead>
<TableHead className="text-white">Description</TableHead>
<TableHead className="text-right text-white">Qty</TableHead>
<TableHead className="text-right text-white">Res.</TableHead>
<TableHead className="text-right text-white">Setup Hrs</TableHead>
<TableHead className="text-right text-white">Prod Hrs</TableHead>
<TableHead className="text-right text-white">Standard</TableHead>
<TableHead className="text-white">Start</TableHead>
<TableHead className="text-white">Due</TableHead>
<TableHead className="text-white">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{operations.map((op, i) => (
<TableRow key={i} className={i % 2 === 0 ? 'bg-muted/30' : ''}>
<TableCell>{op.opr_seq}</TableCell>
<TableCell>{op.op_code || '-'}</TableCell>
<TableCell>{op.op_desc || '-'}</TableCell>
<TableCell className="text-right">{op.run_qty?.toLocaleString() ?? '-'}</TableCell>
<TableCell className="text-right">{op.crew_size ?? '-'}</TableCell>
<TableCell className="text-right">{op.est_setup_hours?.toFixed(2) ?? '-'}</TableCell>
<TableCell className="text-right">{op.est_prod_hours?.toFixed(2) ?? '-'}</TableCell>
<TableCell className="text-right">{op.prod_standard?.toFixed(2) ?? '-'}</TableCell>
<TableCell>{op.start_date ? formatDate(new Date(op.start_date)) : '-'}</TableCell>
<TableCell>{op.due_date ? formatDate(new Date(op.due_date)) : '-'}</TableCell>
<TableCell>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
op.status === 'OPEN'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{op.status || '-'}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* Paint/Part Specifications */}
{paint_data && Object.keys(paint_data).length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Part Specifications</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm md:grid-cols-3 lg:grid-cols-4">
{Object.entries(paint_data)
.filter(([, v]) => v != null && String(v).trim() !== '' && String(v) !== '0' && String(v) !== '0.00000000')
.map(([key, value]) => (
<div key={key}>
<span className="font-semibold text-muted-foreground">{formatPaintKey(key)}:</span>{' '}
{String(value)}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}
/** 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());
}

View file

@ -134,7 +134,10 @@ export function PortalSidebar({
href: '/jobs',
icon: <Briefcase className="h-5 w-5" />,
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 },
],
},
];

View file

@ -0,0 +1,274 @@
import type { JobTravelerData } from '@/types/jobs';
const VORTEQ_LOGO_SVG = `
<svg width="60" height="60" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="45" r="35" fill="none" stroke="#8BC34A" stroke-width="8"/>
<line x1="65" y1="65" x2="90" y2="90" stroke="#8BC34A" stroke-width="8" stroke-linecap="round"/>
</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) => `
<tr style="border-top: 1px solid #ddd;${i % 2 === 0 ? ' background: #fafafa;' : ''}">
<td style="padding: 4px 6px;">${fmtDate(s.need_by_date)}</td>
<td style="padding: 4px 6px;">${s.line_status || ''}</td>
<td style="padding: 4px 6px;">${s.order_num || ''}</td>
<td style="padding: 4px 6px;">${s.order_line ?? ''}</td>
<td style="padding: 4px 6px;">${s.order_rel_num ?? ''}</td>
<td style="padding: 4px 6px; text-align: right;">${fmtQty(s.order_qty)}</td>
<td style="padding: 4px 6px;">${s.sales_um || ''}</td>
<td style="padding: 4px 6px; text-align: right;">${fmtQty(s.qty_from_job)}</td>
<td style="padding: 4px 6px;">${s.ship_via || ''}</td>
<td style="padding: 4px 6px;">${s.ship_to || ''}</td>
</tr>
`).join('');
// Build materials rows
const materialsRows = materials.map((m, i) => `
<tr style="border-top: 1px solid #ddd;${i % 2 === 0 ? ' background: #fafafa;' : ''}">
<td style="padding: 4px 6px;">${m.mtl_seq ?? ''}</td>
<td style="padding: 4px 6px;">${m.part_num || ''}</td>
<td style="padding: 4px 6px;">${m.description || ''}</td>
<td style="padding: 4px 6px; text-align: right;">${fmtQty(m.required_qty)}</td>
<td style="padding: 4px 6px;">${m.ium || ''}</td>
<td style="padding: 4px 6px;">${m.warehouse_code || ''}</td>
<td style="padding: 4px 6px;">${m.related_operation ?? ''}</td>
<td style="padding: 4px 6px;">${m.issue_status || ''}</td>
</tr>
`).join('');
// Build operations rows
const operationsRows = operations.map((op, i) => `
<tr style="border-top: 1px solid #ddd;${i % 2 === 0 ? ' background: #fafafa;' : ''}">
<td style="padding: 4px 6px;">${op.opr_seq ?? ''}</td>
<td style="padding: 4px 6px;">${op.op_code || ''}</td>
<td style="padding: 4px 6px;">${op.op_desc || ''}</td>
<td style="padding: 4px 6px; text-align: right;">${fmtQty(op.run_qty)}</td>
<td style="padding: 4px 6px;">${op.crew_size || ''}</td>
<td style="padding: 4px 6px; text-align: right;">${op.est_setup_hours?.toFixed(2) || '0.00'}</td>
<td style="padding: 4px 6px; text-align: right;">${op.est_prod_hours?.toFixed(2) || '0.00'}</td>
<td style="padding: 4px 6px; text-align: right;">${op.prod_standard?.toFixed(4) || '0.0000'}</td>
<td style="padding: 4px 6px;">${fmtDate(op.start_date)}</td>
<td style="padding: 4px 6px;">${fmtDate(op.due_date)}</td>
<td style="padding: 4px 6px;">${op.status || ''}</td>
</tr>
`).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 => `
<tr>
<td style="padding: 4px 8px; font-weight: bold; border-right: 1px solid #ccc; background: #f5f5f5;">${f.label}</td>
<td style="padding: 4px 8px;">${specs[f.key as keyof typeof specs] || ''}</td>
</tr>
`).join('');
const specsSection = specRows ? `
<div style="margin-top: 12px;">
<div style="font-weight: bold; font-size: 11px; margin-bottom: 4px; color: #0d9488;">Part Specifications</div>
<table style="width: 50%; border: 1px solid #999; border-collapse: collapse;">
${specRows}
</table>
</div>
` : '';
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, sans-serif; font-size: 9px; color: #333; padding: 12px; line-height: 1.3; }
table { border-collapse: collapse; }
</style>
</head>
<body>
<div style="display: flex; justify-content: space-between; margin-bottom: 12px; border-bottom: 2px solid #0d9488; padding-bottom: 8px;">
<div>
<div style="margin-bottom: 4px;">${VORTEQ_LOGO_SVG}</div>
<div style="font-size: 8px; color: #666;">
<div>1234 Industrial Way</div>
<div>Manufacturing City, ST 12345</div>
<div>Phone: (555) 123-4567</div>
</div>
</div>
<div style="text-align: center; flex: 1;">
<div style="font-size: 18px; font-weight: bold; color: #0d9488; margin-bottom: 4px;">JOB TRAVELER</div>
<div style="font-size: 11px;">
<strong>Job #:</strong> ${header.job_num} &nbsp;&nbsp; <strong>Plant:</strong> ${header.plant || ''}
</div>
</div>
</div>
<div style="display: flex; gap: 16px; margin-bottom: 12px;">
<div style="flex: 2;">
<table style="width: 100%; border: 1px solid #999;">
<tr style="background: #0d9488; color: white;">
<th colspan="2" style="padding: 4px 6px; text-align: left; font-size: 10px;">Job Information</th>
</tr>
<tr>
<td style="padding: 4px 6px; font-weight: bold; width: 30%; background: #f5f5f5;">Customer</td>
<td style="padding: 4px 6px;">${header.customer_name || ''}</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-weight: bold; background: #f5f5f5;">PO Number</td>
<td style="padding: 4px 6px;">${header.po_num || ''}</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-weight: bold; background: #f5f5f5;">Part Number</td>
<td style="padding: 4px 6px;">${header.part_num || ''}</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-weight: bold; background: #f5f5f5;">Description</td>
<td style="padding: 4px 6px;">${header.part_description || ''}</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-weight: bold; background: #f5f5f5;">Revision</td>
<td style="padding: 4px 6px;">${header.revision || ''}</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-weight: bold; background: #f5f5f5;">Customer Part</td>
<td style="padding: 4px 6px;">${header.cust_part || ''}</td>
</tr>
</table>
</div>
<div style="flex: 1;">
<table style="width: 100%; border: 1px solid #999;">
<tr style="background: #0d9488; color: white;">
<th colspan="2" style="padding: 4px 6px; text-align: left; font-size: 10px;">Schedule</th>
</tr>
<tr>
<td style="padding: 4px 6px; font-weight: bold; background: #f5f5f5;">Total Qty</td>
<td style="padding: 4px 6px; text-align: right;">${fmtQty(header.prod_qty)}</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-weight: bold; background: #f5f5f5;">Start Date</td>
<td style="padding: 4px 6px;">${fmtDate(header.start_date)}</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-weight: bold; background: #f5f5f5;">Due Date</td>
<td style="padding: 4px 6px;">${fmtDate(header.due_date)}</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-weight: bold; background: #f5f5f5;">Req Date</td>
<td style="padding: 4px 6px;">${fmtDate(header.req_due_date)}</td>
</tr>
</table>
</div>
</div>
${header.comment_text ? `
<div style="margin-bottom: 12px; padding: 6px; border: 1px solid #999; background: #fffef0;">
<div style="font-weight: bold; font-size: 10px; color: #0d9488; margin-bottom: 2px;">Job Comments</div>
<div style="white-space: pre-wrap;">${header.comment_text}</div>
</div>
` : ''}
<div style="margin-bottom: 12px;">
<table style="width: 100%; border: 1px solid #999;">
<thead>
<tr style="background: #0d9488; color: white;">
<th style="padding: 4px 6px; text-align: left;">Date</th>
<th style="padding: 4px 6px; text-align: left;">Status</th>
<th style="padding: 4px 6px; text-align: left;">SO</th>
<th style="padding: 4px 6px; text-align: left;">Line</th>
<th style="padding: 4px 6px; text-align: left;">Rel</th>
<th style="padding: 4px 6px; text-align: right;">Order Qty</th>
<th style="padding: 4px 6px; text-align: left;">UM</th>
<th style="padding: 4px 6px; text-align: right;">Qty From Job</th>
<th style="padding: 4px 6px; text-align: left;">Ship Via</th>
<th style="padding: 4px 6px; text-align: left;">Ship To</th>
</tr>
</thead>
<tbody>
${shippingRows || '<tr><td colspan="10" style="padding: 8px; text-align: center; color: #999;">No shipping schedule</td></tr>'}
</tbody>
</table>
</div>
<div style="margin-bottom: 12px;">
<div style="font-weight: bold; font-size: 10px; margin-bottom: 4px; color: #0d9488;">Raw Material Components</div>
<table style="width: 100%; border: 1px solid #999;">
<thead>
<tr style="background: #0d9488; color: white;">
<th style="padding: 4px 6px; text-align: left;">Seq</th>
<th style="padding: 4px 6px; text-align: left;">Part Number</th>
<th style="padding: 4px 6px; text-align: left;">Description</th>
<th style="padding: 4px 6px; text-align: right;">Req Qty</th>
<th style="padding: 4px 6px; text-align: left;">UM</th>
<th style="padding: 4px 6px; text-align: left;">Whse</th>
<th style="padding: 4px 6px; text-align: left;">Rel OP</th>
<th style="padding: 4px 6px; text-align: left;">Status</th>
</tr>
</thead>
<tbody>
${materialsRows || '<tr><td colspan="8" style="padding: 8px; text-align: center; color: #999;">No materials</td></tr>'}
</tbody>
</table>
</div>
<div style="margin-bottom: 12px;">
<div style="font-weight: bold; font-size: 10px; margin-bottom: 4px; color: #0d9488;">Operations</div>
<table style="width: 100%; border: 1px solid #999;">
<thead>
<tr style="background: #0d9488; color: white;">
<th style="padding: 4px 6px; text-align: left;">Seq</th>
<th style="padding: 4px 6px; text-align: left;">Code</th>
<th style="padding: 4px 6px; text-align: left;">Description</th>
<th style="padding: 4px 6px; text-align: right;">Qty</th>
<th style="padding: 4px 6px; text-align: left;">Res</th>
<th style="padding: 4px 6px; text-align: right;">Setup Hrs</th>
<th style="padding: 4px 6px; text-align: right;">Prod Hrs</th>
<th style="padding: 4px 6px; text-align: right;">Standard</th>
<th style="padding: 4px 6px; text-align: left;">Start</th>
<th style="padding: 4px 6px; text-align: left;">Due</th>
<th style="padding: 4px 6px; text-align: left;">Status</th>
</tr>
</thead>
<tbody>
${operationsRows || '<tr><td colspan="11" style="padding: 8px; text-align: center; color: #999;">No operations</td></tr>'}
</tbody>
</table>
</div>
${specsSection}
<div style="margin-top: 16px; font-size: 8px; color: #999; text-align: right;">
Generated ${new Date().toLocaleString()}
</div>
</body>
</html>`;
}

View file

@ -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<string, unknown>): 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<CoilByCoilRow[]> {
// 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<Record<string, unknown>[]>(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<string, unknown>): 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),
};
}

319
src/services/jobs.ts Normal file
View file

@ -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<JobStatusRow[]> {
// 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<StoredProcResult[]>(
'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<JobTravelerData | null> {
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<QueryResult[]>(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<string>();
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<number>();
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<number>();
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,
};
}

View file

@ -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;
};

93
src/types/jobs.ts Normal file
View file

@ -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;
};