init-source
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
import { Input, Textarea } from '@/components/ui'
|
||||
import { FieldType } from '@/types'
|
||||
|
||||
export interface FormFieldDef {
|
||||
id: string
|
||||
label: string
|
||||
type: FieldType
|
||||
hint?: string | null
|
||||
options: string[]
|
||||
required: boolean
|
||||
}
|
||||
|
||||
export function FieldRenderer({ field, value, onChange }: {
|
||||
field: FormFieldDef
|
||||
value: any
|
||||
onChange: (v: any) => void
|
||||
}) {
|
||||
switch (field.type) {
|
||||
case 'SHORT_TEXT':
|
||||
return <Input value={value || ''} onChange={e => onChange(e.target.value)} placeholder={field.hint || ''}/>
|
||||
|
||||
case 'LONG_TEXT':
|
||||
return <Textarea value={value || ''} onChange={e => onChange(e.target.value)} placeholder={field.hint || ''}/>
|
||||
|
||||
case 'NUMBER':
|
||||
return <Input type="number" value={value ?? ''} onChange={e => onChange(e.target.value)} style={{ maxWidth: '160px' }}/>
|
||||
|
||||
case 'DATE':
|
||||
return <Input type="date" value={value || ''} onChange={e => onChange(e.target.value)} style={{ maxWidth: '180px' }}/>
|
||||
|
||||
case 'SINGLE_CHOICE':
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{field.options.map(opt => (
|
||||
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', cursor: 'pointer' }}>
|
||||
<input type="radio" name={field.id} checked={value === opt} onChange={() => onChange(opt)} style={{ accentColor: '#534AB7' }}/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'MULTI_CHOICE':
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{field.options.map(opt => (
|
||||
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={(value || []).includes(opt)}
|
||||
onChange={e => {
|
||||
const cur = value || []
|
||||
onChange(e.target.checked ? [...cur, opt] : cur.filter((x: string) => x !== opt))
|
||||
}} style={{ accentColor: '#534AB7' }}/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'RATING':
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<span key={n} onClick={() => onChange(n)} style={{ fontSize: '24px', cursor: 'pointer', color: (value || 0) >= n ? '#EF9F27' : '#ddd' }}>★</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'PHOTO':
|
||||
return <Input type="file" accept="image/*" onChange={e => onChange(e.target.files?.[0]?.name || '')}/>
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function FormFieldList({ fields, values, onChange }: {
|
||||
fields: FormFieldDef[]
|
||||
values: Record<string, any>
|
||||
onChange: (fieldId: string, value: any) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{fields.map(field => (
|
||||
<div key={field.id} style={{ marginBottom: '14px' }}>
|
||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
|
||||
{field.label}
|
||||
{field.required && <span style={{ color: '#E24B4A', marginLeft: '2px' }}>*</span>}
|
||||
</label>
|
||||
{field.hint && <div style={{ fontSize: '10px', color: '#aaa', marginBottom: '4px' }}>{field.hint}</div>}
|
||||
<FieldRenderer field={field} value={values[field.id]} onChange={v => onChange(field.id, v)}/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Layout from '@/components/layout/Layout'
|
||||
import { Card, EmptyState, Btn, Modal, Field, Input, Select, Textarea, showToast, Tag, StatusDot, Table } from '@/components/ui'
|
||||
import { FormFieldList, FormFieldDef } from '@/components/forms/FieldRenderer'
|
||||
import { FieldType } from '@/types'
|
||||
|
||||
const FIELD_TYPES: { value: FieldType; label: string }[] = [
|
||||
{ value: 'SHORT_TEXT', label: 'Short text' },
|
||||
{ value: 'LONG_TEXT', label: 'Long text' },
|
||||
{ value: 'NUMBER', label: 'Number' },
|
||||
{ value: 'DATE', label: 'Date' },
|
||||
{ value: 'SINGLE_CHOICE', label: 'Single choice' },
|
||||
{ value: 'MULTI_CHOICE', label: 'Multi-choice' },
|
||||
{ value: 'RATING', label: 'Rating (1–5)' },
|
||||
{ value: 'PHOTO', label: 'Photo / file' },
|
||||
]
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
DRAFT: 'purple', ACTIVE: 'green', SUSPENDED: 'amber',
|
||||
REVIEW_READY: 'blue', STANDARD_SET: 'teal', ARCHIVED: 'gray',
|
||||
}
|
||||
|
||||
interface BuilderField extends FormFieldDef {
|
||||
order: number
|
||||
}
|
||||
|
||||
export default function FormBuilderPage() {
|
||||
const [forms, setForms] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Builder modal
|
||||
const [builderOpen, setBuilderOpen] = useState(false)
|
||||
const [builderMode, setBuilderMode] = useState<'create' | 'edit'>('create')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formMeta, setFormMeta] = useState({ name: '', product: '', description: '', minSubmissions: 10 })
|
||||
const [fields, setFields] = useState<BuilderField[]>([])
|
||||
const [newField, setNewField] = useState({ label: '', type: 'SHORT_TEXT' as FieldType, hint: '', required: false, options: '' })
|
||||
|
||||
// Preview modal
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [previewFields, setPreviewFields] = useState<FormFieldDef[]>([])
|
||||
const [previewTitle, setPreviewTitle] = useState('')
|
||||
const [previewSubtitle, setPreviewSubtitle] = useState('')
|
||||
const [previewAnswers, setPreviewAnswers] = useState<Record<string, any>>({})
|
||||
|
||||
useEffect(() => { loadForms() }, [])
|
||||
|
||||
async function loadForms() {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/forms')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setForms(data || [])
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// ── Builder open/close ──────────────────────────────────────────────────
|
||||
function openCreate() {
|
||||
setFormMeta({ name: '', product: '', description: '', minSubmissions: 10 })
|
||||
setFields([])
|
||||
setNewField({ label: '', type: 'SHORT_TEXT', hint: '', required: false, options: '' })
|
||||
setEditingId(null)
|
||||
setBuilderMode('create')
|
||||
setBuilderOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(form: any) {
|
||||
setFormMeta({
|
||||
name: form.name,
|
||||
product: form.product || '',
|
||||
description: form.description || '',
|
||||
minSubmissions: form.minSubmissions,
|
||||
})
|
||||
setFields((form.fields || []).map((f: any) => ({
|
||||
id: f.id, label: f.label, type: f.type, hint: f.hint || '',
|
||||
required: !!f.required, options: f.options || [], order: f.order ?? 0,
|
||||
})))
|
||||
setNewField({ label: '', type: 'SHORT_TEXT', hint: '', required: false, options: '' })
|
||||
setEditingId(form.id)
|
||||
setBuilderMode('edit')
|
||||
setBuilderOpen(true)
|
||||
}
|
||||
|
||||
// ── Field add/remove ─────────────────────────────────────────────────────
|
||||
function addField() {
|
||||
if (!newField.label) return
|
||||
const opts = newField.options.split('\n').map(o => o.trim()).filter(Boolean)
|
||||
setFields(f => [...f, { ...newField, options: opts, order: f.length, id: `new_${Date.now()}` }])
|
||||
setNewField({ label: '', type: 'SHORT_TEXT', hint: '', required: false, options: '' })
|
||||
}
|
||||
|
||||
function removeField(index: number) {
|
||||
setFields(fs => fs.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
// ── Save (create or edit) ───────────────────────────────────────────────
|
||||
async function saveForm() {
|
||||
if (!formMeta.name) return
|
||||
setSaving(true)
|
||||
const url = builderMode === 'edit' ? `/api/forms/${editingId}` : '/api/forms'
|
||||
const method = builderMode === 'edit' ? 'PATCH' : 'POST'
|
||||
const res = await fetch(url, {
|
||||
method, headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...formMeta, fields }),
|
||||
})
|
||||
setSaving(false)
|
||||
if (res.ok) {
|
||||
setBuilderOpen(false)
|
||||
showToast(builderMode === 'edit' ? 'Form updated' : 'Form created — saved as draft')
|
||||
loadForms()
|
||||
} else {
|
||||
showToast('Failed to save form', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status transitions ──────────────────────────────────────────────────
|
||||
async function setStatus(id: string, status: string, successMsg: string) {
|
||||
const res = await fetch(`/api/forms/${id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
if (res.ok) { showToast(successMsg); loadForms() }
|
||||
else showToast('Update failed', 'error')
|
||||
}
|
||||
|
||||
// ── Clone ────────────────────────────────────────────────────────────────
|
||||
async function cloneForm(id: string) {
|
||||
const res = await fetch(`/api/forms/${id}/clone`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
showToast(`Cloned as "${data.name}" — edit to customize`)
|
||||
loadForms()
|
||||
} else {
|
||||
showToast('Clone failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preview ──────────────────────────────────────────────────────────────
|
||||
function openPreview(fieldsToShow: FormFieldDef[], title: string, subtitle?: string) {
|
||||
setPreviewFields(fieldsToShow)
|
||||
setPreviewTitle(title || 'Untitled form')
|
||||
setPreviewSubtitle(subtitle || '')
|
||||
setPreviewAnswers({})
|
||||
setPreviewOpen(true)
|
||||
}
|
||||
|
||||
const needsOptions = ['SINGLE_CHOICE', 'MULTI_CHOICE'].includes(newField.type)
|
||||
|
||||
return (
|
||||
<Layout title="Form builder">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: '500', margin: 0 }}>First build form builder</h2>
|
||||
<p style={{ fontSize: '11px', color: '#aaa', margin: '2px 0 0' }}>Admin only — design, edit, suspend, clone, and archive data collection forms</p>
|
||||
</div>
|
||||
<Btn onClick={openCreate}>+ New form</Btn>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '32px', color: '#aaa', fontSize: '12px' }}>Loading…</div>
|
||||
) : forms.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No forms yet"
|
||||
message="Create your first data collection form. Production teams will fill it out on the shop floor to help establish quality standards."
|
||||
action={{ label: '+ Build first form', onClick: openCreate }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<Table headers={['Form name', 'Product', 'Fields', 'Submissions', 'Target', 'Status', 'Actions']}>
|
||||
{forms.map((f: any) => (
|
||||
<tr key={f.id}>
|
||||
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5' }}>
|
||||
<div style={{ fontWeight: '500', fontSize: '12px' }}>{f.name}</div>
|
||||
{f.clonedFromName && <div style={{ fontSize: '10px', color: '#aaa', marginTop: '1px' }}>Cloned from {f.clonedFromName}</div>}
|
||||
</td>
|
||||
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', color: '#888' }}>{f.product || '—'}</td>
|
||||
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px' }}>{f._count?.fields || 0}</td>
|
||||
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', fontWeight: '500', color: f._count?.submissions >= f.minSubmissions ? '#1D9E75' : '#333' }}>{f._count?.submissions || 0}</td>
|
||||
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', color: '#888' }}>{f.minSubmissions}</td>
|
||||
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', whiteSpace: 'nowrap' }}>
|
||||
<StatusDot status={f.status}/>
|
||||
<Tag color={STATUS_COLOR[f.status]}>{f.status.replace('_', ' ')}</Tag>
|
||||
</td>
|
||||
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', minWidth: '220px' }}>
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
<Btn size="sm" variant="ghost" onClick={() => openPreview(f.fields || [], f.name, f.product)}>Preview</Btn>
|
||||
<Btn size="sm" variant="ghost" onClick={() => openEdit(f)}>Edit</Btn>
|
||||
<Btn size="sm" variant="ghost" onClick={() => cloneForm(f.id)}>Clone</Btn>
|
||||
{f.status === 'DRAFT' && (
|
||||
<Btn size="sm" onClick={() => setStatus(f.id, 'ACTIVE', 'Form published — production team can now fill it')}>Publish</Btn>
|
||||
)}
|
||||
{f.status === 'ACTIVE' && (
|
||||
<Btn size="sm" variant="ghost" onClick={() => setStatus(f.id, 'SUSPENDED', 'Form suspended')}>Suspend</Btn>
|
||||
)}
|
||||
{f.status === 'SUSPENDED' && (
|
||||
<Btn size="sm" onClick={() => setStatus(f.id, 'ACTIVE', 'Form reactivated')}>Reactivate</Btn>
|
||||
)}
|
||||
{f.status === 'ARCHIVED' ? (
|
||||
<Btn size="sm" variant="ghost" onClick={() => setStatus(f.id, 'DRAFT', 'Form restored to draft')}>Restore</Btn>
|
||||
) : (
|
||||
<Btn size="sm" variant="ghost" onClick={() => setStatus(f.id, 'ARCHIVED', 'Form archived')}>Archive</Btn>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Builder Modal (create / edit) */}
|
||||
<Modal open={builderOpen} onClose={() => setBuilderOpen(false)} title={builderMode === 'edit' ? `Edit form — ${formMeta.name || ''}` : 'Build new form'} width={640}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', fontWeight: '500', color: '#aaa', textTransform: 'uppercase', letterSpacing: '0.6px', marginBottom: '10px' }}>Form details</div>
|
||||
<Field label="Form name" required><Input value={formMeta.name} onChange={e => setFormMeta(m => ({ ...m, name: e.target.value }))} placeholder="First Build Data Sheet — Line 3"/></Field>
|
||||
<Field label="Product / assembly"><Input value={formMeta.product} onChange={e => setFormMeta(m => ({ ...m, product: e.target.value }))} placeholder="Widget A — Rev 2"/></Field>
|
||||
<Field label="Description"><Textarea value={formMeta.description} onChange={e => setFormMeta(m => ({ ...m, description: e.target.value }))} style={{ minHeight: '52px' }} placeholder="What data does this form collect?"/></Field>
|
||||
<Field label="Min. submissions before QC review"><Input type="number" value={formMeta.minSubmissions} min={1} onChange={e => setFormMeta(m => ({ ...m, minSubmissions: parseInt(e.target.value) || 1 }))}/></Field>
|
||||
|
||||
<div style={{ fontSize: '11px', fontWeight: '500', color: '#aaa', textTransform: 'uppercase', letterSpacing: '0.6px', marginBottom: '10px', marginTop: '6px' }}>Add field</div>
|
||||
<Field label="Field label" required><Input value={newField.label} onChange={e => setNewField(f => ({ ...f, label: e.target.value }))} placeholder="e.g. Torque reading (Nm)"/></Field>
|
||||
<Field label="Field type">
|
||||
<Select value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value as FieldType }))}>
|
||||
{FIELD_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
{needsOptions && (
|
||||
<Field label="Options (one per line)">
|
||||
<Textarea value={newField.options} onChange={e => setNewField(f => ({ ...f, options: e.target.value }))} style={{ minHeight: '60px' }} placeholder={'Pass\nFail\nRework'}/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Helper hint"><Input value={newField.hint} onChange={e => setNewField(f => ({ ...f, hint: e.target.value }))} placeholder="Instruction shown to filler"/></Field>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '7px', fontSize: '12px', cursor: 'pointer', marginBottom: '10px' }}>
|
||||
<input type="checkbox" checked={newField.required} onChange={e => setNewField(f => ({ ...f, required: e.target.checked }))} style={{ accentColor: '#534AB7' }}/>
|
||||
Required field
|
||||
</label>
|
||||
<Btn variant="ghost" onClick={addField} style={{ width: '100%', justifyContent: 'center' }}>+ Add field</Btn>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', fontWeight: '500', color: '#aaa', textTransform: 'uppercase', letterSpacing: '0.6px', marginBottom: '10px' }}>Fields ({fields.length})</div>
|
||||
{fields.length === 0 ? (
|
||||
<div style={{ border: '0.5px dashed #ddd', borderRadius: '8px', padding: '24px', textAlign: 'center', color: '#bbb', fontSize: '11px' }}>
|
||||
Add fields from the left
|
||||
</div>
|
||||
) : fields.map((f, i) => (
|
||||
<div key={f.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '8px 10px', border: `0.5px solid ${f.required ? '#AFA9EC' : '#eee'}`,
|
||||
borderRadius: '8px', marginBottom: '5px', background: f.required ? '#FAFAFE' : 'white'
|
||||
}}>
|
||||
<span style={{ fontSize: '10px', color: '#bbb', width: '16px', textAlign: 'center' }}>{i + 1}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '12px', fontWeight: '500' }}>{f.label}{f.required && <span style={{ color: '#E24B4A', marginLeft: '2px' }}>*</span>}</div>
|
||||
<div style={{ fontSize: '10px', color: '#aaa' }}>{FIELD_TYPES.find(t => t.value === f.type)?.label}</div>
|
||||
</div>
|
||||
<button onClick={() => removeField(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ddd', fontSize: '16px' }} aria-label="Remove field">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '16px', paddingTop: '14px', borderTop: '0.5px solid #eee' }}>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
onClick={() => openPreview(fields, formMeta.name || 'Untitled form', formMeta.product)}
|
||||
disabled={fields.length === 0}
|
||||
>
|
||||
Preview form
|
||||
</Btn>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Btn variant="ghost" onClick={() => setBuilderOpen(false)}>Cancel</Btn>
|
||||
<Btn onClick={saveForm} disabled={saving || !formMeta.name}>
|
||||
{saving ? 'Saving…' : builderMode === 'edit' ? 'Save changes' : 'Create form (saved as draft)'}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Preview Modal */}
|
||||
<Modal open={previewOpen} onClose={() => setPreviewOpen(false)} title={`Preview — ${previewTitle}`} width={480}>
|
||||
<div style={{ background: '#EEEDFE', borderRadius: '8px', padding: '9px 12px', fontSize: '11px', color: '#3C3489', marginBottom: '14px' }}>
|
||||
Preview mode — interact freely. Nothing here is saved. This is exactly what the production team will see.
|
||||
</div>
|
||||
{previewSubtitle && <div style={{ fontSize: '11px', color: '#aaa', marginBottom: '12px' }}>{previewSubtitle}</div>}
|
||||
|
||||
{previewFields.length === 0 ? (
|
||||
<EmptyState title="No fields yet" message="Add fields in the builder, then preview here."/>
|
||||
) : (
|
||||
<FormFieldList
|
||||
fields={previewFields}
|
||||
values={previewAnswers}
|
||||
onChange={(fieldId, value) => setPreviewAnswers(a => ({ ...a, [fieldId]: value }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '12px', paddingTop: '12px', borderTop: '0.5px solid #eee' }}>
|
||||
<Btn variant="ghost" onClick={() => setPreviewAnswers({})}>Reset answers</Btn>
|
||||
<Btn variant="ghost" onClick={() => setPreviewOpen(false)} style={{ marginLeft: 'auto' }}>Close preview</Btn>
|
||||
</div>
|
||||
</Modal>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireAuth, logAction } from '@/lib/auth'
|
||||
import { FormStatus } from '@prisma/client'
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await requireAuth(req, res)
|
||||
if (!user) return
|
||||
|
||||
const { id } = req.query as { id: string }
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const form = await prisma.buildForm.findUnique({
|
||||
where: { id },
|
||||
include: { fields: { orderBy: { order: 'asc' } }, _count: { select: { submissions: true, fields: true } } },
|
||||
})
|
||||
if (!form) return res.status(404).json({ error: 'Not found' })
|
||||
return res.json({ data: form })
|
||||
}
|
||||
|
||||
if (req.method === 'PATCH') {
|
||||
if (user.role !== 'ADMIN') return res.status(403).json({ error: 'Admin only' })
|
||||
|
||||
const before = await prisma.buildForm.findUnique({ where: { id }, include: { fields: true } })
|
||||
if (!before) return res.status(404).json({ error: 'Not found' })
|
||||
|
||||
const { fields, status, ...rest } = req.body as {
|
||||
fields?: Array<{ id?: string; label: string; type: string; hint?: string; options?: string[]; required?: boolean; trackStd?: boolean }>
|
||||
status?: FormStatus
|
||||
name?: string; product?: string; description?: string; minSubmissions?: number; publishedAt?: string
|
||||
}
|
||||
|
||||
// ── Full field-level edit (name/product/description/minSubmissions + fields array) ──
|
||||
if (fields !== undefined) {
|
||||
const existingIds = new Set(before.fields.map(f => f.id))
|
||||
const keepIds = new Set(fields.filter(f => f.id && existingIds.has(f.id)).map(f => f.id as string))
|
||||
const toDeleteIds = before.fields.filter(f => !keepIds.has(f.id)).map(f => f.id)
|
||||
|
||||
const ops: any[] = []
|
||||
|
||||
if (toDeleteIds.length > 0) {
|
||||
ops.push(prisma.formField.deleteMany({ where: { id: { in: toDeleteIds } } }))
|
||||
}
|
||||
|
||||
fields.forEach((f, i) => {
|
||||
const data = {
|
||||
label: f.label,
|
||||
type: f.type as any,
|
||||
hint: f.hint || null,
|
||||
options: f.options || [],
|
||||
required: !!f.required,
|
||||
trackStd: f.trackStd !== false,
|
||||
order: i,
|
||||
}
|
||||
if (f.id && existingIds.has(f.id)) {
|
||||
ops.push(prisma.formField.update({ where: { id: f.id }, data }))
|
||||
} else {
|
||||
ops.push(prisma.formField.create({ data: { ...data, formId: id } }))
|
||||
}
|
||||
})
|
||||
|
||||
const formUpdate: any = {}
|
||||
if (rest.name !== undefined) formUpdate.name = rest.name
|
||||
if (rest.product !== undefined) formUpdate.product = rest.product
|
||||
if (rest.description !== undefined) formUpdate.description = rest.description
|
||||
if (rest.minSubmissions !== undefined) formUpdate.minSubmissions = rest.minSubmissions
|
||||
|
||||
if (Object.keys(formUpdate).length > 0) {
|
||||
ops.push(prisma.buildForm.update({ where: { id }, data: formUpdate }))
|
||||
}
|
||||
|
||||
await prisma.$transaction(ops)
|
||||
|
||||
const updated = await prisma.buildForm.findUnique({
|
||||
where: { id },
|
||||
include: { fields: { orderBy: { order: 'asc' } }, _count: { select: { submissions: true, fields: true } } },
|
||||
})
|
||||
|
||||
await logAction(user.id, 'UPDATE', 'BuildForm', id, before, updated)
|
||||
return res.json({ data: updated })
|
||||
}
|
||||
|
||||
// ── Status transitions (publish / suspend / reactivate / archive / restore) ──
|
||||
const updateData: any = { ...rest }
|
||||
if (status !== undefined) {
|
||||
updateData.status = status
|
||||
if (status === 'ACTIVE' && before.status === 'DRAFT') updateData.publishedAt = new Date()
|
||||
if (status === 'ACTIVE' && before.status === 'SUSPENDED') updateData.suspendedAt = null
|
||||
if (status === 'SUSPENDED') updateData.suspendedAt = new Date()
|
||||
if (status === 'ARCHIVED') updateData.archivedAt = new Date()
|
||||
if (status === 'DRAFT' && before.status === 'ARCHIVED') updateData.archivedAt = null
|
||||
}
|
||||
|
||||
const updated = await prisma.buildForm.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: { fields: { orderBy: { order: 'asc' } }, _count: { select: { submissions: true, fields: true } } },
|
||||
})
|
||||
|
||||
await logAction(user.id, 'UPDATE', 'BuildForm', id, { status: before.status }, { status: updated.status })
|
||||
return res.json({ data: updated })
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
if (user.role !== 'ADMIN') return res.status(403).json({ error: 'Admin only' })
|
||||
await prisma.buildForm.delete({ where: { id } })
|
||||
await logAction(user.id, 'DELETE', 'BuildForm', id, null, null)
|
||||
return res.json({ ok: true })
|
||||
}
|
||||
|
||||
res.status(405).end()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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, ['ADMIN'])
|
||||
if (!user) return
|
||||
if (req.method !== 'POST') return res.status(405).end()
|
||||
|
||||
const { id } = req.query as { id: string }
|
||||
|
||||
const source = await prisma.buildForm.findUnique({
|
||||
where: { id },
|
||||
include: { fields: { orderBy: { order: 'asc' } } },
|
||||
})
|
||||
if (!source) return res.status(404).json({ error: 'Form not found' })
|
||||
|
||||
const clone = await prisma.buildForm.create({
|
||||
data: {
|
||||
name: `${source.name} (Copy)`,
|
||||
product: source.product,
|
||||
description: source.description,
|
||||
minSubmissions: source.minSubmissions,
|
||||
status: 'DRAFT',
|
||||
createdById: user.id,
|
||||
clonedFromId: source.id,
|
||||
clonedFromName: source.name,
|
||||
fields: {
|
||||
create: source.fields.map(f => ({
|
||||
label: f.label,
|
||||
type: f.type,
|
||||
hint: f.hint,
|
||||
options: f.options,
|
||||
required: f.required,
|
||||
trackStd: f.trackStd,
|
||||
order: f.order,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
fields: { orderBy: { order: 'asc' } },
|
||||
_count: { select: { submissions: true, fields: true } },
|
||||
},
|
||||
})
|
||||
|
||||
await logAction(user.id, 'CREATE', 'BuildForm', clone.id, null, { clonedFrom: source.id, name: clone.name })
|
||||
|
||||
return res.status(201).json({ data: clone })
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Layout from '@/components/layout/Layout'
|
||||
import { Card, EmptyState, Btn, Tag, showToast } from '@/components/ui'
|
||||
import { FormFieldList } from '@/components/forms/FieldRenderer'
|
||||
import { useApp } from '@/lib/context'
|
||||
|
||||
export default function FillPage() {
|
||||
const { user } = useApp()
|
||||
const [forms, setForms] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selected, setSelected] = useState<any>(null)
|
||||
const [answers, setAnswers] = useState<Record<string, any>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
useEffect(() => { loadForms() }, [])
|
||||
|
||||
async function loadForms() {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/forms?status=ACTIVE')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setForms(data || [])
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
function selectForm(form: any) {
|
||||
setSelected(form)
|
||||
setAnswers({})
|
||||
setSubmitted(false)
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
const required = selected.fields.filter((f: any) => f.required)
|
||||
for (const f of required) {
|
||||
if (!answers[f.id]) {
|
||||
showToast(`"${f.label}" is required`, 'error'); return
|
||||
}
|
||||
}
|
||||
setSubmitting(true)
|
||||
const res = await fetch('/api/submissions', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ formId: selected.id, data: answers }),
|
||||
})
|
||||
setSubmitting(false)
|
||||
if (res.ok) {
|
||||
const { submissionCount } = await res.json()
|
||||
setSubmitted(true)
|
||||
showToast(`Submitted — you are contributor #${submissionCount}`)
|
||||
} else {
|
||||
showToast('Submission failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function setAnswer(fieldId: string, value: any) {
|
||||
setAnswers(a => ({ ...a, [fieldId]: value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title="My forms">
|
||||
{!selected ? (
|
||||
<>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: '500', margin: 0 }}>First build forms</h2>
|
||||
<p style={{ fontSize: '11px', color: '#aaa', margin: '2px 0 0' }}>Select a form to fill out — your data helps set quality standards</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#aaa', fontSize: '12px' }}>Loading…</div>
|
||||
) : forms.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No active forms"
|
||||
message="Your admin hasn't published any data collection forms yet. Check back soon."
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '12px' }}>
|
||||
{forms.map((form: any) => (
|
||||
<div key={form.id} onClick={() => selectForm(form)} style={{
|
||||
background: 'white', border: '0.5px solid #eee', borderRadius: '12px',
|
||||
padding: '16px', cursor: 'pointer', transition: 'border-color 0.1s'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<div style={{ width: '32px', height: '32px', borderRadius: '8px', background: '#EEEDFE', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#534AB7" strokeWidth="2">
|
||||
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<Tag color="green">Active</Tag>
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>{form.name}</div>
|
||||
{form.product && <div style={{ fontSize: '11px', color: '#aaa', marginBottom: '8px' }}>{form.product}</div>}
|
||||
<div style={{ fontSize: '11px', color: '#888' }}>{form.fields?.length || 0} fields · {form._count?.submissions || 0} submissions so far</div>
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<Btn size="sm" style={{ width: '100%', justifyContent: 'center' }}>Fill out →</Btn>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : submitted ? (
|
||||
<Card style={{ maxWidth: '480px', margin: '40px auto', textAlign: 'center', padding: '32px' }}>
|
||||
<div style={{ width: '48px', height: '48px', borderRadius: '50%', background: '#EAF3DE', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#1D9E75" strokeWidth="2.5"><path d="M5 13l4 4L19 7"/></svg>
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', marginBottom: '6px' }}>Submitted</div>
|
||||
<div style={{ fontSize: '12px', color: '#888', marginBottom: '20px' }}>Your data has been recorded and will contribute to setting quality standards for {selected.product || selected.name}.</div>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
|
||||
<Btn variant="ghost" onClick={() => { setSubmitted(false); setAnswers({}) }}>Fill again</Btn>
|
||||
<Btn onClick={() => setSelected(null)}>Back to forms</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div style={{ maxWidth: '560px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
|
||||
<button onClick={() => setSelected(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#aaa', padding: '4px', display: 'flex' }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 12H5m7-7l-7 7 7 7"/></svg>
|
||||
</button>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '15px', fontWeight: '500', margin: 0 }}>{selected.name}</h2>
|
||||
{selected.product && <div style={{ fontSize: '11px', color: '#aaa' }}>{selected.product}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div style={{ background: '#EEEDFE', borderRadius: '8px', padding: '9px 12px', fontSize: '11px', color: '#3C3489', marginBottom: '16px' }}>
|
||||
Fill in each field from your direct observation. Your submission is anonymous to QC — it's just your data point.
|
||||
</div>
|
||||
|
||||
<FormFieldList
|
||||
fields={(selected.fields || []).slice().sort((a: any, b: any) => a.order - b.order)}
|
||||
values={answers}
|
||||
onChange={setAnswer}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '20px', paddingTop: '16px', borderTop: '0.5px solid #eee' }}>
|
||||
<Btn variant="ghost" onClick={() => { setAnswers({}); }}>Clear</Btn>
|
||||
<Btn onClick={submitForm} disabled={submitting} style={{ flex: 1, justifyContent: 'center' }}>
|
||||
{submitting ? 'Submitting…' : 'Submit build data'}
|
||||
</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── USERS & AUTH ─────────────────────────────────────────────────────────────
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
QC
|
||||
PRODUCTION
|
||||
MANAGEMENT
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String
|
||||
password String
|
||||
role Role @default(PRODUCTION)
|
||||
department String?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sessions Session[]
|
||||
capasOwned CAPA[] @relation("CAPAOwner")
|
||||
capasRaised CAPA[] @relation("CAPARaisedBy")
|
||||
auditsLed Audit[] @relation("AuditLead")
|
||||
ncrsRaised NCR[] @relation("NCRRaisedBy")
|
||||
submissions FormSubmission[]
|
||||
auditLogs AuditLog[]
|
||||
notifications Notification[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
// ─── AUDIT TRAIL ──────────────────────────────────────────────────────────────
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
action String
|
||||
entity String
|
||||
entityId String
|
||||
before Json?
|
||||
after Json?
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([entity, entityId])
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// ─── NOTIFICATIONS ────────────────────────────────────────────────────────────
|
||||
|
||||
enum NotifType {
|
||||
CAPA_OVERDUE
|
||||
CAPA_ASSIGNED
|
||||
AUDIT_DUE
|
||||
NCR_ESCALATED
|
||||
FORM_REVIEW_READY
|
||||
DOC_EXPIRING
|
||||
STANDARD_APPROVED
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type NotifType
|
||||
title String
|
||||
body String
|
||||
read Boolean @default(false)
|
||||
link String?
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, read])
|
||||
}
|
||||
|
||||
// ─── CAPA ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum CAPAPriority {
|
||||
CRITICAL
|
||||
HIGH
|
||||
MEDIUM
|
||||
LOW
|
||||
}
|
||||
|
||||
enum CAPAStatus {
|
||||
OPEN
|
||||
IN_PROGRESS
|
||||
OVERDUE
|
||||
CLOSED
|
||||
}
|
||||
|
||||
model CAPA {
|
||||
id String @id @default(cuid())
|
||||
ref String @unique
|
||||
title String
|
||||
description String?
|
||||
priority CAPAPriority @default(MEDIUM)
|
||||
status CAPAStatus @default(OPEN)
|
||||
progress Int @default(0)
|
||||
ownerId String
|
||||
raisedById String
|
||||
dueDate DateTime
|
||||
closedAt DateTime?
|
||||
rootCause String?
|
||||
corrAction String?
|
||||
prevAction String?
|
||||
department String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
owner User @relation("CAPAOwner", fields: [ownerId], references: [id])
|
||||
raisedBy User @relation("CAPARaisedBy", fields: [raisedById], references: [id])
|
||||
timeline CAPAEvent[]
|
||||
|
||||
@@index([status])
|
||||
@@index([ownerId])
|
||||
@@index([dueDate])
|
||||
}
|
||||
|
||||
model CAPAEvent {
|
||||
id String @id @default(cuid())
|
||||
capaId String
|
||||
event String
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
capa CAPA @relation(fields: [capaId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([capaId])
|
||||
}
|
||||
|
||||
// ─── AUDITS ───────────────────────────────────────────────────────────────────
|
||||
|
||||
enum AuditType {
|
||||
INTERNAL
|
||||
SUPPLIER
|
||||
EXTERNAL
|
||||
}
|
||||
|
||||
enum AuditStatus {
|
||||
SCHEDULED
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
model Audit {
|
||||
id String @id @default(cuid())
|
||||
ref String @unique
|
||||
name String
|
||||
type AuditType @default(INTERNAL)
|
||||
status AuditStatus @default(SCHEDULED)
|
||||
leadId String
|
||||
scope String?
|
||||
scheduledAt DateTime
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
lead User @relation("AuditLead", fields: [leadId], references: [id])
|
||||
findings Finding[]
|
||||
|
||||
@@index([status])
|
||||
@@index([scheduledAt])
|
||||
}
|
||||
|
||||
enum FindingSeverity {
|
||||
OBSERVATION
|
||||
MINOR
|
||||
MAJOR
|
||||
CRITICAL
|
||||
}
|
||||
|
||||
enum FindingStatus {
|
||||
OPEN
|
||||
IN_PROGRESS
|
||||
CLOSED
|
||||
}
|
||||
|
||||
model Finding {
|
||||
id String @id @default(cuid())
|
||||
auditId String
|
||||
description String
|
||||
severity FindingSeverity @default(MINOR)
|
||||
status FindingStatus @default(OPEN)
|
||||
category String?
|
||||
dueDate DateTime?
|
||||
closedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
audit Audit @relation(fields: [auditId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([auditId])
|
||||
}
|
||||
|
||||
// ─── NCR ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum NCRSeverity {
|
||||
OBSERVATION
|
||||
MINOR
|
||||
MAJOR
|
||||
}
|
||||
|
||||
enum NCRStatus {
|
||||
OPEN
|
||||
INVESTIGATING
|
||||
ESCALATED
|
||||
RESOLVED
|
||||
}
|
||||
|
||||
model NCR {
|
||||
id String @id @default(cuid())
|
||||
ref String @unique
|
||||
description String
|
||||
source String?
|
||||
severity NCRSeverity @default(MINOR)
|
||||
status NCRStatus @default(OPEN)
|
||||
raisedById String
|
||||
resolvedAt DateTime?
|
||||
resolution String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
raisedBy User @relation("NCRRaisedBy", fields: [raisedById], references: [id])
|
||||
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// ─── DOCUMENTS ────────────────────────────────────────────────────────────────
|
||||
|
||||
enum DocStatus {
|
||||
CURRENT
|
||||
PENDING_REVIEW
|
||||
EXPIRED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum DocCategory {
|
||||
SOP
|
||||
POLICY
|
||||
FORM
|
||||
WORK_INSTRUCTION
|
||||
RECORD
|
||||
}
|
||||
|
||||
model Document {
|
||||
id String @id @default(cuid())
|
||||
ref String @unique
|
||||
title String
|
||||
category DocCategory @default(SOP)
|
||||
revision String @default("A")
|
||||
status DocStatus @default(CURRENT)
|
||||
fileUrl String?
|
||||
ownerId String?
|
||||
reviewDate DateTime?
|
||||
approvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status])
|
||||
@@index([category])
|
||||
}
|
||||
|
||||
// ─── RISK REGISTER ────────────────────────────────────────────────────────────
|
||||
|
||||
enum RiskLevel {
|
||||
LOW
|
||||
MEDIUM
|
||||
HIGH
|
||||
CRITICAL
|
||||
}
|
||||
|
||||
model Risk {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
likelihood Int @default(1)
|
||||
impact Int @default(1)
|
||||
score Int @default(1)
|
||||
level RiskLevel @default(LOW)
|
||||
owner String?
|
||||
controls String?
|
||||
reviewDate DateTime?
|
||||
accepted Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([level])
|
||||
}
|
||||
|
||||
// ─── SUPPLIERS ────────────────────────────────────────────────────────────────
|
||||
|
||||
enum SupplierStatus {
|
||||
APPROVED
|
||||
UNDER_REVIEW
|
||||
SUSPENDED
|
||||
INACTIVE
|
||||
}
|
||||
|
||||
enum SupplierTier {
|
||||
TIER_1
|
||||
TIER_2
|
||||
TIER_3
|
||||
}
|
||||
|
||||
model Supplier {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
category String?
|
||||
tier SupplierTier @default(TIER_2)
|
||||
status SupplierStatus @default(UNDER_REVIEW)
|
||||
score Float?
|
||||
contact String?
|
||||
email String?
|
||||
lastAudit DateTime?
|
||||
nextAudit DateTime?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
// ─── FIRST BUILD FORMS ────────────────────────────────────────────────────────
|
||||
|
||||
enum FieldType {
|
||||
SHORT_TEXT
|
||||
LONG_TEXT
|
||||
NUMBER
|
||||
DATE
|
||||
SINGLE_CHOICE
|
||||
MULTI_CHOICE
|
||||
RATING
|
||||
PHOTO
|
||||
}
|
||||
|
||||
enum FormStatus {
|
||||
DRAFT
|
||||
ACTIVE
|
||||
SUSPENDED
|
||||
REVIEW_READY
|
||||
STANDARD_SET
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
model BuildForm {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
product String?
|
||||
description String?
|
||||
status FormStatus @default(DRAFT)
|
||||
minSubmissions Int @default(10)
|
||||
createdById String?
|
||||
publishedAt DateTime?
|
||||
suspendedAt DateTime?
|
||||
archivedAt DateTime?
|
||||
clonedFromId String?
|
||||
clonedFromName String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
fields FormField[]
|
||||
submissions FormSubmission[]
|
||||
standard QualityStandard?
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model FormField {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
label String
|
||||
type FieldType @default(SHORT_TEXT)
|
||||
hint String?
|
||||
options String[]
|
||||
required Boolean @default(false)
|
||||
trackStd Boolean @default(true)
|
||||
order Int @default(0)
|
||||
form BuildForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([formId])
|
||||
}
|
||||
|
||||
model FormSubmission {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
submittedBy String
|
||||
data Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
form BuildForm @relation(fields: [formId], references: [id])
|
||||
user User @relation(fields: [submittedBy], references: [id])
|
||||
|
||||
@@index([formId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model QualityStandard {
|
||||
id String @id @default(cuid())
|
||||
formId String @unique
|
||||
title String
|
||||
specs Json
|
||||
status String @default("DRAFT")
|
||||
approvedBy String?
|
||||
approvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
form BuildForm @relation(fields: [formId], references: [id])
|
||||
}
|
||||
|
||||
// ─── SETTINGS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
model Setting {
|
||||
key String @id
|
||||
value String
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
Reference in New Issue
Block a user