feat: grouped equipment import modal with collapsible groups
All checks were successful
Deploy to Production / deploy (push) Successful in 2m10s

- 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
This commit is contained in:
Johngreen
2026-02-12 14:07:19 +09:00
parent d3ed2798af
commit a8be53c88e
2 changed files with 202 additions and 34 deletions

View File

@@ -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<Set<string>>(new Set());
const importGroups = useMemo<ImportGroup[]>(() => {
const map = new Map<string, ImportPreviewItem[]>();
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() {
</div>
) : (
<>
<div className="import-select-all" onClick={toggleAllImports}>
<span className="material-symbols-outlined">
{selectedImports.size > 0 && selectedImports.size === importPreview.filter(i => !i.already_imported).length
? 'check_box'
: 'check_box_outline_blank'}
<div className="import-toolbar">
<div className="import-select-all" onClick={toggleAllImports}>
<span className="material-symbols-outlined">
{selectedImports.size > 0 && selectedImports.size === importPreview.filter(i => !i.already_imported).length
? 'check_box'
: selectedImports.size > 0
? 'indeterminate_check_box'
: 'check_box_outline_blank'}
</span>
</div>
<span className="import-toolbar-count">
{importPreview.length} · {importGroups.length}
</span>
</div>
<div className="import-list">
{importPreview.map(item => (
<div
key={item.equipmentId}
className={`import-item ${item.already_imported ? 'import-item-imported' : ''}`}
onClick={() => !item.already_imported && toggleImportSelection(item.equipmentId)}
>
<input
type="checkbox"
className="import-item-checkbox"
checked={selectedImports.has(item.equipmentId) || item.already_imported}
disabled={item.already_imported}
readOnly
/>
<div className="import-item-info">
<div className="import-item-name">{item.equipmentName}</div>
<div className="import-item-code">{item.equipmentId} {item.model && `${item.model}`}</div>
{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 (
<div key={group.name} className="import-group">
<div className="import-group-header">
<span
className="material-symbols-outlined import-group-checkbox"
onClick={() => toggleGroupSelection(group.name)}
>
{allGroupSelected ? 'check_box' : someGroupSelected ? 'indeterminate_check_box' : 'check_box_outline_blank'}
</span>
<div className="import-group-info" onClick={() => toggleGroupCollapse(group.name)}>
<span className="import-group-name">{group.name}</span>
<span className="import-group-count">
{group.items.length}
{importedInGroup > 0 && ` · ${importedInGroup}대 가져옴`}
</span>
<span className={`material-symbols-outlined import-group-chevron ${isCollapsed ? '' : 'expanded'}`}>
expand_more
</span>
</div>
</div>
{!isCollapsed && (
<div className="import-group-items">
{group.items.map(item => (
<div
key={item.equipmentId}
className={`import-item ${item.already_imported ? 'import-item-imported' : ''}`}
onClick={() => !item.already_imported && toggleImportSelection(item.equipmentId)}
>
<input
type="checkbox"
className="import-item-checkbox"
checked={selectedImports.has(item.equipmentId) || item.already_imported}
disabled={item.already_imported}
readOnly
/>
<div className="import-item-info">
<div className="import-item-name">{item.equipmentName}</div>
<div className="import-item-code">{item.equipmentId} {item.model && `${item.model}`}</div>
</div>
{item.already_imported && (
<span className="imported-badge"> </span>
)}
</div>
))}
</div>
)}
</div>
{item.already_imported && (
<span className="imported-badge"> </span>
)}
</div>
))}
);
})}
</div>
</>
)}

View File

@@ -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 {