init-source
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireAuth, logAction, generateRef } from '@/lib/auth'
|
||||
import { NCRSeverity, EscapeStatus } from '@prisma/client'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await requireAuth(req, res, ['ADMIN', 'QC'])
|
||||
if (!user) return
|
||||
|
||||
const { id } = req.query as { id: string }
|
||||
|
||||
const escape = await prisma.qualityEscape.findUnique({
|
||||
where: { id },
|
||||
include: { shipment: true, capa: { select: { ref: true } } },
|
||||
})
|
||||
if (!escape) return res.status(404).json({ error: 'Escape not found' })
|
||||
|
||||
if (req.method === 'GET') {
|
||||
return res.json({ data: escape })
|
||||
}
|
||||
|
||||
if (req.method === 'PATCH') {
|
||||
const before = { ...escape }
|
||||
const { severity, status, resolution, category, standardItem, escalate, capaForm } = req.body as {
|
||||
severity?: NCRSeverity
|
||||
status?: EscapeStatus
|
||||
resolution?: string
|
||||
category?: string
|
||||
standardItem?: string
|
||||
escalate?: boolean
|
||||
capaForm?: { title: string; priority: string; ownerId: string; dueDate: string }
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
if (severity !== undefined) updateData.severity = severity
|
||||
if (status !== undefined) updateData.status = status
|
||||
|
||||
if (resolution !== undefined && category !== undefined) {
|
||||
updateData.resolution = resolution
|
||||
updateData.category = category
|
||||
updateData.status = 'RESOLVED'
|
||||
updateData.resolvedAt = new Date()
|
||||
}
|
||||
|
||||
if (standardItem !== undefined) {
|
||||
updateData.standardItemAdded = standardItem || '—'
|
||||
if (standardItem) {
|
||||
const maxOrder = await prisma.shippingStandardItem.count()
|
||||
await prisma.shippingStandardItem.create({
|
||||
data: { text: standardItem, source: escape.ref, order: maxOrder },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let capaRef: string | undefined
|
||||
if (escalate && capaForm) {
|
||||
const count = await prisma.cAPA.count()
|
||||
capaRef = generateRef('CAPA', count)
|
||||
const capa = await prisma.cAPA.create({
|
||||
data: {
|
||||
ref: capaRef, title: capaForm.title,
|
||||
description: `Client-reported quality escape ${escape.ref} (${escape.shipment.ref}): ${escape.description}`,
|
||||
priority: capaForm.priority as any,
|
||||
ownerId: capaForm.ownerId, raisedById: user.id,
|
||||
dueDate: new Date(capaForm.dueDate),
|
||||
},
|
||||
})
|
||||
await prisma.cAPAEvent.create({
|
||||
data: { capaId: capa.id, event: 'CAPA raised', note: `Escalated from quality escape ${escape.ref} by ${user.name}` }
|
||||
})
|
||||
updateData.status = 'ESCALATED'
|
||||
updateData.capaId = capa.id
|
||||
}
|
||||
|
||||
const updated = await prisma.qualityEscape.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: { shipment: true, capa: { select: { ref: true } } },
|
||||
})
|
||||
|
||||
// File to resolutions library
|
||||
if (resolution !== undefined && category !== undefined) {
|
||||
await prisma.resolution.create({
|
||||
data: {
|
||||
title: escape.description.length > 80 ? escape.description.slice(0, 80) + '…' : escape.description,
|
||||
category, resolution, linkedRef: escape.ref,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await logAction(user.id, 'UPDATE', 'QualityEscape', id, before, updated)
|
||||
return res.json({ data: updated })
|
||||
}
|
||||
|
||||
res.status(405).end()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireAuth, logAction, generateRef, SHIPMENT_SEND_ROLES } from '@/lib/auth'
|
||||
import { EscapeStatus } from '@prisma/client'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await requireAuth(req, res)
|
||||
if (!user) return
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const { status } = req.query
|
||||
const where: any = {}
|
||||
if (status) where.status = status as EscapeStatus
|
||||
|
||||
const escapes = await prisma.qualityEscape.findMany({
|
||||
where,
|
||||
include: { shipment: true, capa: { select: { ref: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return res.json({ data: escapes })
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
// Report access: Production leads, Logistics lead, Admin
|
||||
const gated = await requireAuth(req, res, SHIPMENT_SEND_ROLES)
|
||||
if (!gated) return
|
||||
|
||||
const { shipmentId, description, contact } = req.body
|
||||
if (!shipmentId || !description) return res.status(400).json({ error: 'shipmentId and description required' })
|
||||
|
||||
const shipment = await prisma.shipment.findUnique({ where: { id: shipmentId } })
|
||||
if (!shipment) return res.status(404).json({ error: 'Shipment not found' })
|
||||
|
||||
const count = await prisma.qualityEscape.count()
|
||||
const ref = generateRef('ESC', count)
|
||||
|
||||
const escape = await prisma.qualityEscape.create({
|
||||
data: { ref, shipmentId, description, contact },
|
||||
include: { shipment: true },
|
||||
})
|
||||
|
||||
await logAction(user.id, 'CREATE', 'QualityEscape', escape.id, null, { ref, shipmentRef: shipment.ref })
|
||||
return res.status(201).json({ data: escape })
|
||||
}
|
||||
|
||||
res.status(405).end()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireAuth, logAction } from '@/lib/auth'
|
||||
import { notifySolutionConfirmed } from '@/lib/email'
|
||||
import { NCRSeverity, NCRStatus } from '@prisma/client'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await requireAuth(req, res, ['ADMIN', 'QC'])
|
||||
if (!user) return
|
||||
|
||||
const { id } = req.query as { id: string }
|
||||
|
||||
const ncr = await prisma.nCR.findUnique({
|
||||
where: { id },
|
||||
include: { raisedBy: { select: { id: true, name: true, email: true } }, capa: { select: { ref: true } } },
|
||||
})
|
||||
if (!ncr) return res.status(404).json({ error: 'NCR not found' })
|
||||
|
||||
if (req.method === 'GET') {
|
||||
return res.json({ data: ncr })
|
||||
}
|
||||
|
||||
if (req.method === 'PATCH') {
|
||||
const before = { ...ncr }
|
||||
const { severity, status, resolution, category, confirmNotify } = req.body as {
|
||||
severity?: NCRSeverity
|
||||
status?: NCRStatus
|
||||
resolution?: string
|
||||
category?: string
|
||||
confirmNotify?: boolean
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
|
||||
if (severity !== undefined) updateData.severity = severity
|
||||
|
||||
if (status !== undefined) updateData.status = status
|
||||
|
||||
if (resolution !== undefined && category !== undefined) {
|
||||
// Resolving — requires both resolution notes and a category, files to library
|
||||
updateData.resolution = resolution
|
||||
updateData.category = category
|
||||
updateData.status = 'RESOLVED'
|
||||
updateData.resolvedAt = new Date()
|
||||
}
|
||||
|
||||
if (confirmNotify) {
|
||||
updateData.notified = true
|
||||
updateData.notifiedAt = new Date()
|
||||
}
|
||||
|
||||
const updated = await prisma.nCR.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: { raisedBy: { select: { id: true, name: true, email: true } }, capa: { select: { ref: true } } },
|
||||
})
|
||||
|
||||
// File to resolutions library
|
||||
if (resolution !== undefined && category !== undefined) {
|
||||
await prisma.resolution.create({
|
||||
data: {
|
||||
title: ncr.description.length > 80 ? ncr.description.slice(0, 80) + '…' : ncr.description,
|
||||
category, resolution, linkedRef: ncr.ref,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Confirm-and-notify: emails/notifies whoever raised the NCR
|
||||
if (confirmNotify && ncr.raisedBy) {
|
||||
await notifySolutionConfirmed(ncr.ref, ncr.description, ncr.raisedBy.email, ncr.raisedBy.id)
|
||||
}
|
||||
|
||||
await logAction(user.id, 'UPDATE', 'NCR', id, before, updated)
|
||||
return res.json({ data: updated })
|
||||
}
|
||||
|
||||
res.status(405).end()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireAuth, logAction, generateRef } from '@/lib/auth'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await requireAuth(req, res, ['ADMIN', 'QC'])
|
||||
if (!user) return
|
||||
if (req.method !== 'POST') return res.status(405).end()
|
||||
|
||||
const { id } = req.query as { id: string }
|
||||
const { title, priority, ownerId, dueDate } = req.body
|
||||
|
||||
if (!title || !ownerId || !dueDate) {
|
||||
return res.status(400).json({ error: 'title, ownerId, dueDate required' })
|
||||
}
|
||||
|
||||
const ncr = await prisma.nCR.findUnique({ where: { id } })
|
||||
if (!ncr) return res.status(404).json({ error: 'NCR not found' })
|
||||
|
||||
const count = await prisma.cAPA.count()
|
||||
const ref = generateRef('CAPA', count)
|
||||
|
||||
const capa = await prisma.cAPA.create({
|
||||
data: {
|
||||
ref, title,
|
||||
description: `Escalated from ${ncr.ref}: ${ncr.description}`,
|
||||
priority: priority || 'MEDIUM',
|
||||
ownerId, raisedById: user.id,
|
||||
dueDate: new Date(dueDate),
|
||||
},
|
||||
include: { owner: { select: { id: true, name: true, email: true } } },
|
||||
})
|
||||
|
||||
await prisma.cAPAEvent.create({
|
||||
data: { capaId: capa.id, event: 'CAPA raised', note: `Escalated from ${ncr.ref} by ${user.name}` }
|
||||
})
|
||||
|
||||
const updatedNcr = await prisma.nCR.update({
|
||||
where: { id },
|
||||
data: { status: 'ESCALATED', capaId: capa.id },
|
||||
include: { raisedBy: { select: { name: true } }, capa: { select: { ref: true } } },
|
||||
})
|
||||
|
||||
await logAction(user.id, 'UPDATE', 'NCR', id, { status: ncr.status }, { status: 'ESCALATED', capaRef: capa.ref })
|
||||
await logAction(user.id, 'CREATE', 'CAPA', capa.id, null, { ref: capa.ref, fromNcr: ncr.ref })
|
||||
|
||||
return res.status(201).json({ data: { ncr: updatedNcr, capa } })
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireAuth, logAction, generateRef } from '@/lib/auth'
|
||||
import { NCRStatus } from '@prisma/client'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await requireAuth(req, res)
|
||||
if (!user) return
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const { status } = req.query
|
||||
const where: any = {}
|
||||
if (status) where.status = status as NCRStatus
|
||||
|
||||
const ncrs = await prisma.nCR.findMany({
|
||||
where,
|
||||
include: { raisedBy: { select: { name: true } }, capa: { select: { ref: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return res.json({ data: ncrs })
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
// Any authenticated user can report an issue (production intake)
|
||||
const { description, source } = req.body
|
||||
if (!description) return res.status(400).json({ error: 'description required' })
|
||||
|
||||
const count = await prisma.nCR.count()
|
||||
const ref = generateRef('NCR', count)
|
||||
|
||||
const ncr = await prisma.nCR.create({
|
||||
data: { ref, description, source, raisedById: user.id },
|
||||
include: { raisedBy: { select: { name: true } } },
|
||||
})
|
||||
|
||||
await logAction(user.id, 'CREATE', 'NCR', ncr.id, null, { ref, description })
|
||||
return res.status(201).json({ data: ncr })
|
||||
}
|
||||
|
||||
res.status(405).end()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireAuth } from '@/lib/auth'
|
||||
|
||||
const STOPWORDS = new Set(['from', 'with', 'during', 'this', 'that', 'were', 'found', 'units', 'batch', 'final', 'client', 'reported'])
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await requireAuth(req, res)
|
||||
if (!user) return
|
||||
if (req.method !== 'GET') return res.status(405).end()
|
||||
|
||||
const { category, search, similarTo } = req.query
|
||||
|
||||
// Similar-fix matching: given a description, find resolutions sharing keywords
|
||||
if (similarTo) {
|
||||
const words = (similarTo as string)
|
||||
.toLowerCase()
|
||||
.match(/\b\w{4,}\b/g)?.filter(w => !STOPWORDS.has(w)) || []
|
||||
|
||||
if (words.length === 0) return res.json({ data: [] })
|
||||
|
||||
const all = await prisma.resolution.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
const matches = all.filter(r => {
|
||||
const text = (r.title + ' ' + r.resolution).toLowerCase()
|
||||
return words.some(w => text.includes(w))
|
||||
})
|
||||
return res.json({ data: matches.slice(0, 3) })
|
||||
}
|
||||
|
||||
const where: any = {}
|
||||
if (category) where.category = category as string
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search as string, mode: 'insensitive' } },
|
||||
{ resolution: { contains: search as string, mode: 'insensitive' } },
|
||||
{ category: { contains: search as string, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const resolutions = await prisma.resolution.findMany({ where, orderBy: { createdAt: 'desc' } })
|
||||
return res.json({ data: resolutions })
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireAuth, logAction, SHIPMENT_SEND_ROLES } from '@/lib/auth'
|
||||
import { sendEmail, emailTemplate } from '@/lib/email'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await requireAuth(req, res, SHIPMENT_SEND_ROLES)
|
||||
if (!user) return
|
||||
if (req.method !== 'POST') return res.status(405).end()
|
||||
|
||||
const { id } = req.query as { id: string }
|
||||
const { clientEmail, subject, message } = req.body
|
||||
|
||||
if (!clientEmail || !subject || !message) {
|
||||
return res.status(400).json({ error: 'clientEmail, subject, message required' })
|
||||
}
|
||||
|
||||
const shipment = await prisma.shipment.findUnique({ where: { id }, include: { items: true } })
|
||||
if (!shipment) return res.status(404).json({ error: 'Shipment not found' })
|
||||
|
||||
const itemsHtml = shipment.items.filter(i => i.included).map(i => `<li>${i.label}</li>`).join('')
|
||||
await sendEmail(clientEmail, subject, emailTemplate('Quality Release Package', `
|
||||
<p>${message.replace(/\n/g, '<br>')}</p>
|
||||
<ul>${itemsHtml}</ul>
|
||||
`))
|
||||
|
||||
const updated = await prisma.shipment.update({
|
||||
where: { id },
|
||||
data: { sentAt: new Date(), sentTo: clientEmail },
|
||||
})
|
||||
|
||||
await logAction(user.id, 'UPDATE', 'Shipment', id, { sentAt: null }, { sentAt: updated.sentAt, sentTo: clientEmail })
|
||||
|
||||
return res.json({ data: updated })
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireAuth, logAction, generateRef } from '@/lib/auth'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await requireAuth(req, res)
|
||||
if (!user) return
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const shipments = await prisma.shipment.findMany({
|
||||
include: { items: { orderBy: { order: 'asc' } }, _count: { select: { escapes: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return res.json({ data: shipments })
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const { product, batch, client, clientEmail, shippedAt, items } = req.body
|
||||
if (!product || !batch || !client || !shippedAt) {
|
||||
return res.status(400).json({ error: 'product, batch, client, shippedAt required' })
|
||||
}
|
||||
|
||||
const count = await prisma.shipment.count()
|
||||
const ref = generateRef('REL', count)
|
||||
|
||||
const shipment = await prisma.shipment.create({
|
||||
data: {
|
||||
ref, product, batch, client, clientEmail,
|
||||
shippedAt: new Date(shippedAt),
|
||||
createdById: user.id,
|
||||
items: {
|
||||
create: (items || []).map((it: any, i: number) => ({
|
||||
label: it.label, type: it.type || 'OTHER', included: it.included !== false, order: i,
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: { items: { orderBy: { order: 'asc' } } },
|
||||
})
|
||||
|
||||
await logAction(user.id, 'CREATE', 'Shipment', shipment.id, null, { ref, product, batch })
|
||||
return res.status(201).json({ data: shipment })
|
||||
}
|
||||
|
||||
res.status(405).end()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireAuth } from '@/lib/auth'
|
||||
|
||||
// Auto-suggests release items tied to a product: recent first-build form
|
||||
// submission counts for that product, plus recently resolved NCR fixes.
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await requireAuth(req, res)
|
||||
if (!user) return
|
||||
if (req.method !== 'GET') return res.status(405).end()
|
||||
|
||||
const { product } = req.query as { product?: string }
|
||||
const items: { label: string; type: string; included: boolean }[] = []
|
||||
|
||||
if (product) {
|
||||
const forms = await prisma.buildForm.findMany({
|
||||
where: { product: { contains: product, mode: 'insensitive' } },
|
||||
include: { _count: { select: { submissions: true } } },
|
||||
})
|
||||
for (const f of forms) {
|
||||
if (f._count.submissions > 0) {
|
||||
items.push({ label: `${f.name} — ${f._count.submissions} submissions`, type: 'FORM_DATA', included: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recently resolved NCRs (last 90 days) as candidate fixes to include
|
||||
const since = new Date()
|
||||
since.setDate(since.getDate() - 90)
|
||||
const recentNcrs = await prisma.nCR.findMany({
|
||||
where: { status: 'RESOLVED', resolvedAt: { gte: since } },
|
||||
orderBy: { resolvedAt: 'desc' },
|
||||
take: 5,
|
||||
})
|
||||
for (const n of recentNcrs) {
|
||||
items.push({ label: `${n.ref} fix — ${n.category || 'resolved'}`, type: 'NCR_FIX', included: false })
|
||||
}
|
||||
|
||||
return res.json({ data: items })
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireAuth, logAction } from '@/lib/auth'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await requireAuth(req, res)
|
||||
if (!user) return
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const items = await prisma.shippingStandardItem.findMany({ orderBy: { order: 'asc' } })
|
||||
return res.json({ data: items })
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
if (user.role !== 'ADMIN' && user.role !== 'QC') return res.status(403).json({ error: 'Admin/QC only' })
|
||||
const { text, source } = req.body
|
||||
if (!text) return res.status(400).json({ error: 'text required' })
|
||||
|
||||
const maxOrder = await prisma.shippingStandardItem.count()
|
||||
const item = await prisma.shippingStandardItem.create({
|
||||
data: { text, source: source || 'Baseline', order: maxOrder },
|
||||
})
|
||||
|
||||
await logAction(user.id, 'CREATE', 'ShippingStandardItem', item.id, null, { text })
|
||||
return res.status(201).json({ data: item })
|
||||
}
|
||||
|
||||
res.status(405).end()
|
||||
}
|
||||
Reference in New Issue
Block a user