enterprise-saa-s-dashboard-.../src/app/materials/register/page.tsx

317 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { ArrowLeft, Plus, Trash2, Upload, Info, CheckCircle, FileText, X, ScanLine } from 'lucide-react'
import { useApi } from '@/lib/hooks'
interface CategoryItem { id: number; name: string; status: string; has_calibration: number }
interface VersionItem { id: number; type: string; version: string; status: string }
interface BoardEntry {
id: number
type: string
version: string
sn: string
productionDate: string
status: string
calibStatus: string
calibFile: File | null
}
let nextId = 1
export default function BoardRegisterPage() {
const router = useRouter()
const { data: categoriesData } = useApi<CategoryItem[]>('/api/material-categories', [])
const { data: versionsData } = useApi<VersionItem[]>('/api/material-versions', [])
const typeOptions = categoriesData.filter(c => c.status === '启用').map(c => c.name)
// 根据分类名获取该分类下的版本列表
const getVersionsForType = (type: string) => versionsData.filter(v => v.type === type && v.status === '在产')
// 判断分类是否需要校准
const needsCalibration = (type: string) => !!categoriesData.find(c => c.name === type)?.has_calibration
function createEntry(): BoardEntry {
const defaultType = typeOptions[0] || ''
const versions = getVersionsForType(defaultType)
return { id: nextId++, type: defaultType, version: versions[0]?.version || '', sn: '', productionDate: '', status: '在库', calibStatus: '-', calibFile: null }
}
const [entries, setEntries] = useState<BoardEntry[]>([createEntry()])
const [batchMode, setBatchMode] = useState(false)
const addEntry = () => {
const defaultType = typeOptions[0] || ''
const versions = getVersionsForType(defaultType)
setEntries(prev => [...prev, { id: nextId++, type: defaultType, version: versions[0]?.version || '', sn: '', productionDate: '', status: '在库', calibStatus: '-', calibFile: null }])
}
const removeEntry = (id: number) => {
if (entries.length <= 1) return
setEntries(prev => prev.filter(e => e.id !== id))
}
const updateEntry = (id: number, field: keyof BoardEntry, value: string) => {
setEntries(prev => prev.map(e => {
if (e.id !== id) return e
const updated = { ...e, [field]: value }
// 切换类型时自动选第一个版本
if (field === 'type') {
const versions = getVersionsForType(value)
updated.version = versions[0]?.version || ''
if (needsCalibration(value)) { updated.calibStatus = '待校准' } else { updated.calibStatus = '-'; updated.calibFile = null }
}
return updated
}))
}
const fileInputRefs = useRef<Record<number, HTMLInputElement | null>>({})
const handleCalibFileChange = (entryId: number, file: File | null) => {
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, calibFile: file } : e))
}
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const isValid = entries.every(e => e.sn.trim() && e.productionDate)
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, padding: 24, paddingBottom: 80 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<button onClick={() => router.push('/materials')} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer' }}>
<ArrowLeft size={16} />
</button>
<div>
<h2 style={{ fontSize: 20, fontWeight: 600, margin: 0 }}></h2>
<p style={{ fontSize: 14, color: 'rgba(0,0,0,0.45)', margin: '4px 0 0' }}></p>
</div>
</div>
{/* Info Banner */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: 16, backgroundColor: '#eef5f0', borderRadius: 8, marginBottom: 24, border: '1px solid #a3c4ad' }}>
<Info size={18} style={{ color: '#4a7c59', flexShrink: 0, marginTop: 2 }} />
<div style={{ fontSize: 14, color: '#4a7c59', lineHeight: 1.6 }}>
SN号为唯一标识
</div>
</div>
{/* Mode Toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<span style={{ fontSize: 14, color: 'rgba(0,0,0,0.65)' }}></span>
<div style={{ display: 'flex', gap: 0, borderRadius: 6, overflow: 'hidden', border: '1px solid #D9D9D9' }}>
<button onClick={() => setBatchMode(false)} style={{ padding: '6px 16px', fontSize: 13, border: 'none', cursor: 'pointer', backgroundColor: !batchMode ? '#4a7c59' : '#fff', color: !batchMode ? '#fff' : 'rgba(0,0,0,0.65)' }}></button>
<button onClick={() => setBatchMode(true)} style={{ padding: '6px 16px', fontSize: 13, border: 'none', cursor: 'pointer', backgroundColor: batchMode ? '#4a7c59' : '#fff', color: batchMode ? '#fff' : 'rgba(0,0,0,0.65)', borderLeft: '1px solid #D9D9D9' }}></button>
</div>
{batchMode && (
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}> {entries.length} </span>
)}
</div>
{/* Entry Cards */}
{entries.map((entry, idx) => (
<div key={entry.id} style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, marginBottom: 16, boxShadow: '0 1px 2px rgba(0,0,0,0.05)', border: '1px solid #F0F0F0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, margin: 0, color: 'rgba(0,0,0,0.85)' }}>
{batchMode ? `物料 #${idx + 1}` : '物料信息'}
</h3>
{batchMode && entries.length > 1 && (
<button onClick={() => removeEntry(entry.id)} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 12px', border: '1px solid #FFCCC7', borderRadius: 6, backgroundColor: '#FFF1F0', color: '#FF4D4F', cursor: 'pointer', fontSize: 13 }}>
<Trash2 size={13} />
</button>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 20 }}>
{/* 板卡类型 */}
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> </label>
<select value={entry.type} onChange={e => updateEntry(entry.id, 'type', e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
{typeOptions.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
{/* 板卡版本 */}
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> </label>
<select value={entry.version} onChange={e => updateEntry(entry.id, 'version', e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
{getVersionsForType(entry.type).map(v => <option key={v.id} value={v.version}>{v.version}</option>)}
{getVersionsForType(entry.type).length === 0 && <option value=""></option>}
</select>
</div>
{/* 物料SN */}
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> SN号</label>
<div style={{ display: 'flex', gap: 6 }}>
<input value={entry.sn} onChange={e => updateEntry(entry.id, 'sn', e.target.value)} placeholder={`扫码或手动输入 如 ${entry.version}-20250401001`} style={{ flex: 1, padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
<button title="扫码录入SN" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 36, height: 36, border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', flexShrink: 0 }}>
<ScanLine size={16} style={{ color: '#4a7c59' }} />
</button>
</div>
</div>
{/* 生产日期 */}
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> </label>
<input type="date" value={entry.productionDate} onChange={e => updateEntry(entry.id, 'productionDate', e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
</div>
{/* 物料状态 */}
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}></label>
<select value={entry.status} onChange={e => updateEntry(entry.id, 'status', e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
{['在库', '已装配', '故障', '报废'].map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
{/* 校准状态(仅需校准的分类显示) */}
{needsCalibration(entry.type) && (
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}></label>
<select value={entry.calibStatus} onChange={e => updateEntry(entry.id, 'calibStatus', e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
{['待校准', '合格', '不合格'].map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
)}
</div>
{/* 需校准物料提示 */}
{needsCalibration(entry.type) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, padding: '8px 12px', backgroundColor: '#FFFBE6', borderRadius: 6, border: '1px solid #FFE58F' }}>
<Info size={14} style={{ color: '#FAAD14', flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.65)' }}>"待校准"</span>
</div>
)}
{/* 校准文件导入(需校准的分类显示) */}
{needsCalibration(entry.type) && (
<div style={{ marginTop: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 8 }}></label>
<input
type="file"
accept=".csv,.xlsx,.xls,.dat,.json"
ref={el => { fileInputRefs.current[entry.id] = el }}
onChange={e => handleCalibFileChange(entry.id, e.target.files?.[0] || null)}
style={{ display: 'none' }}
/>
{entry.calibFile ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', backgroundColor: '#eef5f0', borderRadius: 6, border: '1px solid #a3c4ad' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FileText size={16} style={{ color: '#4a7c59', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'rgba(0,0,0,0.85)' }}>{entry.calibFile.name}</div>
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>{formatFileSize(entry.calibFile.size)}</div>
</div>
</div>
<button onClick={() => { handleCalibFileChange(entry.id, null); if (fileInputRefs.current[entry.id]) fileInputRefs.current[entry.id]!.value = '' }} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, border: 'none', background: 'none', cursor: 'pointer', color: 'rgba(0,0,0,0.45)', borderRadius: 4 }}>
<X size={14} />
</button>
</div>
) : (
<button onClick={() => fileInputRefs.current[entry.id]?.click()} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, width: '100%', padding: '14px 0', border: '2px dashed #D9D9D9', borderRadius: 6, backgroundColor: '#FAFAFA', cursor: 'pointer', fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>
<Upload size={16} /> .csv / .xlsx / .dat / .json
</button>
)}
</div>
)}
</div>
))}
{/* Add More Button (batch mode) */}
{batchMode && (
<button onClick={addEntry} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%', padding: '12px 0', border: '2px dashed #D9D9D9', borderRadius: 8, backgroundColor: '#FAFAFA', cursor: 'pointer', fontSize: 14, color: 'rgba(0,0,0,0.45)', marginBottom: 16 }}>
<Plus size={16} />
</button>
)}
{/* Preview Summary */}
{entries.length > 0 && (
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 2px rgba(0,0,0,0.05)', border: '1px solid #F0F0F0' }}>
<h3 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 16px', color: 'rgba(0,0,0,0.85)' }}></h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: '#FAFAFA' }}>
{['序号', '物料分类', '物料版本', '物料SN号', '生产日期', '物料状态', '校准状态', '校准文件'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', fontSize: 13, fontWeight: 600, color: 'rgba(0,0,0,0.85)', borderBottom: '1px solid #F0F0F0' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{entries.map((entry, i) => (
<tr key={entry.id} style={{ borderBottom: '1px solid #F0F0F0' }}>
<td style={{ padding: '10px 14px', fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>{i + 1}</td>
<td style={{ padding: '10px 14px', fontSize: 13 }}>{entry.type}</td>
<td style={{ padding: '10px 14px', fontSize: 13 }}>{entry.version}</td>
<td style={{ padding: '10px 14px', fontSize: 13, fontWeight: 500, color: entry.sn ? 'rgba(0,0,0,0.85)' : 'rgba(0,0,0,0.25)' }}>{entry.sn || '未填写'}</td>
<td style={{ padding: '10px 14px', fontSize: 13, color: entry.productionDate ? 'rgba(0,0,0,0.65)' : 'rgba(0,0,0,0.25)' }}>{entry.productionDate || '未填写'}</td>
<td style={{ padding: '10px 14px', fontSize: 13 }}>{entry.status}</td>
<td style={{ padding: '10px 14px', fontSize: 13 }}>
{needsCalibration(entry.type) ? entry.calibStatus : <span style={{ color: 'rgba(0,0,0,0.25)' }}>-</span>}
</td>
<td style={{ padding: '10px 14px', fontSize: 13 }}>
{needsCalibration(entry.type) ? (
entry.calibFile ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: '#4a7c59', fontSize: 12 }}>
<FileText size={12} />{entry.calibFile.name}
</span>
) : (
<span style={{ fontSize: 12, color: '#FAAD14' }}></span>
)
) : (
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.25)' }}>-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Sticky Bottom Bar */}
<div style={{ position: 'sticky', bottom: 0, backgroundColor: '#fff', borderTop: '1px solid #F0F0F0', padding: '12px 24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', zIndex: 10 }}>
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>
{entries.length}
{entries.some(e => needsCalibration(e.type)) && <span style={{ color: '#FAAD14' }}> · </span>}
{entries.some(e => needsCalibration(e.type) && e.calibFile) && <span style={{ color: '#4a7c59' }}> · {entries.filter(e => e.calibFile).length} </span>}
</span>
<div style={{ display: 'flex', gap: 12 }}>
<button onClick={() => router.push('/materials')} style={{ padding: '8px 24px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}></button>
<button
onClick={async () => {
for (const entry of entries) {
await fetch('/api/materials', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sn: entry.sn, name: entry.type, category: entry.type, type: entry.version,
device_model: '', version: entry.version, description: '',
firmware: '-', status: entry.status,
production_date: entry.productionDate,
calib_status: needsCalibration(entry.type) ? entry.calibStatus : '-',
calib_date: '-',
})
})
}
router.push('/materials')
}}
disabled={!isValid}
style={{ padding: '8px 24px', border: 'none', borderRadius: 6, backgroundColor: isValid ? '#4a7c59' : '#D9D9D9', color: '#fff', cursor: isValid ? 'pointer' : 'not-allowed', fontSize: 14 }}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<CheckCircle size={14} />
</span>
</button>
</div>
</div>
</div>
)
}