Files
factoryOps-v2/dashboard/components/MachineList.tsx
Johngreen 278cd9d551
All checks were successful
Deploy to Production / deploy (push) Successful in 1m37s
feat: bidirectional equipment sync with digital-twin
Add import, sync, and push capabilities between factoryOps and the
digital-twin (BaSyx AAS) backend. Includes:

- Equipment sync service with field mapping and LWW conflict resolution
- Import preview modal with already-imported detection
- Bidirectional sync (pull updates + push local changes)
- Sync history tracking via equipment_sync_history table
- Machine detail page shows sync status and change history
- Docker networking for container-to-container communication
- UI fixes: responsive layout (375px), touch targets, section spacing
- 30 test cases for sync service
2026-02-12 12:27:21 +09:00

179 lines
6.5 KiB
TypeScript

'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import type { Machine, Criticality } from '@/lib/types';
interface MachineListProps {
machines: Machine[];
tenantId: string;
onEdit: (machine: Machine) => void;
onDelete: (machine: Machine) => void;
onBatchInspect?: (area: string, machineIds: string[]) => void;
}
const CRITICALITY_ORDER: Record<string, number> = {
critical: 0,
major: 1,
minor: 2,
};
const getCriticalityScore = (c: Criticality | null) => {
if (!c) return 3;
return CRITICALITY_ORDER[c] ?? 3;
};
export function MachineList({ machines, tenantId, onEdit, onDelete, onBatchInspect }: MachineListProps) {
const router = useRouter();
const groupedMachines = machines.reduce((acc, machine) => {
const area = machine.area || '미지정';
if (!acc[area]) {
acc[area] = [];
}
acc[area].push(machine);
return acc;
}, {} as Record<string, Machine[]>);
const sortedAreas = Object.keys(groupedMachines).sort((a, b) => {
const machinesA = groupedMachines[a];
const machinesB = groupedMachines[b];
const hasCriticalA = machinesA.some(m => m.criticality === 'critical');
const hasCriticalB = machinesB.some(m => m.criticality === 'critical');
if (hasCriticalA && !hasCriticalB) return -1;
if (!hasCriticalA && hasCriticalB) return 1;
return a.localeCompare(b);
});
sortedAreas.forEach(area => {
groupedMachines[area].sort((a, b) => {
const scoreA = getCriticalityScore(a.criticality);
const scoreB = getCriticalityScore(b.criticality);
return scoreA - scoreB;
});
});
const getBadgeClass = (criticality: Criticality | null) => {
switch (criticality) {
case 'critical': return 'machine-badge-critical';
case 'major': return 'machine-badge-major';
case 'minor': return 'machine-badge-minor';
default: return null;
}
};
const getBadgeText = (criticality: Criticality | null) => {
switch (criticality) {
case 'critical': return '핵심';
case 'major': return '주요';
case 'minor': return '보조';
default: return null;
}
};
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
const init: Record<string, boolean> = {};
Object.keys(groupedMachines).forEach(area => {
init[area] = groupedMachines[area].length > 30;
});
return init;
});
const toggleArea = useCallback((area: string) => {
setCollapsed(prev => ({ ...prev, [area]: !prev[area] }));
}, []);
return (
<div className="machine-list-container">
{sortedAreas.map(area => (
<div key={area} className="machine-group">
<div className="machine-group-header" onClick={() => toggleArea(area)} style={{ cursor: 'pointer' }}>
<h2 className="machine-group-title">
<span className="material-symbols-outlined">location_on</span>
{area} <span className="machine-count">({groupedMachines[area].length})</span>
<span className={`material-symbols-outlined machine-group-chevron ${collapsed[area] ? '' : 'machine-group-chevron-open'}`}>expand_more</span>
</h2>
{onBatchInspect && (
<button
className="btn-outline btn-sm"
onClick={(e) => { e.stopPropagation(); onBatchInspect(area, groupedMachines[area].map(m => m.id)); }}
>
<span className="material-symbols-outlined">playlist_add_check</span>
</button>
)}
</div>
{!collapsed[area] && <div className="machine-grid">
{groupedMachines[area].map((machine) => (
<div key={machine.id} className="machine-card">
<div
className="machine-card-body"
onClick={() => router.push(`/${tenantId}/machines/${machine.id}`)}
>
<div className="machine-card-top">
<div className="machine-card-icon">
<span className="material-symbols-outlined">precision_manufacturing</span>
</div>
<div style={{ display: 'flex', gap: '4px' }}>
{machine.source === 'digital-twin' && (
<span className="sync-badge">
<span className="material-symbols-outlined">cloud</span>
</span>
)}
{machine.criticality && (
<span className={`machine-badge ${getBadgeClass(machine.criticality)}`}>
{getBadgeText(machine.criticality)}
</span>
)}
</div>
</div>
<div className="machine-card-info">
<h3 className="machine-card-name">{machine.name}</h3>
<span className="machine-card-code">{machine.equipment_code}</span>
{machine.model && (
<span className="machine-card-model">{machine.model}</span>
)}
</div>
<div className="machine-card-meta">
<div className="machine-card-parts">
<span className="material-symbols-outlined">settings</span>
<span> {machine.parts_count}</span>
</div>
</div>
</div>
<div className="machine-card-actions">
<button
className="btn-icon"
onClick={(e) => { e.stopPropagation(); onEdit(machine); }}
title="수정"
>
<span className="material-symbols-outlined">edit</span>
</button>
<button
className="btn-icon btn-icon-danger"
onClick={(e) => { e.stopPropagation(); onDelete(machine); }}
title="삭제"
>
<span className="material-symbols-outlined">delete</span>
</button>
</div>
</div>
))}
</div>}
</div>
))}
{sortedAreas.length === 0 && (
<div className="empty-state">
<span className="material-symbols-outlined">precision_manufacturing</span>
<p> .</p>
</div>
)}
</div>
);
}