feat: grouped equipment import modal with collapsible groups
All checks were successful
Deploy to Production / deploy (push) Successful in 2m10s
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:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user