Files
qms/pages/qc/shipments.tsx
T
jason ad499f6782
Build and Push Docker Image / build (push) Successful in 1m12s
Assemble QMS app + SQLite refactor + Unraid single-container deploy
Reconstruct the full app from init-source overlays (base + fix-1..6 +
update-1..3, last-wins) at the repo root, complete the missing pieces so it
builds and runs, and stage the Unraid deployment.

App completion:
- types/index.ts: former Prisma enums as string-literal unions + AppUser
- pages/_app.tsx + styles/globals.css (mount AppProvider/ToastProvider)
- API routes: auth/login, auth/me, users, submissions (+REVIEW_READY notify),
  forms (list/create), notifications
- scripts/create-admin.js: idempotent first-admin bootstrap
- 14 unbuilt nav targets stubbed via ComingSoon placeholder

SQLite refactor (single-container, no external DB):
- schema provider -> sqlite; enums -> String; Json -> String;
  FormField.options String[] -> JSON-encoded String
- lib/forms.ts (de)serialises options at the DB boundary
- drop mode:"insensitive" (unsupported on SQLite)
- enum imports repointed from @prisma/client to @/types

Deploy:
- multi-stage Dockerfile (next build -> prod runner), docker-entrypoint.sh
  (prisma db push -> create-admin -> next start), .dockerignore
- docker-compose.yml: br0 10.2.0.x, /mnt/user/appdata/qms -> /data volume
- README rewritten for the Unraid/Gitea Actions flow; .env scrubbed of the
  live Supabase credential; vercel.json removed

Verified: next build clean (41 routes); live SQLite round-trip of
login/session, form options array, and submission -> REVIEW_READY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:58:47 -05:00

190 lines
11 KiB
TypeScript

import { useState, useEffect } from 'react'
import Layout from '@/components/layout/Layout'
import { Card, EmptyState, Btn, Modal, Field, Input, Textarea, showToast, Tag } from '@/components/ui'
import { useApp } from '@/lib/context'
import { SHIPMENT_SEND_ROLES } from '@/lib/auth'
const TYPE_TAG: Record<string, string> = { FORM_DATA: 'purple', NCR_FIX: 'green', AUDIT: 'amber', OTHER: 'gray' }
const TYPE_LABEL: Record<string, string> = { FORM_DATA: 'Form data', NCR_FIX: 'NCR fix', AUDIT: 'Audit', OTHER: 'Other' }
export default function ShipmentsPage() {
const { user } = useApp()
const [shipments, setShipments] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [openId, setOpenId] = useState<string | null>(null)
const [items, setItems] = useState<Record<string, boolean>>({})
// New shipment modal
const [newOpen, setNewOpen] = useState(false)
const [newForm, setNewForm] = useState({ product: '', batch: '', client: '', clientEmail: '', shippedAt: '' })
const [suggested, setSuggested] = useState<any[]>([])
const [creating, setCreating] = useState(false)
// Compose modal
const [composeShipment, setComposeShipment] = useState<any>(null)
const [composeForm, setComposeForm] = useState({ email: '', subject: '', message: '' })
const [sending, setSending] = useState(false)
const canSend = user && (SHIPMENT_SEND_ROLES as readonly string[]).includes(user.role)
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
const res = await fetch('/api/shipments')
if (res.ok) { const { data } = await res.json(); setShipments(data || []) }
setLoading(false)
}
async function suggestItems(product: string) {
if (!product) { setSuggested([]); return }
const res = await fetch(`/api/shipments/suggest?product=${encodeURIComponent(product)}`)
if (res.ok) { const { data } = await res.json(); setSuggested(data || []) }
}
async function createShipment() {
if (!newForm.product || !newForm.batch || !newForm.client || !newForm.shippedAt) {
showToast('Product, batch, client, and ship date required', 'error'); return
}
setCreating(true)
const res = await fetch('/api/shipments', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...newForm, items: suggested }),
})
setCreating(false)
if (res.ok) {
setNewOpen(false)
setNewForm({ product: '', batch: '', client: '', clientEmail: '', shippedAt: '' })
setSuggested([])
showToast('Shipment recorded')
load()
} else {
showToast('Failed to create shipment', 'error')
}
}
function toggleShipment(s: any) {
if (openId === s.id) { setOpenId(null); return }
setOpenId(s.id)
const sel: Record<string, boolean> = {}
s.items.forEach((it: any) => { sel[it.id] = it.included })
setItems(sel)
}
function openCompose(s: any) {
const selected = s.items.filter((it: any) => items[it.id])
const lines = selected.map((it: any) => `- ${it.label}`).join('\n')
setComposeShipment(s)
setComposeForm({
email: s.clientEmail || '',
subject: `Quality Release Package — ${s.product} — Batch ${s.batch}`,
message: `Hello,\n\nPlease find confirmation that the following items have passed QC standards and the product has been cleared for shipment:\n\n${lines}\n\nIf you have any questions, just reply to this email.\n\nThanks,\nQuality team`,
})
}
async function sendCompose() {
if (!composeForm.email) { showToast('Client email required', 'error'); return }
setSending(true)
const res = await fetch(`/api/shipments/${composeShipment.id}/send`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clientEmail: composeForm.email, subject: composeForm.subject, message: composeForm.message }),
})
setSending(false)
if (res.ok) {
showToast(`Package sent to ${composeForm.email}`)
setComposeShipment(null)
load()
} else {
showToast('Failed to send', 'error')
}
}
return (
<Layout title="Client release">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div>
<h2 style={{ fontSize: '16px', fontWeight: '500', margin: 0 }}>Client release packages</h2>
<p style={{ fontSize: '11px', color: '#aaa', margin: '2px 0 0' }}>Shipments grouped by product, batch, and date send "good status" confirmation to clients</p>
</div>
<Btn onClick={() => setNewOpen(true)}>+ Record shipment</Btn>
</div>
<div style={{ background: '#F1EFE8', borderRadius: '10px', padding: '10px 14px', fontSize: '11px', color: '#444', marginBottom: '14px', display: 'flex', alignItems: 'center', gap: '7px' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2a3 3 0 00-1.5-2.598M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2a3 3 0 011.5-2.598M9 7a3 3 0 116 0 3 3 0 01-6 0z"/></svg>
Send access: Production leads · Logistics lead · Admin
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '32px', color: '#aaa', fontSize: '12px' }}>Loading</div>
) : shipments.length === 0 ? (
<EmptyState title="No shipments recorded yet" message="Record a shipment to start tracking what's been sent to clients, and to report any future quality escapes against it." action={{ label: '+ Record first shipment', onClick: () => setNewOpen(true) }}/>
) : shipments.map((s: any) => (
<Card key={s.id} style={{ marginBottom: '10px', padding: 0, overflow: 'hidden' }}>
<div onClick={() => toggleShipment(s)} style={{ padding: '13px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}>
<div>
<div style={{ fontSize: '13px', fontWeight: '500' }}>{s.product} Batch {s.batch}</div>
<div style={{ fontSize: '11px', color: '#aaa', marginTop: '2px' }}>
{s.ref} · shipped {new Date(s.shippedAt).toLocaleDateString()} · {s.client} · {s.items.filter((i: any) => i.included).length} records included
{s.sentAt && <span style={{ color: '#1D9E75', fontWeight: 500 }}> · sent to {s.sentTo}</span>}
{s._count?.escapes > 0 && <span style={{ color: '#E24B4A', fontWeight: 500 }}> · {s._count.escapes} client issue{s._count.escapes > 1 ? 's' : ''}</span>}
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#aaa" strokeWidth="2" style={{ transform: openId === s.id ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }}><path d="M6 9l6 6 6-6"/></svg>
</div>
{openId === s.id && (
<div style={{ padding: '0 16px 14px', borderTop: '0.5px solid #eee' }}>
{s.items.map((it: any) => (
<label key={it.id} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 0', fontSize: '12px', borderBottom: '0.5px solid #f5f5f5', cursor: 'pointer' }}>
<input type="checkbox" checked={items[it.id] ?? it.included} onChange={e => setItems(i => ({ ...i, [it.id]: e.target.checked }))} style={{ accentColor: '#534AB7' }}/>
<span style={{ flex: 1 }}>{it.label}</span>
<Tag color={TYPE_TAG[it.type]}>{TYPE_LABEL[it.type]}</Tag>
</label>
))}
{canSend ? (
<Btn size="sm" onClick={() => openCompose(s)} style={{ marginTop: '12px' }}>Email selected to client</Btn>
) : (
<div style={{ fontSize: '11px', color: '#aaa', marginTop: '12px' }}>Only Production leads, Logistics lead, or Admin can send this package.</div>
)}
</div>
)}
</Card>
))}
{/* New shipment modal */}
<Modal open={newOpen} onClose={() => setNewOpen(false)} title="Record shipment" width={460}>
<Field label="Product" required>
<Input value={newForm.product} onChange={e => { setNewForm(f => ({ ...f, product: e.target.value })); suggestItems(e.target.value) }} placeholder="Widget A Rev 2"/>
</Field>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<Field label="Batch" required><Input value={newForm.batch} onChange={e => setNewForm(f => ({ ...f, batch: e.target.value }))} placeholder="B-2024-06"/></Field>
<Field label="Ship date" required><Input type="date" value={newForm.shippedAt} onChange={e => setNewForm(f => ({ ...f, shippedAt: e.target.value }))}/></Field>
</div>
<Field label="Client" required><Input value={newForm.client} onChange={e => setNewForm(f => ({ ...f, client: e.target.value }))} placeholder="Acme Distribution"/></Field>
<Field label="Client email (optional)"><Input type="email" value={newForm.clientEmail} onChange={e => setNewForm(f => ({ ...f, clientEmail: e.target.value }))} placeholder="qa@acme.com"/></Field>
{suggested.length > 0 && (
<div style={{ background: '#EEEDFE', borderRadius: '8px', padding: '9px 12px', fontSize: '11px', color: '#3C3489', marginBottom: '12px' }}>
Auto-suggested {suggested.length} record{suggested.length !== 1 ? 's' : ''} for this product adjust after creating.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '14px' }}>
<Btn variant="ghost" onClick={() => setNewOpen(false)}>Cancel</Btn>
<Btn onClick={createShipment} disabled={creating}>{creating ? 'Saving…' : 'Record shipment'}</Btn>
</div>
</Modal>
{/* Compose modal */}
<Modal open={!!composeShipment} onClose={() => setComposeShipment(null)} title="Send quality release package" width={480}>
<Field label="Client email" required><Input type="email" value={composeForm.email} onChange={e => setComposeForm(f => ({ ...f, email: e.target.value }))} placeholder="client@company.com"/></Field>
<Field label="Subject"><Input value={composeForm.subject} onChange={e => setComposeForm(f => ({ ...f, subject: e.target.value }))}/></Field>
<Field label="Message"><Textarea value={composeForm.message} onChange={e => setComposeForm(f => ({ ...f, message: e.target.value }))} style={{ minHeight: '180px' }}/></Field>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '14px' }}>
<Btn variant="ghost" onClick={() => setComposeShipment(null)}>Cancel</Btn>
<Btn onClick={sendCompose} disabled={sending}>{sending ? 'Sending…' : 'Send package'}</Btn>
</div>
</Modal>
</Layout>
)
}