init-source
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user