164 lines
7.1 KiB
TypeScript
164 lines
7.1 KiB
TypeScript
import { useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { Plus, Trash2, Star, X } from 'lucide-react'
|
|
import { getFirmware, createFirmware, deleteFirmware } from '../api/client'
|
|
|
|
export default function FirmwareList() {
|
|
const qc = useQueryClient()
|
|
const [modal, setModal] = useState<any>(null)
|
|
|
|
const { data: fws = [], isLoading } = useQuery({
|
|
queryKey: ['firmware'],
|
|
queryFn: () => getFirmware(),
|
|
})
|
|
|
|
const saveMut = useMutation({
|
|
mutationFn: (d: any) => createFirmware(d),
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['firmware'] }); setModal(null) },
|
|
})
|
|
|
|
const deleteMut = useMutation({
|
|
mutationFn: deleteFirmware,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['firmware'] }),
|
|
})
|
|
|
|
if (isLoading) return <div className="text-gray-400 p-8">로딩 중...</div>
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-xl font-semibold text-white">펌웨어 관리</h1>
|
|
<button onClick={() => setModal({})}
|
|
className="flex items-center gap-1.5 bg-brand hover:bg-brand2 text-white px-3 py-1.5 rounded text-sm">
|
|
<Plus size={14} /> 펌웨어 등록
|
|
</button>
|
|
</div>
|
|
<div className="bg-card border border-edge rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-panel border-b border-edge">
|
|
<tr>
|
|
{['버전','테넌트','장치유형','파일명','크기','체크섬','최신','등록일','액션'].map(h => (
|
|
<th key={h} className="px-4 py-3 text-left text-xs text-gray-400 font-medium">{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(fws as any[]).map((f: any) => (
|
|
<tr key={f.id} className="border-b border-edge hover:bg-edge/30">
|
|
<td className="px-4 py-3 text-brand font-mono text-xs">{f.firmwareVersion}</td>
|
|
<td className="px-4 py-3 text-gray-400 text-xs">{f.tenantCode}</td>
|
|
<td className="px-4 py-3 text-gray-300 text-xs">{f.deviceType}</td>
|
|
<td className="px-4 py-3 text-gray-300 text-xs">{f.fileName}</td>
|
|
<td className="px-4 py-3 text-gray-400 text-xs">
|
|
{f.fileSize ? `${(f.fileSize / 1024).toFixed(1)}KB` : '-'}
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-500 text-xs font-mono truncate max-w-[80px]">
|
|
{f.checksum ? f.checksum.substring(0, 8) + '...' : '-'}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{f.latest && <Star size={14} className="text-yellow-400" fill="currentColor" />}
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-500 text-xs">
|
|
{f.createdAt ? new Date(f.createdAt).toLocaleDateString('ko-KR') : '-'}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(f.id) }}
|
|
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{(fws as any[]).length === 0 && (
|
|
<tr><td colSpan={9} className="px-4 py-8 text-center text-gray-500">펌웨어가 없습니다</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{modal && (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
|
<div className="bg-card border border-edge rounded-lg p-6 w-full max-w-md">
|
|
<div className="flex justify-between mb-4">
|
|
<h2 className="font-semibold">펌웨어 등록</h2>
|
|
<button onClick={() => setModal(null)}><X size={16} /></button>
|
|
</div>
|
|
<FirmwareForm onSave={(d: any) => saveMut.mutate(d)} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FirmwareForm({ onSave }: { onSave: (d: any) => void }) {
|
|
const [f, setF] = useState({
|
|
tenantCode: 'EMART',
|
|
firmwareVersion: '',
|
|
deviceType: 'ESL_DEVICE',
|
|
fileName: '',
|
|
filePath: '',
|
|
fileSize: '',
|
|
checksum: '',
|
|
description: '',
|
|
latest: true,
|
|
})
|
|
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
|
setF(p => ({ ...p, [k]: e.target.value }))
|
|
|
|
const submit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
onSave({
|
|
...f,
|
|
fileSize: f.fileSize === '' ? null : Number(f.fileSize),
|
|
latest: f.latest === true || (f as any).latest === 'true',
|
|
})
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={submit} className="space-y-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">테넌트</label>
|
|
<select value={f.tenantCode} onChange={set('tenantCode')}
|
|
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
|
{['TENANT_A','TENANT_B','EMART','ZIOINFO'].map(o => <option key={o} value={o}>{o}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">장치 유형</label>
|
|
<select value={f.deviceType} onChange={set('deviceType')}
|
|
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
|
{['GATEWAY','HUB','ESL_DEVICE'].map(o => <option key={o} value={o}>{o}</option>)}
|
|
</select>
|
|
</div>
|
|
{[
|
|
{ label: '펌웨어 버전', key: 'firmwareVersion', ph: '예: v1.2.3', required: true },
|
|
{ label: '파일명', key: 'fileName', ph: 'firmware_v1.2.3.bin' },
|
|
{ label: '파일 경로', key: 'filePath', ph: '/opt/esn/firmware/...' },
|
|
{ label: '파일 크기(byte)', key: 'fileSize', type: 'number' },
|
|
{ label: '체크섬(SHA256)', key: 'checksum' },
|
|
].map(({ label, key, ph, type, required }) => (
|
|
<div key={key}>
|
|
<label className="block text-xs text-gray-400 mb-1">{label}</label>
|
|
<input type={type || 'text'} value={(f as any)[key]} onChange={set(key)} placeholder={ph} required={required}
|
|
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white" />
|
|
</div>
|
|
))}
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">설명</label>
|
|
<textarea value={f.description} onChange={set('description')} rows={2}
|
|
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-400 mb-1">최신 버전 지정</label>
|
|
<select value={String(f.latest)} onChange={set('latest')}
|
|
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
|
<option value="true">최신으로 지정</option>
|
|
<option value="false">일반</option>
|
|
</select>
|
|
</div>
|
|
<button type="submit" className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm">
|
|
등록
|
|
</button>
|
|
</form>
|
|
)
|
|
}
|