From a8be53c88e45807e8501a60c7a6ae403c1b57adf Mon Sep 17 00:00:00 2001 From: Johngreen Date: Thu, 12 Feb 2026 14:07:19 +0900 Subject: [PATCH] feat: grouped equipment import modal with collapsible groups - Group 304 equipment items into 8 groups by name prefix - Add group-level select/deselect with indeterminate checkbox state - Add collapsible groups with sticky headers during scroll - Show imported count per group and total toolbar summary - Sort groups by item count descending --- dashboard/app/[tenant]/page.tsx | 155 ++++++++++++++++++++++++++------ dashboard/app/globals.css | 81 +++++++++++++++-- 2 files changed, 202 insertions(+), 34 deletions(-) diff --git a/dashboard/app/[tenant]/page.tsx b/dashboard/app/[tenant]/page.tsx index eef0778..5085812 100644 --- a/dashboard/app/[tenant]/page.tsx +++ b/dashboard/app/[tenant]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { useMachines } from '@/lib/hooks'; import { useToast } from '@/lib/toast-context'; @@ -18,6 +18,19 @@ interface MachineForm { const INITIAL_FORM: MachineForm = { name: '', equipment_code: '', model: '', area: '', criticality: 'major' }; +function getEquipmentGroup(name: string): string { + const trimmed = name.trim(); + const spaceIdx = trimmed.indexOf(' '); + const firstWord = spaceIdx > 0 ? trimmed.slice(0, spaceIdx) : trimmed; + if (firstWord.endsWith('크레인')) return '크레인'; + return firstWord.replace(/\d+호기$/, '').replace(/\d+$/, '') || firstWord; +} + +interface ImportGroup { + name: string; + items: ImportPreviewItem[]; +} + export default function TenantDashboard() { const params = useParams(); const router = useRouter(); @@ -36,6 +49,19 @@ export default function TenantDashboard() { const [syncing, setSyncing] = useState(false); const [importing, setImporting] = useState(false); const [importLoading, setImportLoading] = useState(false); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + + const importGroups = useMemo(() => { + const map = new Map(); + for (const item of importPreview) { + const group = getEquipmentGroup(item.equipmentName); + if (!map.has(group)) map.set(group, []); + map.get(group)!.push(item); + } + return Array.from(map.entries()) + .map(([name, items]) => ({ name, items })) + .sort((a, b) => b.items.length - a.items.length); + }, [importPreview]); const openCreate = useCallback(() => { setEditTarget(null); @@ -148,6 +174,37 @@ export default function TenantDashboard() { }); }, [importPreview]); + const toggleGroupSelection = useCallback((groupName: string) => { + const group = importGroups.find(g => g.name === groupName); + if (!group) return; + const available = group.items.filter(i => !i.already_imported); + if (available.length === 0) return; + setSelectedImports(prev => { + const next = new Set(prev); + const allSelected = available.every(i => next.has(i.equipmentId)); + for (const item of available) { + if (allSelected) { + next.delete(item.equipmentId); + } else { + next.add(item.equipmentId); + } + } + return next; + }); + }, [importGroups]); + + const toggleGroupCollapse = useCallback((groupName: string) => { + setCollapsedGroups(prev => { + const next = new Set(prev); + if (next.has(groupName)) { + next.delete(groupName); + } else { + next.add(groupName); + } + return next; + }); + }, []); + const handleImport = useCallback(async () => { if (selectedImports.size === 0) return; setImporting(true); @@ -345,37 +402,79 @@ export default function TenantDashboard() { ) : ( <> -
- - {selectedImports.size > 0 && selectedImports.size === importPreview.filter(i => !i.already_imported).length - ? 'check_box' - : 'check_box_outline_blank'} +
+
+ + {selectedImports.size > 0 && selectedImports.size === importPreview.filter(i => !i.already_imported).length + ? 'check_box' + : selectedImports.size > 0 + ? 'indeterminate_check_box' + : 'check_box_outline_blank'} + + 전체 선택 +
+ + {importPreview.length}개 설비 · {importGroups.length}개 그룹 - 전체 선택
- {importPreview.map(item => ( -
!item.already_imported && toggleImportSelection(item.equipmentId)} - > - -
-
{item.equipmentName}
-
{item.equipmentId} {item.model && `• ${item.model}`}
+ {importGroups.map(group => { + const available = group.items.filter(i => !i.already_imported); + const selectedInGroup = available.filter(i => selectedImports.has(i.equipmentId)).length; + const allGroupSelected = available.length > 0 && selectedInGroup === available.length; + const someGroupSelected = selectedInGroup > 0 && !allGroupSelected; + const isCollapsed = collapsedGroups.has(group.name); + const importedInGroup = group.items.length - available.length; + + return ( +
+
+ toggleGroupSelection(group.name)} + > + {allGroupSelected ? 'check_box' : someGroupSelected ? 'indeterminate_check_box' : 'check_box_outline_blank'} + +
toggleGroupCollapse(group.name)}> + {group.name} + + {group.items.length}대 + {importedInGroup > 0 && ` · ${importedInGroup}대 가져옴`} + + + expand_more + +
+
+ {!isCollapsed && ( +
+ {group.items.map(item => ( +
!item.already_imported && toggleImportSelection(item.equipmentId)} + > + +
+
{item.equipmentName}
+
{item.equipmentId} {item.model && `• ${item.model}`}
+
+ {item.already_imported && ( + 이미 가져옴 + )} +
+ ))} +
+ )}
- {item.already_imported && ( - 이미 가져옴 - )} -
- ))} + ); + })}
)} diff --git a/dashboard/app/globals.css b/dashboard/app/globals.css index 865f9bc..a7f70fe 100644 --- a/dashboard/app/globals.css +++ b/dashboard/app/globals.css @@ -2645,20 +2645,90 @@ a { max-width: 640px; } +.import-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--md-outline-variant); +} + +.import-toolbar-count { + font-size: 12px; + color: var(--md-on-surface-variant); + padding-right: 16px; +} + .import-list { display: flex; flex-direction: column; - gap: 2px; - max-height: 400px; + max-height: 480px; overflow-y: auto; padding: 4px 0; } +.import-group + .import-group { + border-top: 1px solid var(--md-outline-variant); +} + +.import-group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + position: sticky; + top: 0; + background: var(--md-surface); + z-index: 1; +} + +.import-group-checkbox { + font-size: 22px; + color: var(--md-primary); + cursor: pointer; + user-select: none; +} + +.import-group-info { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; +} + +.import-group-name { + font-size: 14px; + font-weight: 600; + color: var(--md-on-surface); +} + +.import-group-count { + font-size: 12px; + color: var(--md-on-surface-variant); +} + +.import-group-chevron { + margin-left: auto; + font-size: 20px; + color: var(--md-on-surface-variant); + transition: transform var(--md-motion-standard); + transform: rotate(-90deg); +} + +.import-group-chevron.expanded { + transform: rotate(0deg); +} + +.import-group-items { + padding-left: 16px; +} + .import-item { display: flex; align-items: center; gap: 12px; - padding: 10px 16px; + padding: 8px 16px; border-radius: var(--md-radius-sm); cursor: pointer; transition: background var(--md-motion-standard); @@ -2680,13 +2750,13 @@ a { } .import-item-name { - font-size: 14px; + font-size: 13px; font-weight: 500; color: var(--md-on-surface); } .import-item-code { - font-size: 12px; + font-size: 11px; color: var(--md-on-surface-variant); } @@ -2895,7 +2965,6 @@ a { color: var(--md-primary); font-weight: 500; cursor: pointer; - border-bottom: 1px solid var(--md-outline-variant); } .import-summary {