Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node

This commit is contained in:
kmh 2026-03-25 18:39:07 +09:00
commit 02ac36c94f
57 changed files with 7268 additions and 1589 deletions

2
.gitignore vendored
View File

@ -193,7 +193,9 @@ scripts/browser-test-*.js
# 개인 작업 문서
popdocs/
kshdocs/
.cursor/rules/popdocs-safety.mdc
.cursor/rules/overtime-registration.mdc
# 멀티 에이전트 MCP 태스크 큐
mcp-task-queue/

View File

@ -354,8 +354,8 @@
"hotPaths": [
{
"path": "frontend/app/(main)/sales/order/page.tsx",
"accessCount": 16,
"lastAccessed": 1774313958064,
"accessCount": 19,
"lastAccessed": 1774408850812,
"type": "file"
},
{
@ -366,14 +366,14 @@
},
{
"path": "frontend/components/common/DataGrid.tsx",
"accessCount": 3,
"lastAccessed": 1774313504763,
"accessCount": 4,
"lastAccessed": 1774408732451,
"type": "file"
},
{
"path": "frontend/components/common/DynamicSearchFilter.tsx",
"accessCount": 2,
"lastAccessed": 1774313460662,
"accessCount": 3,
"lastAccessed": 1774408732309,
"type": "file"
},
{
@ -435,6 +435,12 @@
"accessCount": 1,
"lastAccessed": 1774313925751,
"type": "file"
},
{
"path": "frontend/components/common/TableSettingsModal.tsx",
"accessCount": 1,
"lastAccessed": 1774409034693,
"type": "file"
}
],
"userDirectives": []

View File

@ -173,7 +173,11 @@ export async function getPkgUnitItems(
const pool = getPool();
const result = await pool.query(
`SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
`SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit
FROM pkg_unit_item pui
LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code
WHERE pui.pkg_code=$1 AND pui.company_code=$2
ORDER BY pui.created_date DESC`,
[pkgCode, companyCode]
);
@ -410,7 +414,11 @@ export async function getLoadingUnitPkgs(
const pool = getPool();
const result = await pool.query(
`SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
`SELECT lup.*, pu.pkg_name, pu.pkg_type
FROM loading_unit_pkg lup
LEFT JOIN pkg_unit pu ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code
WHERE lup.loading_code=$1 AND lup.company_code=$2
ORDER BY lup.created_date DESC`,
[loadingCode, companyCode]
);

File diff suppressed because it is too large Load Diff

View File

@ -148,9 +148,9 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo
const { processCode } = req.params;
const result = await pool.query(
`SELECT pe.*, ei.equipment_name
`SELECT pe.*, em.equipment_name
FROM process_equipment pe
LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code
LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code
WHERE pe.process_code = $1 AND pe.company_code = $2
ORDER BY pe.equipment_code`,
[processCode, companyCode]
@ -214,7 +214,7 @@ export async function getEquipmentList(req: AuthenticatedRequest, res: Response)
const params = companyCode === "*" ? [] : [companyCode];
const result = await pool.query(
`SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`,
`SELECT objid AS id, equipment_code, equipment_name FROM equipment_mng ${condition} ORDER BY equipment_code`,
params
);

View File

@ -3,6 +3,14 @@ import { authenticateToken } from "../middleware/authMiddleware";
import {
createWorkProcesses,
controlTimer,
controlGroupTimer,
getDefectTypes,
saveResult,
confirmResult,
getResultHistory,
getAvailableQty,
acceptProcess,
cancelAccept,
} from "../controllers/popProductionController";
const router = Router();
@ -11,5 +19,13 @@ router.use(authenticateToken);
router.post("/create-work-processes", createWorkProcesses);
router.post("/timer", controlTimer);
router.post("/group-timer", controlGroupTimer);
router.get("/defect-types", getDefectTypes);
router.post("/save-result", saveResult);
router.post("/confirm-result", confirmResult);
router.get("/result-history", getResultHistory);
router.get("/available-qty", getAvailableQty);
router.post("/accept-process", acceptProcess);
router.post("/cancel-accept", cancelAccept);
export default router;

View File

@ -2717,6 +2717,43 @@ export class TableManagementService {
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
}
// entity 컬럼의 display_column 자동 채우기 (예: supplier_code → supplier_name)
try {
const companyCode = data.company_code || "*";
const entityColsResult = await query<any>(
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'entity'
AND reference_table IS NOT NULL AND reference_table != ''
AND display_column IS NOT NULL AND display_column != ''
AND company_code IN ($2, '*')
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
[tableName, companyCode]
);
for (const ec of entityColsResult) {
const srcVal = data[ec.column_name];
const displayCol = ec.display_column;
// display_column이 테이블에 존재하고, 값이 비어있거나 없으면 자동 조회
if (srcVal && columnTypeMap.has(displayCol) && (!data[displayCol] || data[displayCol] === "")) {
try {
const refResult = await query<any>(
`SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`,
[srcVal, companyCode]
);
if (refResult.length > 0 && refResult[0][displayCol]) {
data[displayCol] = refResult[0][displayCol];
logger.info(`Entity auto-fill: ${tableName}.${displayCol} = ${data[displayCol]} (from ${ec.reference_table})`);
}
} catch (refErr: any) {
logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`);
}
}
}
} catch (entityErr: any) {
logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`);
}
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
const skippedColumns: string[] = [];
const existingColumns = Object.keys(data).filter((col) => {
@ -2868,6 +2905,42 @@ export class TableManagementService {
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
}
// entity 컬럼의 display_column 자동 채우기 (수정 시)
try {
const companyCode = updatedData.company_code || originalData.company_code || "*";
const entityColsResult = await query<any>(
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'entity'
AND reference_table IS NOT NULL AND reference_table != ''
AND display_column IS NOT NULL AND display_column != ''
AND company_code IN ($2, '*')
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
[tableName, companyCode]
);
for (const ec of entityColsResult) {
const srcVal = updatedData[ec.column_name];
const displayCol = ec.display_column;
if (srcVal && columnTypeMap.has(displayCol) && (!updatedData[displayCol] || updatedData[displayCol] === "")) {
try {
const refResult = await query<any>(
`SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`,
[srcVal, companyCode]
);
if (refResult.length > 0 && refResult[0][displayCol]) {
updatedData[displayCol] = refResult[0][displayCol];
logger.info(`Entity auto-fill (edit): ${tableName}.${displayCol} = ${updatedData[displayCol]}`);
}
} catch (refErr: any) {
logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`);
}
}
}
} catch (entityErr: any) {
logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`);
}
// SET 절 생성 (수정할 데이터) - 먼저 생성
// 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
const setConditions: string[] = [];

View File

@ -0,0 +1,14 @@
{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":110167}
{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":196548}
{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":253997}
{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":339528}
{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":380641}
{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":413980}
{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":401646}

View File

@ -1,3 +1,3 @@
{
"lastSentAt": "2026-03-25T01:37:37.051Z"
"lastSentAt": "2026-03-25T05:06:13.529Z"
}

View File

@ -1,7 +1,7 @@
{
"tool_name": "Read",
"tool_input_preview": "{\"file_path\":\"/Users/kimjuseok/ERP-node/frontend/app/(main)/sales/sales-item/page.tsx\"}",
"error": "File content (13282 tokens) exceeds maximum allowed tokens (10000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.",
"timestamp": "2026-03-25T01:36:58.910Z",
"tool_name": "Bash",
"tool_input_preview": "{\"command\":\"wc -l /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx\",\"description\":\"Get total line count of the file\"}",
"error": "Exit code 1\n(eval):1: no matches found: /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx",
"timestamp": "2026-03-25T05:00:38.410Z",
"retry_count": 1
}

View File

@ -1,5 +1,5 @@
{
"updatedAt": "2026-03-25T01:37:19.659Z",
"updatedAt": "2026-03-25T05:06:35.487Z",
"missions": [
{
"id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none",
@ -104,6 +104,178 @@
"sourceKey": "session-stop:a9a231d40fd5a150b"
}
]
},
{
"id": "session:037169c7-72ba-4843-8e9a-417ca1423715:none",
"source": "session",
"name": "none",
"objective": "Session mission",
"createdAt": "2026-03-25T04:59:24.101Z",
"updatedAt": "2026-03-25T05:06:35.487Z",
"status": "done",
"workerCount": 7,
"taskCounts": {
"total": 7,
"pending": 0,
"blocked": 0,
"inProgress": 0,
"completed": 7,
"failed": 0
},
"agents": [
{
"name": "executor:a32b34c",
"role": "executor",
"ownership": "a32b34c341b854da5",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:06:18.081Z"
},
{
"name": "executor:ad2c89c",
"role": "executor",
"ownership": "ad2c89cf14936ea42",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:02:45.524Z"
},
{
"name": "executor:a2c140c",
"role": "executor",
"ownership": "a2c140c5a5adb0719",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:05:13.388Z"
},
{
"name": "executor:a2e5213",
"role": "executor",
"ownership": "a2e52136ea8f04385",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:03:53.163Z"
},
{
"name": "executor:a3735bf",
"role": "executor",
"ownership": "a3735bf51a74d6fc8",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:01:33.817Z"
},
{
"name": "executor:a77742b",
"role": "executor",
"ownership": "a77742ba65fd2451c",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:06:09.324Z"
},
{
"name": "executor:a4eb932",
"role": "executor",
"ownership": "a4eb932c438b898c0",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:06:35.487Z"
}
],
"timeline": [
{
"id": "session-start:a3735bf51a74d6fc8:2026-03-25T04:59:43.650Z",
"at": "2026-03-25T04:59:43.650Z",
"kind": "update",
"agent": "executor:a3735bf",
"detail": "started executor:a3735bf",
"sourceKey": "session-start:a3735bf51a74d6fc8"
},
{
"id": "session-start:a77742ba65fd2451c:2026-03-25T04:59:48.683Z",
"at": "2026-03-25T04:59:48.683Z",
"kind": "update",
"agent": "executor:a77742b",
"detail": "started executor:a77742b",
"sourceKey": "session-start:a77742ba65fd2451c"
},
{
"id": "session-start:a4eb932c438b898c0:2026-03-25T04:59:53.841Z",
"at": "2026-03-25T04:59:53.841Z",
"kind": "update",
"agent": "executor:a4eb932",
"detail": "started executor:a4eb932",
"sourceKey": "session-start:a4eb932c438b898c0"
},
{
"id": "session-stop:a3735bf51a74d6fc8:2026-03-25T05:01:33.817Z",
"at": "2026-03-25T05:01:33.817Z",
"kind": "completion",
"agent": "executor:a3735bf",
"detail": "completed",
"sourceKey": "session-stop:a3735bf51a74d6fc8"
},
{
"id": "session-stop:ad2c89cf14936ea42:2026-03-25T05:02:45.524Z",
"at": "2026-03-25T05:02:45.524Z",
"kind": "completion",
"agent": "executor:ad2c89c",
"detail": "completed",
"sourceKey": "session-stop:ad2c89cf14936ea42"
},
{
"id": "session-stop:a2e52136ea8f04385:2026-03-25T05:03:53.163Z",
"at": "2026-03-25T05:03:53.163Z",
"kind": "completion",
"agent": "executor:a2e5213",
"detail": "completed",
"sourceKey": "session-stop:a2e52136ea8f04385"
},
{
"id": "session-stop:a2c140c5a5adb0719:2026-03-25T05:05:13.388Z",
"at": "2026-03-25T05:05:13.388Z",
"kind": "completion",
"agent": "executor:a2c140c",
"detail": "completed",
"sourceKey": "session-stop:a2c140c5a5adb0719"
},
{
"id": "session-stop:a77742ba65fd2451c:2026-03-25T05:06:09.324Z",
"at": "2026-03-25T05:06:09.324Z",
"kind": "completion",
"agent": "executor:a77742b",
"detail": "completed",
"sourceKey": "session-stop:a77742ba65fd2451c"
},
{
"id": "session-stop:a32b34c341b854da5:2026-03-25T05:06:18.081Z",
"at": "2026-03-25T05:06:18.081Z",
"kind": "completion",
"agent": "executor:a32b34c",
"detail": "completed",
"sourceKey": "session-stop:a32b34c341b854da5"
},
{
"id": "session-stop:a4eb932c438b898c0:2026-03-25T05:06:35.487Z",
"at": "2026-03-25T05:06:35.487Z",
"kind": "completion",
"agent": "executor:a4eb932",
"detail": "completed",
"sourceKey": "session-stop:a4eb932c438b898c0"
}
]
}
]
}

View File

@ -44,10 +44,73 @@
"status": "completed",
"completed_at": "2026-03-25T01:37:19.659Z",
"duration_ms": 139427
},
{
"agent_id": "a32b34c341b854da5",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:24.101Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:06:18.081Z",
"duration_ms": 413980
},
{
"agent_id": "ad2c89cf14936ea42",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:28.976Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:02:45.524Z",
"duration_ms": 196548
},
{
"agent_id": "a2c140c5a5adb0719",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:33.860Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:05:13.388Z",
"duration_ms": 339528
},
{
"agent_id": "a2e52136ea8f04385",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:39.166Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:03:53.163Z",
"duration_ms": 253997
},
{
"agent_id": "a3735bf51a74d6fc8",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:43.650Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:01:33.817Z",
"duration_ms": 110167
},
{
"agent_id": "a77742ba65fd2451c",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:48.683Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:06:09.324Z",
"duration_ms": 380641
},
{
"agent_id": "a4eb932c438b898c0",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:53.841Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:06:35.487Z",
"duration_ms": 401646
}
],
"total_spawned": 5,
"total_completed": 5,
"total_spawned": 12,
"total_completed": 12,
"total_failed": 0,
"last_updated": "2026-03-25T01:37:19.762Z"
"last_updated": "2026-03-25T05:06:35.589Z"
}

View File

@ -20,13 +20,14 @@ import { Checkbox } from "@/components/ui/checkbox";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Wrench, ClipboardCheck, Package, Copy, Info,
Wrench, ClipboardCheck, Package, Copy, Info, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
@ -78,6 +79,8 @@ export default function EquipmentInfoPage() {
const [equipLoading, setEquipLoading] = useState(false);
const [equipCount, setEquipCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
// 우측 탭
@ -119,6 +122,15 @@ export default function EquipmentInfoPage() {
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("equipment-info");
if (saved) applyTableSettings(saved);
}, []);
// 카테고리 로드
useEffect(() => {
const load = async () => {
@ -395,8 +407,12 @@ export default function EquipmentInfoPage() {
return (
<div className="flex h-full flex-col gap-3 p-3">
<DynamicSearchFilter tableName={EQUIP_TABLE} filterId="equipment-info" onFilterChange={setSearchFilters} dataCount={equipCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
onClick={async () => {
setExcelDetecting(true);
@ -722,6 +738,14 @@ export default function EquipmentInfoPage() {
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
)}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={EQUIP_TABLE}
settingsId="equipment-info"
onSave={applyTableSettings}
/>
{ConfirmDialogComponent}
</div>
);

View File

@ -105,6 +105,7 @@ export default function PackagingPage() {
const [pkgMatchQty, setPkgMatchQty] = useState(1);
const [pkgMatchMethod, setPkgMatchMethod] = useState("");
const [pkgMatchSelected, setPkgMatchSelected] = useState<PkgUnit | null>(null);
const [pkgMatchSearchKw, setPkgMatchSearchKw] = useState("");
const [saving, setSaving] = useState(false);
@ -313,7 +314,7 @@ export default function PackagingPage() {
// --- 포장단위 추가 모달 (적재함 구성) ---
const openPkgMatchModal = () => {
setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod("");
setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod(""); setPkgMatchSearchKw("");
setPkgMatchModalOpen(true);
};
@ -790,11 +791,19 @@ export default function PackagingPage() {
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex gap-2">
<Input placeholder="품목코드 / 품목명 검색" value={itemMatchKeyword} onChange={(e) => setItemMatchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItemsForMatch()} className="h-9 text-xs" />
<Button size="sm" onClick={searchItemsForMatch} className="h-9"><Search className="mr-1 h-3 w-3" /> </Button>
</div>
<Input placeholder="품목코드 / 품목명 검색 (입력 시 자동 검색)" value={itemMatchKeyword}
onChange={(e) => {
setItemMatchKeyword(e.target.value);
const kw = e.target.value;
clearTimeout((window as any).__itemMatchTimer);
(window as any).__itemMatchTimer = setTimeout(async () => {
try {
const res = await getGeneralItems(kw || undefined);
if (res.success) setItemMatchResults(res.data);
} catch { /* ignore */ }
}, 300);
}}
className="h-9 text-xs" />
<div className="max-h-[300px] overflow-auto border rounded">
<Table>
<TableHeader>
@ -808,9 +817,9 @@ export default function PackagingPage() {
</TableRow>
</TableHeader>
<TableBody>
{itemMatchResults.length === 0 ? (
{itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16"> </TableCell></TableRow>
) : itemMatchResults.map((item) => (
) : itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer text-xs", itemMatchSelected?.id === item.id && "bg-primary/10")}
onClick={() => setItemMatchSelected(item)}>
<TableCell className="p-2 text-center">{itemMatchSelected?.id === item.id ? "✓" : ""}</TableCell>
@ -830,8 +839,8 @@ export default function PackagingPage() {
<Input value={itemMatchSelected ? `${itemMatchSelected.item_name} (${itemMatchSelected.item_number})` : ""} readOnly className="h-9 bg-muted text-xs" />
</div>
<div className="w-[120px]">
<Label className="text-xs">(EA) <span className="text-destructive">*</span></Label>
<Input type="number" value={itemMatchQty} onChange={(e) => setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
<Label htmlFor="pkg-item-match-qty" className="text-xs">(EA) <span className="text-destructive">*</span></Label>
<Input id="pkg-item-match-qty" type="number" value={itemMatchQty} onChange={(e) => setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
</div>
</div>
</div>
@ -844,41 +853,59 @@ export default function PackagingPage() {
{/* 포장단위 추가 모달 (적재함 구성) */}
<Dialog open={pkgMatchModalOpen} onOpenChange={setPkgMatchModalOpen}>
<DialogContent className="max-w-[550px]">
<DialogContent className="max-w-[800px]">
<DialogHeader>
<DialogTitle> {selectedLoading?.loading_name}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="max-h-[200px] overflow-auto border rounded">
<Input
placeholder="포장코드 / 포장명 검색"
value={pkgMatchSearchKw}
onChange={(e) => setPkgMatchSearchKw(e.target.value)}
className="h-9 text-xs"
/>
<div className="max-h-[300px] overflow-auto border rounded">
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2 w-[30px]" />
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[120px]"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[100px]">(mm)</TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pkgUnits.length === 0 ? (
<TableRow><TableCell colSpan={4} className="text-center text-muted-foreground text-xs h-16"> </TableCell></TableRow>
) : pkgUnits.filter(p => p.status === "ACTIVE").map((p) => (
<TableRow key={p.id} className={cn("cursor-pointer text-xs", pkgMatchSelected?.id === p.id && "bg-primary/10")}
onClick={() => setPkgMatchSelected(p)}>
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
<TableCell className="p-2">{p.pkg_name}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
</TableRow>
))}
{(() => {
const kw = pkgMatchSearchKw.toLowerCase();
const filtered = pkgUnits.filter(p =>
p.status === "ACTIVE"
&& !loadingPkgs.some(lp => lp.pkg_code === p.pkg_code)
&& (!kw || p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw))
);
return filtered.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16"> </TableCell></TableRow>
) : filtered.map((p) => (
<TableRow key={p.id} className={cn("cursor-pointer text-xs", pkgMatchSelected?.id === p.id && "bg-primary/10")}
onClick={() => setPkgMatchSelected(p)}>
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
<TableCell className="p-2">{p.pkg_name}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
</TableRow>
));
})()}
</TableBody>
</Table>
</div>
<div className="flex items-end gap-4">
<div className="w-[150px]">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Input type="number" value={pkgMatchQty} onChange={(e) => setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
<Label htmlFor="loading-pkg-match-qty" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="loading-pkg-match-qty" type="number" value={pkgMatchQty} onChange={(e) => setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
</div>
<div className="flex-1">
<Label className="text-xs"></Label>

View File

@ -21,13 +21,14 @@ import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Building2, Users,
Building2, Users, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
@ -63,7 +64,9 @@ export default function DepartmentPage() {
const [deptLoading, setDeptLoading] = useState(false);
const [deptCount, setDeptCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedDeptCode, setSelectedDeptCode] = useState<string | null>(null);
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
// 우측: 사원
const [members, setMembers] = useState<any[]>([]);
@ -77,12 +80,22 @@ export default function DepartmentPage() {
// 사원 모달
const [userModalOpen, setUserModalOpen] = useState(false);
const [userEditMode, setUserEditMode] = useState(false);
const [userForm, setUserForm] = useState<Record<string, any>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("department");
if (saved) applyTableSettings(saved);
}, []);
// 부서 조회
const fetchDepts = useCallback(async () => {
setDeptLoading(true);
@ -93,7 +106,9 @@ export default function DepartmentPage() {
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const data = res.data?.data?.data || res.data?.data?.rows || [];
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑
const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code }));
setDepts(data);
setDeptCount(res.data?.data?.total || data.length);
} catch (err) {
@ -107,25 +122,27 @@ export default function DepartmentPage() {
useEffect(() => { fetchDepts(); }, [fetchDepts]);
// 선택된 부서
const selectedDept = depts.find((d) => d.dept_code === selectedDeptCode);
const selectedDept = depts.find((d) => d.id === selectedDeptId);
const selectedDeptCode = selectedDept?.dept_code || null;
// 우측: 사원 조회
useEffect(() => {
if (!selectedDeptCode) { setMembers([]); return; }
const fetchMembers = async () => {
setMemberLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] },
autoFilter: true,
});
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setMembers([]); } finally { setMemberLoading(false); }
};
fetchMembers();
// 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서)
const fetchMembers = useCallback(async () => {
setMemberLoading(true);
try {
const filters = selectedDeptCode
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
: [];
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setMembers([]); } finally { setMemberLoading(false); }
}, [selectedDeptCode]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
// 부서 등록
const openDeptRegister = () => {
setDeptForm({});
@ -180,14 +197,20 @@ export default function DepartmentPage() {
data: [{ dept_code: selectedDeptCode }],
});
toast.success("삭제되었습니다.");
setSelectedDeptCode(null);
setSelectedDeptId(null);
fetchDepts();
} catch { toast.error("삭제에 실패했습니다."); }
};
// 사원 추가
const openUserModal = () => {
setUserForm({ dept_code: selectedDeptCode || "" });
const openUserModal = (editData?: any) => {
if (editData) {
setUserEditMode(true);
setUserForm({ ...editData, user_password: "" });
} else {
setUserEditMode(false);
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
}
setFormErrors({});
setUserModalOpen(true);
};
@ -208,14 +231,34 @@ export default function DepartmentPage() {
setSaving(true);
try {
const { created_date, updated_date, ...fields } = userForm;
await apiClient.post(`/table-management/tables/${USER_TABLE}/add`, fields);
toast.success("사원이 추가되었습니다.");
// 비밀번호 미입력 시 기본값 (신규만)
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
await apiClient.post("/admin/users/with-dept", {
userInfo: {
user_id: userForm.user_id,
user_name: userForm.user_name,
user_name_eng: userForm.user_name_eng || undefined,
user_password: password || undefined,
email: userForm.email || undefined,
tel: userForm.tel || undefined,
cell_phone: userForm.cell_phone || undefined,
sabun: userForm.sabun || undefined,
position_name: userForm.position_name || undefined,
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
position_name: userForm.position_name || undefined,
} : undefined,
isUpdate: userEditMode,
});
toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다.");
setUserModalOpen(false);
// 우측 새로고침
const code = selectedDeptCode;
setSelectedDeptCode(null);
setTimeout(() => setSelectedDeptCode(code), 50);
fetchMembers();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
@ -241,8 +284,12 @@ export default function DepartmentPage() {
filterId="department"
onFilterChange={setSearchFilters}
dataCount={deptCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
@ -275,10 +322,9 @@ export default function DepartmentPage() {
columns={LEFT_COLUMNS}
data={depts}
loading={deptLoading}
selectedId={selectedDeptCode}
selectedId={selectedDeptId}
onSelect={(id) => {
const dept = depts.find((d) => d.dept_code === id || d.id === id);
setSelectedDeptCode(dept?.dept_code || id);
setSelectedDeptId((prev) => (prev === id ? null : id));
}}
onRowDoubleClick={() => openDeptEdit()}
emptyMessage="등록된 부서가 없습니다"
@ -293,29 +339,25 @@ export default function DepartmentPage() {
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
<Users className="w-4 h-4" />
{selectedDept ? "부서 인원" : "전체 사원"}
{selectedDept && <Badge variant="outline" className="font-normal">{selectedDept.dept_name}</Badge>}
{members.length > 0 && <Badge variant="secondary" className="font-normal">{members.length}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openUserModal}>
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedDeptCode ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<DataGrid
gridId="dept-right"
columns={RIGHT_COLUMNS}
data={members}
loading={memberLoading}
showRowNumber={false}
tableName={USER_TABLE}
emptyMessage="소속 사원이 없습니다"
/>
)}
<DataGrid
gridId="dept-right"
columns={RIGHT_COLUMNS}
data={members}
loading={memberLoading}
showRowNumber={false}
tableName={USER_TABLE}
emptyMessage={selectedDeptCode ? "소속 사원이 없습니다" : "등록된 사원이 없습니다"}
onRowDoubleClick={(row) => openUserModal(row)}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
@ -365,14 +407,14 @@ export default function DepartmentPage() {
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>{selectedDept?.dept_name} .</DialogDescription>
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
<DialogDescription>{userEditMode ? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.` : selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"> ID <span className="text-destructive">*</span></Label>
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
placeholder="사용자 ID" className="h-9" />
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
@ -387,7 +429,7 @@ export default function DepartmentPage() {
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
placeholder="비밀번호" className="h-9" type="password" />
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
@ -443,6 +485,14 @@ export default function DepartmentPage() {
/>
{ConfirmDialogComponent}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={DEPT_TABLE}
settingsId="department"
onSave={applyTableSettings}
/>
</div>
);
}

View File

@ -20,13 +20,14 @@ import {
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search } from "lucide-react";
import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, Settings2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
@ -68,6 +69,8 @@ export default function SubcontractorItemPage() {
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 우측: 외주업체
@ -92,6 +95,15 @@ export default function SubcontractorItemPage() {
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("subcontractor-item");
if (saved) applyTableSettings(saved);
}, []);
// 카테고리 로드
useEffect(() => {
const load = async () => {
@ -296,8 +308,12 @@ export default function SubcontractorItemPage() {
filterId="subcontractor-item"
onFilterChange={setSearchFilters}
dataCount={itemCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
@ -504,6 +520,14 @@ export default function SubcontractorItemPage() {
onSuccess={() => fetchItems()}
/>
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={ITEM_TABLE}
settingsId="subcontractor-item"
onSave={applyTableSettings}
/>
{ConfirmDialogComponent}
</div>
);

View File

@ -24,13 +24,14 @@ import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Wrench, Package, Search, X,
Wrench, Package, Search, X, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
@ -79,6 +80,8 @@ export default function SubcontractorManagementPage() {
const [subcontractorLoading, setSubcontractorLoading] = useState(false);
const [subcontractorCount, setSubcontractorCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedSubcontractorId, setSelectedSubcontractorId] = useState<string | null>(null);
// 우측: 품목 단가
@ -158,6 +161,15 @@ export default function SubcontractorManagementPage() {
load();
}, []);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("subcontractor-mng");
if (saved) applyTableSettings(saved);
}, []);
// 외주업체 목록 조회
const fetchSubcontractors = useCallback(async () => {
setSubcontractorLoading(true);
@ -728,8 +740,12 @@ export default function SubcontractorManagementPage() {
filterId="subcontractor-mng"
onFilterChange={setSearchFilters}
dataCount={subcontractorCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
onClick={async () => {
setExcelDetecting(true);
@ -1136,6 +1152,14 @@ export default function SubcontractorManagementPage() {
/>
)}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={SUBCONTRACTOR_TABLE}
settingsId="subcontractor-mng"
onSave={applyTableSettings}
/>
{ConfirmDialogComponent}
</div>
);

View File

@ -53,6 +53,7 @@ import {
Maximize2,
Minimize2,
Merge,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
@ -80,6 +81,7 @@ import TimelineScheduler, {
type StatusColor,
} from "@/components/common/TimelineScheduler";
import { DynamicSearchFilter, type FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
@ -134,6 +136,8 @@ export default function ProductionPlanManagementPage() {
// 검색 필터 (DynamicSearchFilter에서 사용)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [searchItemCode, setSearchItemCode] = useState("");
const [searchStatus, setSearchStatus] = useState("all");
const [searchStartDate, setSearchStartDate] = useState("");
@ -277,6 +281,15 @@ export default function ProductionPlanManagementPage() {
[]
);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("production-plan");
if (saved) applyTableSettings(saved);
}, []);
// ========== 토글/선택 핸들러 ==========
const toggleItemExpand = useCallback((itemCode: string) => {
@ -879,6 +892,7 @@ export default function ProductionPlanManagementPage() {
filterId="production-plan"
onFilterChange={handleSearchFilterChange}
dataCount={finishedPlans.length + semiPlans.length}
externalFilterConfig={filterConfig}
extraActions={
<Button size="sm" onClick={handleSearch}>
<RefreshCw className="mr-1.5 h-4 w-4" />
@ -887,6 +901,9 @@ export default function ProductionPlanManagementPage() {
}
/>
<div className="flex items-center gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10" onClick={() => setExcelUploadOpen(true)}>
<Upload className="mr-1.5 h-4 w-4" />
@ -1731,6 +1748,14 @@ export default function ProductionPlanManagementPage() {
{/* ConfirmDialog 렌더 */}
{ConfirmDialogComponent}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName="production_plan_mng"
settingsId="production-plan"
onSave={applyTableSettings}
/>
</div>
);
}

View File

@ -25,7 +25,7 @@ import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Users, Package, MapPin, Search, X, Maximize2, Minimize2,
Users, Package, MapPin, Search, X, Maximize2, Minimize2, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@ -40,6 +40,7 @@ import { exportToExcel } from "@/lib/utils/excelExport";
import { validateField, validateForm, formatField } from "@/lib/utils/validation";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
const CUSTOMER_TABLE = "customer_mng";
const MAPPING_TABLE = "customer_item_mapping";
@ -81,6 +82,8 @@ export default function CustomerManagementPage() {
const [customerLoading, setCustomerLoading] = useState(false);
const [customerCount, setCustomerCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
// 우측: 탭
@ -169,6 +172,15 @@ export default function CustomerManagementPage() {
load();
}, []);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("customer-mng");
if (saved) applyTableSettings(saved);
}, []);
// 거래처 목록 조회
const fetchCustomers = useCallback(async () => {
setCustomerLoading(true);
@ -777,8 +789,12 @@ export default function CustomerManagementPage() {
filterId="customer-mng"
onFilterChange={setSearchFilters}
dataCount={customerCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
onClick={async () => {
setExcelDetecting(true);
@ -1271,6 +1287,14 @@ export default function CustomerManagementPage() {
/>
)}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={CUSTOMER_TABLE}
settingsId="customer-mng"
onSave={applyTableSettings}
/>
{ConfirmDialogComponent}
</div>
);

View File

@ -13,7 +13,7 @@ import {
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck,
ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@ -27,6 +27,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
const DETAIL_TABLE = "sales_order_detail";
@ -98,6 +99,39 @@ export default function SalesOrderPage() {
// 체크된 행 (다중선택)
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 테이블 설정
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [gridColumns, setGridColumns] = useState<DataGridColumn[]>(GRID_COLUMNS);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
// 테이블 설정 적용 (컬럼 + 필터)
const applyTableSettings = useCallback((settings: TableSettings) => {
// 컬럼 표시/숨김/순서/너비
const colMap = new Map(GRID_COLUMNS.map((c) => [c.key, c]));
const applied: DataGridColumn[] = [];
for (const cs of settings.columns) {
if (!cs.visible) continue;
const orig = colMap.get(cs.columnName);
if (orig) {
applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined });
}
}
const settingKeys = new Set(settings.columns.map((c) => c.columnName));
for (const col of GRID_COLUMNS) {
if (!settingKeys.has(col.key)) applied.push(col);
}
setGridColumns(applied.length > 0 ? applied : GRID_COLUMNS);
// 필터 설정 → DynamicSearchFilter에 전달
setFilterConfig(settings.filters);
}, []);
// 마운트 시 저장된 설정 복원
useEffect(() => {
const saved = loadTableSettings("sales-order");
if (saved) applyTableSettings(saved);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 카테고리 로드
useEffect(() => {
const loadCategories = async () => {
@ -537,6 +571,7 @@ export default function SalesOrderPage() {
filterId="sales-order"
onFilterChange={setSearchFilters}
dataCount={totalCount}
externalFilterConfig={filterConfig}
/>
{/* 메인 테이블 */}
@ -568,12 +603,15 @@ export default function SalesOrderPage() {
<Button variant="outline" size="sm" disabled={checkedIds.length === 0} onClick={() => setShippingPlanOpen(true)}>
<Truck className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
<Button variant="outline" size="sm" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
</div>
</div>
<DataGrid
gridId="sales-order"
columns={GRID_COLUMNS}
columns={gridColumns}
data={orders}
loading={loading}
showCheckbox
@ -893,6 +931,15 @@ export default function SalesOrderPage() {
onSuccess={() => fetchOrders()}
/>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={DETAIL_TABLE}
settingsId="sales-order"
onSave={applyTableSettings}
/>
{/* 공통 확인 다이얼로그 */}
{ConfirmDialogComponent}
</div>

View File

@ -20,12 +20,13 @@ import {
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, X } from "lucide-react";
import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, X, Settings2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
@ -71,6 +72,10 @@ export default function SalesItemPage() {
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 테이블 설정
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
// 우측: 거래처
const [customerItems, setCustomerItems] = useState<any[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
@ -106,6 +111,17 @@ export default function SalesItemPage() {
const [priceCategoryOptions, setPriceCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [editCustData, setEditCustData] = useState<any>(null);
// 테이블 설정 적용 (필터)
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
// 마운트 시 저장된 설정 복원
useEffect(() => {
const saved = loadTableSettings("sales-item");
if (saved) applyTableSettings(saved);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 카테고리 로드
useEffect(() => {
const load = async () => {
@ -522,8 +538,12 @@ export default function SalesItemPage() {
filterId="sales-item"
onFilterChange={setSearchFilters}
dataCount={itemCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
@ -884,6 +904,14 @@ export default function SalesItemPage() {
/>
{ConfirmDialogComponent}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={ITEM_TABLE}
settingsId="sales-item"
onSave={applyTableSettings}
/>
</div>
);
}

View File

@ -3,11 +3,10 @@
import React, { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react";
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth";
@ -21,8 +20,6 @@ import {
GridMode,
isPopLayout,
createEmptyLayout,
GAP_PRESETS,
GRID_BREAKPOINTS,
BLOCK_GAP,
BLOCK_PADDING,
detectGridMode,
@ -64,7 +61,8 @@ function PopScreenViewPage() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const screenId = parseInt(params.screenId as string);
const screenId = parseInt(params.screenId as string, 10);
const isValidScreenId = !isNaN(screenId) && screenId > 0;
const isPreviewMode = searchParams.get("preview") === "true";
@ -86,26 +84,32 @@ function PopScreenViewPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px)
const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로
// 모드 결정:
// - 프리뷰 모드: 수동 선택한 device/orientation 사용
// - 일반 모드: 화면 너비 기준으로 자동 결정 (GRID_BREAKPOINTS와 일치)
const currentModeKey = isPreviewMode
? getModeKey(deviceType, isLandscape)
: detectGridMode(viewportWidth);
// 실제 브라우저 너비 (모드 감지용)
const [rawWidth, setRawWidth] = useState(1024);
useEffect(() => {
const updateViewportWidth = () => {
setViewportWidth(Math.min(window.innerWidth, 1366));
};
updateViewportWidth();
window.addEventListener("resize", updateViewportWidth);
return () => window.removeEventListener("resize", updateViewportWidth);
const updateWidth = () => setRawWidth(window.innerWidth);
updateWidth();
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}, []);
// 모드 결정
const currentModeKey = isPreviewMode
? getModeKey(deviceType, isLandscape)
: detectGridMode(rawWidth);
// 디자이너와 동일한 기준 너비 사용 (모드별 고정 너비)
const MODE_REFERENCE_WIDTH: Record<GridMode, number> = {
mobile_portrait: 375,
mobile_landscape: 600,
tablet_portrait: 820,
tablet_landscape: 1024,
};
const viewportWidth = isPreviewMode
? DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"].width
: MODE_REFERENCE_WIDTH[currentModeKey];
// 화면 및 POP 레이아웃 로드
useEffect(() => {
const loadScreen = async () => {
@ -122,22 +126,15 @@ function PopScreenViewPage() {
if (popLayout && isPopLayout(popLayout)) {
const v6Layout = loadLegacyLayout(popLayout);
setLayout(v6Layout);
const componentCount = Object.keys(popLayout.components).length;
console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
} else if (popLayout) {
// 다른 버전 레이아웃은 빈 v5로 처리
console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version);
setLayout(createEmptyLayout());
} else {
console.log("[POP] 레이아웃 없음");
setLayout(createEmptyLayout());
}
} catch (layoutError) {
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
} catch {
setLayout(createEmptyLayout());
}
} catch (error) {
console.error("[POP] 화면 로드 실패:", error);
setError("화면을 불러오는데 실패했습니다.");
showErrorToast("POP 화면을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." });
} finally {
@ -145,10 +142,13 @@ function PopScreenViewPage() {
}
};
if (screenId) {
if (isValidScreenId) {
loadScreen();
} else if (params.screenId) {
setError("유효하지 않은 화면 ID입니다.");
setLoading(false);
}
}, [screenId]);
}, [screenId, isValidScreenId]);
// 뷰어 모드에서도 컴포넌트 크기 변경 지원 (더보기 등)
const handleRequestResize = React.useCallback((componentId: string, newRowSpan: number, newColSpan?: number) => {
@ -288,26 +288,13 @@ function PopScreenViewPage() {
</div>
)}
{/* 일반 모드 네비게이션 바 */}
{!isPreviewMode && (
<div className="sticky top-0 z-50 flex h-10 items-center justify-between border-b bg-white/80 px-3 backdrop-blur">
<Button variant="ghost" size="sm" onClick={() => router.push("/pop")} className="gap-1 text-xs">
<LayoutGrid className="h-3.5 w-3.5" />
POP
</Button>
<span className="text-xs text-gray-500">{screen.screenName}</span>
<Button variant="ghost" size="sm" onClick={() => router.push("/")} className="gap-1 text-xs">
<Monitor className="h-3.5 w-3.5" />
PC
</Button>
</div>
)}
{/* 일반 모드 네비게이션 바 제거 (프로필 컴포넌트에서 PC 모드 전환 가능) */}
{/* POP 화면 컨텐츠 */}
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-background"}`}>
<div
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-foreground" : "w-full min-h-full"}`}
className={`bg-background transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-foreground" : "w-full min-h-full"}`}
style={isPreviewMode ? {
width: currentDevice.width,
maxHeight: "80vh",
@ -317,8 +304,8 @@ function PopScreenViewPage() {
{/* v5 그리드 렌더러 */}
{hasComponents ? (
<div
className="mx-auto min-h-full"
style={{ maxWidth: 1366 }}
className="min-h-full"
style={isPreviewMode ? { maxWidth: currentDevice.width, margin: "0 auto" } : undefined}
>
{(() => {
const adjustedGap = BLOCK_GAP;

View File

@ -23,7 +23,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Settings, ChevronsUpDown, RotateCcw } from "lucide-react";
import { Settings, ChevronsUpDown, RotateCcw, Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
@ -50,6 +50,14 @@ export interface FilterValue {
value: string;
}
export interface ExternalFilterConfig {
columnName: string;
displayName: string;
enabled: boolean;
filterType: FilterType;
width: number;
}
export interface DynamicSearchFilterProps {
/** 테이블명 (컬럼 목록 + 카테고리 옵션 로드에 사용) */
tableName: string;
@ -61,6 +69,8 @@ export interface DynamicSearchFilterProps {
dataCount?: number;
/** 추가 액션 버튼 영역 */
extraActions?: React.ReactNode;
/** TableSettingsModal에서 전달된 외부 필터 설정 (제공 시 자체 설정 모달 숨김) */
externalFilterConfig?: ExternalFilterConfig[];
}
const FILTER_TYPE_OPTIONS: { value: FilterType; label: string }[] = [
@ -86,12 +96,14 @@ export function DynamicSearchFilter({
onFilterChange,
dataCount,
extraActions,
externalFilterConfig,
}: DynamicSearchFilterProps) {
const [allColumns, setAllColumns] = useState<FilterColumn[]>([]);
const [activeFilters, setActiveFilters] = useState<FilterColumn[]>([]);
const [filterValues, setFilterValues] = useState<Record<string, any>>({});
const [selectOptions, setSelectOptions] = useState<Record<string, { label: string; value: string }[]>>({});
const [settingsOpen, setSettingsOpen] = useState(false);
const [selectSearchTerms, setSelectSearchTerms] = useState<Record<string, string>>({});
const [tempColumns, setTempColumns] = useState<FilterColumn[]>([]);
const STORAGE_KEY_FILTERS = `dynamic_filter_config_${filterId}`;
@ -149,6 +161,22 @@ export function DynamicSearchFilter({
loadColumns();
}, [tableName, STORAGE_KEY_FILTERS, STORAGE_KEY_VALUES]);
// 외부 필터 설정 적용 (TableSettingsModal에서 전달)
useEffect(() => {
if (!externalFilterConfig) return;
const active: FilterColumn[] = externalFilterConfig
.filter((f) => f.enabled)
.map((f) => ({
columnName: f.columnName,
columnLabel: f.displayName,
originalType: f.filterType,
filterType: f.filterType,
enabled: true,
width: f.width,
}));
setActiveFilters(active);
}, [externalFilterConfig]);
// select 타입 필터의 옵션 로드
useEffect(() => {
const loadOptions = async () => {
@ -305,9 +333,22 @@ export function DynamicSearchFilter({
handleValueChange(filter.columnName, next.length > 0 ? next : "");
};
const searchTerm = (selectSearchTerms[filter.columnName] || "").toLowerCase();
const filteredOptions = searchTerm
? options.filter((opt) => opt.label.toLowerCase().includes(searchTerm))
: options;
return (
<div style={widthStyle}>
<Popover>
<Popover onOpenChange={(open) => {
if (!open) {
setSelectSearchTerms((prev) => {
const next = { ...prev };
delete next[filter.columnName];
return next;
});
}
}}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox"
className={cn("h-9 w-full justify-between text-sm font-normal", selectedValues.length === 0 && "text-muted-foreground")}>
@ -316,10 +357,37 @@ export function DynamicSearchFilter({
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
{options.length > 5 && (
<div className="border-b p-1">
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2" />
<Input
value={selectSearchTerms[filter.columnName] || ""}
onChange={(e) =>
setSelectSearchTerms((prev) => ({ ...prev, [filter.columnName]: e.target.value }))
}
placeholder="검색..."
className="h-7 pl-7 pr-7 text-xs"
/>
{selectSearchTerms[filter.columnName] && (
<button
onClick={() =>
setSelectSearchTerms((prev) => ({ ...prev, [filter.columnName]: "" }))
}
className="absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="text-muted-foreground hover:text-foreground h-3 w-3" />
</button>
)}
</div>
</div>
)}
<div className="max-h-60 overflow-auto p-1">
{options.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
) : options.map((opt, i) => (
) : filteredOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
) : filteredOptions.map((opt, i) => (
<div key={`${opt.value}-${i}`}
className="hover:bg-accent flex cursor-pointer items-center space-x-2 rounded-sm px-2 py-1.5"
onClick={() => toggleOption(opt.value, !selectedValues.includes(opt.value))}>
@ -376,9 +444,11 @@ export function DynamicSearchFilter({
</div>
)}
{extraActions}
<Button variant="outline" size="sm" onClick={openSettings} className="h-9">
<Settings className="mr-1 h-3.5 w-3.5" />
</Button>
{!externalFilterConfig && (
<Button variant="outline" size="sm" onClick={openSettings} className="h-9">
<Settings className="mr-1 h-3.5 w-3.5" />
</Button>
)}
</div>
{/* 필터 설정 모달 */}

View File

@ -0,0 +1,569 @@
"use client";
/**
* TableSettingsModal -- (3)
*
* 1: 컬럼 -- /, , (px) ,
* 2: 필터 -- /, (//), (%) ,
* 3: 그룹 --
*
* localStorage에 , onSave
* DynamicSearchFilter, DataGrid와
*/
import React, { useState, useEffect } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { GripVertical, Settings2, SlidersHorizontal, Layers, RotateCcw, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import {
DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext, verticalListSortingStrategy, useSortable, arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// ===== 타입 =====
export interface ColumnSetting {
columnName: string;
displayName: string;
visible: boolean;
width: number;
}
export interface FilterSetting {
columnName: string;
displayName: string;
enabled: boolean;
filterType: "text" | "select" | "date";
width: number;
}
export interface GroupSetting {
columnName: string;
displayName: string;
enabled: boolean;
}
export interface TableSettings {
columns: ColumnSetting[];
filters: FilterSetting[];
groups: GroupSetting[];
frozenCount: number;
groupSumEnabled: boolean;
}
export interface TableSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 테이블명 (web-types API 호출용) */
tableName: string;
/** localStorage 키 분리용 고유 ID */
settingsId: string;
/** 저장 시 콜백 */
onSave?: (settings: TableSettings) => void;
/** 초기 탭 */
initialTab?: "columns" | "filters" | "groups";
}
// ===== 상수 =====
const FILTER_TYPE_OPTIONS = [
{ value: "text", label: "텍스트" },
{ value: "select", label: "선택" },
{ value: "date", label: "날짜" },
];
const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"];
// ===== 유틸 =====
function getStorageKey(settingsId: string) {
return `table_settings_${settingsId}`;
}
/** localStorage에서 저장된 설정 로드 (외부에서도 사용 가능) */
export function loadTableSettings(settingsId: string): TableSettings | null {
try {
const raw = localStorage.getItem(getStorageKey(settingsId));
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
/** 저장된 컬럼 순서/설정을 API 컬럼과 병합 */
function mergeColumns(fresh: ColumnSetting[], saved: ColumnSetting[]): ColumnSetting[] {
const savedMap = new Map(saved.map((s) => [s.columnName, s]));
const ordered: ColumnSetting[] = [];
// 저장된 순서대로
for (const s of saved) {
const f = fresh.find((c) => c.columnName === s.columnName);
if (f) ordered.push({ ...f, visible: s.visible, width: s.width });
}
// 새로 추가된 컬럼은 맨 뒤에
for (const f of fresh) {
if (!savedMap.has(f.columnName)) ordered.push(f);
}
return ordered;
}
// ===== Sortable Column Row (탭 1) =====
function SortableColumnRow({
col,
onToggleVisible,
onWidthChange,
}: {
col: ColumnSetting & { _idx: number };
onToggleVisible: (idx: number) => void;
onWidthChange: (idx: number, width: number) => void;
}) {
const {
attributes, listeners, setNodeRef, transform, transition, isDragging,
} = useSortable({ id: col.columnName });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center gap-3 py-2 px-2 rounded hover:bg-muted/50",
isDragging && "bg-muted/50 shadow-md",
)}
>
{/* 드래그 핸들 */}
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground"
>
<GripVertical className="h-4 w-4" />
</button>
{/* 표시 체크박스 */}
<Checkbox
checked={col.visible}
onCheckedChange={() => onToggleVisible(col._idx)}
/>
{/* 표시 토글 (Switch) */}
<Switch
checked={col.visible}
onCheckedChange={() => onToggleVisible(col._idx)}
className="shrink-0"
/>
{/* 컬럼명 + 기술명 */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{col.displayName}</div>
<div className="text-xs text-muted-foreground truncate">{col.columnName}</div>
</div>
{/* 너비 입력 */}
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-muted-foreground">:</span>
<Input
type="number"
value={col.width}
onChange={(e) => onWidthChange(col._idx, Number(e.target.value) || 100)}
className="h-8 w-[70px] text-xs text-center"
min={50}
max={500}
/>
</div>
</div>
);
}
// ===== TableSettingsModal =====
export function TableSettingsModal({
open,
onOpenChange,
tableName,
settingsId,
onSave,
initialTab = "columns",
}: TableSettingsModalProps) {
const [activeTab, setActiveTab] = useState(initialTab);
const [loading, setLoading] = useState(false);
// 임시 설정 (모달 내에서만 수정, 저장 시 반영)
const [tempColumns, setTempColumns] = useState<ColumnSetting[]>([]);
const [tempFilters, setTempFilters] = useState<FilterSetting[]>([]);
const [tempGroups, setTempGroups] = useState<GroupSetting[]>([]);
const [tempFrozenCount, setTempFrozenCount] = useState(0);
const [tempGroupSum, setTempGroupSum] = useState(false);
// 원본 컬럼 (초기화용)
const [defaultColumns, setDefaultColumns] = useState<ColumnSetting[]>([]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
// 모달 열릴 때 데이터 로드
useEffect(() => {
if (!open) return;
setActiveTab(initialTab);
loadData();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const loadData = async () => {
setLoading(true);
try {
const res = await apiClient.get(`/table-management/tables/${tableName}/web-types`);
const types: any[] = res.data?.data || [];
// 기본 컬럼 설정 생성
const freshColumns: ColumnSetting[] = types
.filter((t) => !AUTO_COLS.includes(t.columnName))
.map((t) => ({
columnName: t.columnName,
displayName: t.displayName || t.columnLabel || t.columnName,
visible: true,
width: 120,
}));
// 기본 필터 설정 생성
const freshFilters: FilterSetting[] = freshColumns.map((c) => {
const wt = types.find((t) => t.columnName === c.columnName);
let filterType: "text" | "select" | "date" = "text";
if (wt?.inputType === "category" || wt?.inputType === "select") filterType = "select";
else if (wt?.inputType === "date" || wt?.inputType === "datetime") filterType = "date";
return {
columnName: c.columnName,
displayName: c.displayName,
enabled: false,
filterType,
width: 25,
};
});
// 기본 그룹 설정 생성
const freshGroups: GroupSetting[] = freshColumns.map((c) => ({
columnName: c.columnName,
displayName: c.displayName,
enabled: false,
}));
setDefaultColumns(freshColumns);
// localStorage에서 저장된 설정 복원
const saved = loadTableSettings(settingsId);
if (saved) {
setTempColumns(mergeColumns(freshColumns, saved.columns));
setTempFilters(freshFilters.map((f) => {
const s = saved.filters?.find((sf) => sf.columnName === f.columnName);
return s ? { ...f, enabled: s.enabled, filterType: s.filterType, width: s.width } : f;
}));
setTempGroups(freshGroups.map((g) => {
const s = saved.groups?.find((sg) => sg.columnName === g.columnName);
return s ? { ...g, enabled: s.enabled } : g;
}));
setTempFrozenCount(saved.frozenCount || 0);
setTempGroupSum(saved.groupSumEnabled || false);
} else {
setTempColumns(freshColumns);
setTempFilters(freshFilters);
setTempGroups(freshGroups);
setTempFrozenCount(0);
setTempGroupSum(false);
}
} catch (err) {
console.error("테이블 설정 로드 실패:", err);
} finally {
setLoading(false);
}
};
// 저장
const handleSave = () => {
const settings: TableSettings = {
columns: tempColumns,
filters: tempFilters,
groups: tempGroups,
frozenCount: tempFrozenCount,
groupSumEnabled: tempGroupSum,
};
localStorage.setItem(getStorageKey(settingsId), JSON.stringify(settings));
onSave?.(settings);
onOpenChange(false);
};
// 컬럼 설정 초기화
const handleResetColumns = () => {
setTempColumns(defaultColumns.map((c) => ({ ...c })));
setTempFrozenCount(0);
};
// ===== 컬럼 설정 핸들러 =====
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setTempColumns((prev) => {
const oldIdx = prev.findIndex((c) => c.columnName === active.id);
const newIdx = prev.findIndex((c) => c.columnName === over.id);
return arrayMove(prev, oldIdx, newIdx);
});
};
const toggleColumnVisible = (idx: number) => {
setTempColumns((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], visible: !next[idx].visible };
return next;
});
};
const changeColumnWidth = (idx: number, width: number) => {
setTempColumns((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], width };
return next;
});
};
// ===== 필터 설정 핸들러 =====
const allFiltersEnabled = tempFilters.length > 0 && tempFilters.every((f) => f.enabled);
const toggleFilterAll = (checked: boolean) => {
setTempFilters((prev) => prev.map((f) => ({ ...f, enabled: checked })));
};
const toggleFilter = (idx: number) => {
setTempFilters((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], enabled: !next[idx].enabled };
return next;
});
};
const changeFilterType = (idx: number, filterType: "text" | "select" | "date") => {
setTempFilters((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], filterType };
return next;
});
};
const changeFilterWidth = (idx: number, width: number) => {
setTempFilters((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], width };
return next;
});
};
// ===== 그룹 설정 핸들러 =====
const toggleGroup = (idx: number) => {
setTempGroups((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], enabled: !next[idx].enabled };
return next;
});
};
const visibleCount = tempColumns.filter((c) => c.visible).length;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> , , </DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">
...
</div>
) : (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as typeof activeTab)} className="flex-1 flex flex-col min-h-0">
<TabsList className="grid w-full grid-cols-3 shrink-0">
<TabsTrigger value="columns" className="flex items-center gap-1.5">
<Settings2 className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="filters" className="flex items-center gap-1.5">
<SlidersHorizontal className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="groups" className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
{/* ===== 탭 1: 컬럼 설정 ===== */}
<TabsContent value="columns" className="flex-1 overflow-auto mt-0 pt-3">
{/* 헤더: 표시 수 / 틀고정 / 초기화 */}
<div className="flex items-center justify-between px-2 pb-3 border-b mb-2">
<div className="flex items-center gap-3 text-sm">
<span>
{visibleCount}/{tempColumns.length}
</span>
<div className="flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">:</span>
<Input
type="number"
value={tempFrozenCount}
onChange={(e) =>
setTempFrozenCount(
Math.min(Math.max(0, Number(e.target.value) || 0), tempColumns.length)
)
}
className="h-7 w-[50px] text-xs text-center"
min={0}
max={tempColumns.length}
/>
<span className="text-muted-foreground text-sm"> </span>
</div>
</div>
<Button variant="ghost" size="sm" onClick={handleResetColumns} className="text-xs">
</Button>
</div>
{/* 컬럼 목록 (드래그 순서 변경 가능) */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={tempColumns.map((c) => c.columnName)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-0.5">
{tempColumns.map((col, idx) => (
<SortableColumnRow
key={col.columnName}
col={{ ...col, _idx: idx }}
onToggleVisible={toggleColumnVisible}
onWidthChange={changeColumnWidth}
/>
))}
</div>
</SortableContext>
</DndContext>
</TabsContent>
{/* ===== 탭 2: 필터 설정 ===== */}
<TabsContent value="filters" className="flex-1 overflow-auto mt-0 pt-3">
{/* 전체 선택 */}
<div
className="flex items-center gap-2 px-2 pb-3 border-b mb-2 cursor-pointer"
onClick={() => toggleFilterAll(!allFiltersEnabled)}
>
<Checkbox checked={allFiltersEnabled} />
<span className="text-sm"> </span>
</div>
{/* 필터 목록 */}
<div className="space-y-1">
{tempFilters.map((filter, idx) => (
<div
key={filter.columnName}
className="flex items-center gap-3 py-1.5 px-2 hover:bg-muted/50 rounded"
>
<Checkbox
checked={filter.enabled}
onCheckedChange={() => toggleFilter(idx)}
/>
<div className="flex-1 text-sm min-w-0 truncate">{filter.displayName}</div>
<Select
value={filter.filterType}
onValueChange={(v) => changeFilterType(idx, v as any)}
>
<SelectTrigger className="h-8 w-[90px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1 shrink-0">
<Input
type="number"
value={filter.width}
onChange={(e) => changeFilterWidth(idx, Number(e.target.value) || 25)}
className="h-8 w-[55px] text-xs text-center"
min={10}
max={100}
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
))}
</div>
{/* 그룹별 합산 토글 */}
<div className="mt-4 flex items-center justify-between rounded-lg border p-3">
<div>
<div className="text-sm font-medium"> </div>
<div className="text-xs text-muted-foreground"> </div>
</div>
<Switch checked={tempGroupSum} onCheckedChange={setTempGroupSum} />
</div>
</TabsContent>
{/* ===== 탭 3: 그룹 설정 ===== */}
<TabsContent value="groups" className="flex-1 overflow-auto mt-0 pt-3">
<div className="px-2 pb-3 border-b mb-2">
<span className="text-sm font-medium"> </span>
</div>
<div className="space-y-0.5">
{tempGroups.map((group, idx) => (
<div
key={group.columnName}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded cursor-pointer hover:bg-muted/50",
group.enabled && "bg-primary/5",
)}
onClick={() => toggleGroup(idx)}
>
<Checkbox checked={group.enabled} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{group.displayName}</div>
<div className="text-xs text-muted-foreground truncate">{group.columnName}</div>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -70,8 +70,8 @@ const COMPONENT_TYPE_LABELS: Record<string, string> = {
"pop-text": "텍스트",
"pop-icon": "아이콘",
"pop-dashboard": "대시보드",
"pop-card-list": "카드 목록",
"pop-card-list-v2": "카드 목록 V2",
"pop-card-list": "장바구니 목록",
"pop-card-list-v2": "MES 공정흐름",
"pop-field": "필드",
"pop-button": "버튼",
"pop-string-list": "리스트 목록",

View File

@ -41,13 +41,13 @@ const PALETTE_ITEMS: PaletteItem[] = [
},
{
type: "pop-card-list",
label: "카드 목록",
label: "장바구니 목록",
icon: LayoutGrid,
description: "테이블 데이터를 카드 형태로 표시",
},
{
type: "pop-card-list-v2",
label: "카드 목록 V2",
label: "MES 공정흐름",
icon: LayoutGrid,
description: "슬롯 기반 카드 (CSS Grid + 셀 타입별 렌더링)",
},

View File

@ -4,7 +4,6 @@ import React from "react";
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@ -172,7 +171,7 @@ function SendSection({
</span>
{conn.filterConfig.isSubTable && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] text-amber-700">
</span>
)}
</div>
@ -229,9 +228,6 @@ function SimpleConnectionForm({
const [selectedTargetId, setSelectedTargetId] = React.useState(
initial?.targetComponent || ""
);
const [isSubTable, setIsSubTable] = React.useState(
initial?.filterConfig?.isSubTable || false
);
const [targetColumn, setTargetColumn] = React.useState(
initial?.filterConfig?.targetColumn || ""
);
@ -255,23 +251,34 @@ function SimpleConnectionForm({
&& targetReg?.connectionMeta?.receivable?.some((r) => r.type === "filter_value");
const subTableName = targetComp ? extractSubTableName(targetComp) : null;
const mainTableName = (() => {
const cfg = targetComp?.config as Record<string, unknown> | undefined;
const ds = cfg?.dataSource as { tableName?: string } | undefined;
return ds?.tableName || null;
})();
React.useEffect(() => {
if (!isSubTable || !subTableName) {
if (!isFilterConnection || !selectedTargetId) {
setSubColumns([]);
return;
}
const tables = [mainTableName, subTableName].filter(Boolean) as string[];
if (tables.length === 0) { setSubColumns([]); return; }
setLoadingColumns(true);
getTableColumns(subTableName)
.then((res) => {
const cols = res.success && res.data?.columns;
if (Array.isArray(cols)) {
setSubColumns(cols.map((c) => c.columnName || "").filter(Boolean));
Promise.all(tables.map((t) => getTableColumns(t)))
.then((results) => {
const allCols = new Set<string>();
for (const res of results) {
const cols = res.success && res.data?.columns;
if (Array.isArray(cols)) {
cols.forEach((c) => { if (c.columnName) allCols.add(c.columnName); });
}
}
setSubColumns([...allCols].sort());
})
.catch(() => setSubColumns([]))
.finally(() => setLoadingColumns(false));
}, [isSubTable, subTableName]);
}, [isFilterConnection, selectedTargetId, mainTableName, subTableName]);
const handleSubmit = () => {
if (!selectedTargetId) return;
@ -290,11 +297,10 @@ function SimpleConnectionForm({
label: `${srcLabel}${tgtLabel}`,
};
if (isFilterConnection && isSubTable && targetColumn) {
if (isFilterConnection && targetColumn) {
conn.filterConfig = {
targetColumn,
filterMode: filterMode as "equals" | "contains" | "starts_with" | "range",
isSubTable: true,
};
}
@ -302,7 +308,6 @@ function SimpleConnectionForm({
if (!initial) {
setSelectedTargetId("");
setIsSubTable(false);
setTargetColumn("");
setFilterMode("equals");
}
@ -328,7 +333,6 @@ function SimpleConnectionForm({
value={selectedTargetId}
onValueChange={(v) => {
setSelectedTargetId(v);
setIsSubTable(false);
setTargetColumn("");
}}
>
@ -345,62 +349,47 @@ function SimpleConnectionForm({
</Select>
</div>
{isFilterConnection && selectedTargetId && subTableName && (
{isFilterConnection && selectedTargetId && (
<div className="space-y-2 rounded bg-muted/50 p-2">
<div className="flex items-center gap-2">
<Checkbox
id={`isSubTable_${component.id}`}
checked={isSubTable}
onCheckedChange={(v) => {
setIsSubTable(v === true);
if (!v) setTargetColumn("");
}}
/>
<label htmlFor={`isSubTable_${component.id}`} className="text-[10px] text-muted-foreground cursor-pointer">
({subTableName})
</label>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
{loadingColumns ? (
<div className="flex items-center gap-1 py-1">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : (
<Select value={targetColumn} onValueChange={setTargetColumn}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subColumns.filter(Boolean).map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{isSubTable && (
<div className="space-y-2 pl-5">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
{loadingColumns ? (
<div className="flex items-center gap-1 py-1">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : (
<Select value={targetColumn} onValueChange={setTargetColumn}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subColumns.filter(Boolean).map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={filterMode} onValueChange={setFilterMode}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals" className="text-xs"> (equals)</SelectItem>
<SelectItem value="contains" className="text-xs"> (contains)</SelectItem>
<SelectItem value="starts_with" className="text-xs"> (starts_with)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={filterMode} onValueChange={setFilterMode}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals" className="text-xs"> (equals)</SelectItem>
<SelectItem value="contains" className="text-xs"> (contains)</SelectItem>
<SelectItem value="starts_with" className="text-xs"> (starts_with)</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-[9px] text-muted-foreground">
/
</p>
</div>
)}

View File

@ -75,8 +75,8 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-text": "텍스트",
"pop-icon": "아이콘",
"pop-dashboard": "대시보드",
"pop-card-list": "카드 목록",
"pop-card-list-v2": "카드 목록 V2",
"pop-card-list": "장바구니 목록",
"pop-card-list-v2": "MES 공정흐름",
"pop-button": "버튼",
"pop-string-list": "리스트 목록",
"pop-search": "검색",
@ -145,13 +145,9 @@ export default function PopRenderer({
return Math.max(10, maxRowEnd + 3);
}, [components, overrides, mode, hiddenIds]);
// V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE
const rowTemplate = isDesignMode
? `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)`
: `repeat(${dynamicRowCount}, minmax(${BLOCK_SIZE}px, auto))`;
const autoRowHeight = isDesignMode
? `${BLOCK_SIZE}px`
: `minmax(${BLOCK_SIZE}px, auto)`;
// V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE (디자이너/뷰어 동일 = WYSIWYG)
const rowTemplate = `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)`;
const autoRowHeight = `${BLOCK_SIZE}px`;
const gridStyle = useMemo((): React.CSSProperties => ({
display: "grid",
@ -161,7 +157,7 @@ export default function PopRenderer({
gap: `${finalGap}px`,
padding: `${finalPadding}px`,
minHeight: "100%",
backgroundColor: "#ffffff",
backgroundColor: "hsl(var(--background))",
position: "relative",
}), [columns, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]);
@ -296,11 +292,20 @@ export default function PopRenderer({
);
}
// 뷰어 모드: 드래그 없는 일반 렌더링 (overflow visible로 컨텐츠 확장 허용)
// 콘텐츠 영역 컴포넌트는 라운드 테두리 표시
const contentTypes = new Set([
"pop-dashboard", "pop-card-list", "pop-card-list-v2",
"pop-string-list", "pop-work-detail", "pop-sample",
]);
const needsBorder = contentTypes.has(comp.type);
return (
<div
key={comp.id}
className="relative overflow-hidden rounded-lg border-2 border-border bg-white transition-all z-10"
className={cn(
"relative overflow-hidden transition-all z-10",
needsBorder && "rounded-lg border border-border/40 bg-card"
)}
style={positionStyle}
>
<ComponentContent

View File

@ -171,6 +171,80 @@ function SectionHeader({
);
}
// ─── 화면 선택 Combobox ───
const ScreenSelector: React.FC<{
value?: number;
onChange: (screenId?: number) => void;
}> = ({ value, onChange }) => {
const [open, setOpen] = useState(false);
const [screens, setScreens] = useState<Array<{ screenId: number; screenName: string; screenCode: string }>>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadScreens = async () => {
setLoading(true);
try {
const { screenApi } = await import("@/lib/api/screen");
const response = await screenApi.getScreens({ page: 1, size: 1000 });
setScreens(
response.data.map((s: any) => ({ screenId: s.screenId, screenName: s.screenName, screenCode: s.screenCode })),
);
} catch (error) {
console.error("화면 목록 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadScreens();
}, []);
const selectedScreen = screens.find((s) => s.screenId === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{loading ? "로딩 중..." : selectedScreen ? selectedScreen.screenName : "화면 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="화면 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-6 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-auto">
{screens.map((screen) => (
<CommandItem
key={screen.screenId}
value={`${screen.screenName.toLowerCase()} ${screen.screenCode.toLowerCase()} ${screen.screenId}`}
onSelect={() => {
onChange(screen.screenId === value ? undefined : screen.screenId);
setOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-4 w-4", value === screen.screenId ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{screen.screenName}</span>
<span className="text-muted-foreground text-[10px]">{screen.screenCode}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
// ─── 수평 Switch Row (토스 패턴) ───
function SwitchRow({
label,
@ -2002,6 +2076,23 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<V2SplitPanelLayoutConfigPan
checked={tab.showAdd ?? false}
onCheckedChange={(checked) => updateTab(tabIndex, { showAdd: checked })}
/>
{tab.showAdd && (
<div className="border-primary/20 ml-4 space-y-2 border-l-2 pl-3 pb-1">
<span className="text-muted-foreground text-[11px]"> </span>
<ScreenSelector
value={tab.addButton?.modalScreenId}
onChange={(screenId) => {
updateTab(tabIndex, {
addButton: {
enabled: true,
mode: screenId ? "modal" : "auto",
modalScreenId: screenId,
},
});
}}
/>
</div>
)}
<SwitchRow
label="삭제"
checked={tab.showDelete ?? false}

View File

@ -1745,7 +1745,48 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
(panel: "left" | "right") => {
console.log("🆕 [추가모달] handleAddClick 호출:", { panel, activeTabIndex });
// screenId 기반 모달 확인
// 추가 탭의 addButton.modalScreenId 확인
if (panel === "right" && activeTabIndex > 0) {
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
if (tabConfig?.addButton?.mode === "modal" && tabConfig.addButton.modalScreenId) {
if (!selectedLeftItem) {
toast({
title: "항목을 선택해주세요",
description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.",
variant: "destructive",
});
return;
}
const tableName = tabConfig.tableName || "";
const urlParams: Record<string, any> = { mode: "add", tableName };
const parentData: Record<string, any> = {};
if (selectedLeftItem) {
const relation = tabConfig.relation;
if (relation?.keys && Array.isArray(relation.keys)) {
for (const key of relation.keys) {
if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) {
parentData[key.rightColumn] = selectedLeftItem[key.leftColumn];
}
}
}
}
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId: tabConfig.addButton.modalScreenId,
urlParams,
splitPanelParentData: parentData,
},
}),
);
return;
}
}
// screenId 기반 모달 확인 (기본 패널)
const panelConfig = panel === "left" ? componentConfig.leftPanel : componentConfig.rightPanel;
const addModalConfig = panelConfig?.addModal;

View File

@ -675,8 +675,50 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
)}
</div>
{/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */}
{/* ===== 7. 추가 버튼 설정 (showAdd일 때) ===== */}
{tab.showAdd && (
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
<Label className="text-xs font-semibold text-purple-700"> </Label>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={tab.addButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") => {
updateTab({
addButton: { ...tab.addButton, enabled: true, mode: value },
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ()</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
</div>
{tab.addButton?.mode === "modal" && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ScreenSelector
value={tab.addButton?.modalScreenId}
onChange={(screenId) => {
updateTab({
addButton: { ...tab.addButton, enabled: true, mode: "modal", modalScreenId: screenId },
});
}}
/>
</div>
)}
</div>
</div>
)}
{/* ===== 7-1. 추가 모달 컬럼 설정 (showAdd && mode=auto일 때) ===== */}
{tab.showAdd && (!tab.addButton?.mode || tab.addButton?.mode === "auto") && (
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold text-purple-700"> </Label>

View File

@ -63,6 +63,12 @@ export interface AdditionalTabConfig {
}>;
};
addButton?: {
enabled: boolean;
mode: "auto" | "modal";
modalScreenId?: number;
};
addConfig?: {
targetTable?: string;
autoFillColumns?: Record<string, any>;

View File

@ -651,8 +651,12 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
const { subscribe, publish } = usePopEvent(screenId || "default");
// 장바구니 모드 상태
const isCartMode = config?.preset === "cart";
// 장바구니 모드 상태 (v1 preset 또는 v2 tasks에 cart-save가 있으면 활성)
const v2Tasks = (config && "tasks" in config && Array.isArray((config as any).tasks))
? (config as any).tasks as PopButtonTask[]
: null;
const hasCartSaveTask = !!v2Tasks?.some((t) => t.type === "cart-save");
const isCartMode = config?.preset === "cart" || hasCartSaveTask;
const isInboundConfirmMode = config?.preset === "inbound-confirm";
const [cartCount, setCartCount] = useState(0);
const [cartIsDirty, setCartIsDirty] = useState(false);
@ -746,8 +750,10 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
}, [isCartMode, componentId, subscribe]);
// 저장 완료 수신 (카드 목록에서 saveToDb 완료 후 전달)
const cartScreenIdRef = React.useRef(config?.cart?.cartScreenId);
cartScreenIdRef.current = config?.cart?.cartScreenId;
const resolvedCartScreenId = config?.cart?.cartScreenId
|| v2Tasks?.find((t) => t.type === "cart-save")?.cartScreenId;
const cartScreenIdRef = React.useRef(resolvedCartScreenId);
cartScreenIdRef.current = resolvedCartScreenId;
useEffect(() => {
if (!isCartMode || !componentId) return;
@ -990,7 +996,28 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
return;
}
// v2 경로: tasks 배열이 있으면 새 실행 엔진 사용
// 장바구니 모드 (v1 preset: "cart" 또는 v2 tasks에 cart-save 포함)
if (isCartMode) {
if (cartCount === 0 && !cartIsDirty) {
toast.info("장바구니가 비어 있습니다.");
return;
}
if (cartIsDirty) {
setShowCartConfirm(true);
} else {
const targetScreenId = resolvedCartScreenId;
if (targetScreenId) {
const cleanId = String(targetScreenId).replace(/^.*\/(\d+)$/, "$1").trim();
window.location.href = `/pop/screens/${cleanId}`;
} else {
toast.info("장바구니 화면이 설정되지 않았습니다.");
}
}
return;
}
// v2 경로: tasks 배열이 있으면 새 실행 엔진 사용 (cart-save 제외)
if (v2Config) {
if (v2Config.confirm?.enabled) {
setShowInboundConfirm(true);
@ -1012,27 +1039,6 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
return;
}
// 장바구니 모드: isDirty 여부에 따라 분기
if (isCartMode) {
if (cartCount === 0 && !cartIsDirty) {
toast.info("장바구니가 비어 있습니다.");
return;
}
if (cartIsDirty) {
setShowCartConfirm(true);
} else {
const targetScreenId = config?.cart?.cartScreenId;
if (targetScreenId) {
const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim();
window.location.href = `/pop/screens/${cleanId}`;
} else {
toast.info("장바구니 화면이 설정되지 않았습니다.");
}
}
return;
}
const action = config?.action;
if (!action) return;
@ -1072,10 +1078,10 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
const cartButtonClass = useMemo(() => {
if (!isCartMode) return "";
if (cartCount > 0 && !cartIsDirty) {
return "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600";
return "bg-primary hover:bg-primary/90 text-primary-foreground border-primary";
}
if (cartIsDirty) {
return "bg-amber-500 hover:bg-orange-600 text-white border-orange-500 animate-pulse";
return "bg-warning hover:bg-warning/90 text-warning-foreground border-warning animate-pulse";
}
return "";
}, [isCartMode, cartCount, cartIsDirty]);
@ -1089,19 +1095,19 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
// 데이터 작업 버튼 2상태 색상: 미선택(기본) / 선택됨(초록)
const inboundButtonClass = useMemo(() => {
if (isCartMode) return "";
return inboundSelectedCount > 0 ? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600" : "";
return inboundSelectedCount > 0 ? "bg-primary hover:bg-primary/90 text-primary-foreground border-primary" : "";
}, [isCartMode, inboundSelectedCount]);
return (
<>
<div className="flex h-full w-full items-center justify-center">
<div className="relative">
<div className="flex h-full w-full items-center justify-center overflow-hidden">
<div className="relative max-h-full">
<Button
variant={variant}
onClick={handleClick}
disabled={isLoading || cartSaving || confirmProcessing}
className={cn(
"transition-transform active:scale-95",
"max-h-full transition-transform active:scale-95",
isIconOnly && "px-2",
cartButtonClass,
inboundButtonClass,
@ -1121,8 +1127,8 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
{isCartMode && cartCount > 0 && (
<div
className={cn(
"absolute -top-2 -right-2 flex items-center justify-center rounded-full text-[10px] font-bold",
cartIsDirty ? "bg-amber-500 text-white" : "bg-emerald-600 text-white",
"absolute top-0 right-0 translate-x-1/3 -translate-y-1/3 flex items-center justify-center rounded-full text-[10px] font-bold",
cartIsDirty ? "bg-warning text-warning-foreground" : "bg-primary text-primary-foreground",
)}
style={{ minWidth: 18, height: 18, padding: "0 4px" }}
>
@ -1133,7 +1139,7 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
{/* 선택 개수 배지 (v1 inbound-confirm + v2 data tasks) */}
{!isCartMode && hasDataTasks && inboundSelectedCount > 0 && (
<div
className="absolute -top-2 -right-2 flex items-center justify-center rounded-full bg-emerald-600 text-[10px] font-bold text-white"
className="absolute top-0 right-0 translate-x-1/3 -translate-y-1/3 flex items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground"
style={{ minWidth: 18, height: 18, padding: "0 4px" }}
>
{inboundSelectedCount}

View File

@ -48,7 +48,6 @@ import type {
CardSortConfig,
V2OverflowConfig,
V2CardClickAction,
V2CardClickModalConfig,
ActionButtonUpdate,
TimelineDataSource,
StatusValueMapping,
@ -117,37 +116,35 @@ const V2_DEFAULT_CONFIG: PopCardListV2Config = {
cardGap: 8,
scrollDirection: "vertical",
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
cardClickAction: "none",
cardClickAction: "modal-open",
};
// ===== 탭 정의 =====
type V2ConfigTab = "data" | "design" | "actions";
type V2ConfigTab = "info" | "actions";
const TAB_LABELS: { id: V2ConfigTab; label: string }[] = [
{ id: "data", label: "데이터" },
{ id: "design", label: "카드 디자인" },
{ id: "info", label: "정보" },
{ id: "actions", label: "동작" },
];
// ===== 셀 타입 라벨 =====
const V2_CELL_TYPE_LABELS: Record<CardCellType, { label: string; group: string }> = {
const V2_CELL_TYPE_LABELS: Record<string, { label: string; group: string }> = {
text: { label: "텍스트", group: "기본" },
field: { label: "필드 (라벨+값)", group: "기본" },
image: { label: "이미지", group: "기본" },
badge: { label: "배지", group: "기본" },
button: { label: "버튼", group: "동작" },
"number-input": { label: "숫자 입력", group: "입력" },
"cart-button": { label: "담기 버튼", group: "입력" },
"package-summary": { label: "포장 요약", group: "요약" },
"status-badge": { label: "상태 배지", group: "표시" },
timeline: { label: "타임라인", group: "표시" },
"footer-status": { label: "하단 상태", group: "표시" },
"action-buttons": { label: "액션 버튼", group: "동작" },
"process-qty-summary": { label: "공정 수량 요약", group: "표시" },
"mes-process-card": { label: "MES 공정 카드", group: "표시" },
};
const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작", "요약"] as const;
const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작"] as const;
// ===== 그리드 유틸 =====
@ -197,10 +194,8 @@ const shortType = (t: string): string => {
// ===== 메인 컴포넌트 =====
export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) {
const [tab, setTab] = useState<V2ConfigTab>("data");
const [tables, setTables] = useState<TableInfo[]>([]);
const [tab, setTab] = useState<V2ConfigTab>("info");
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
const cfg: PopCardListV2Config = {
...V2_DEFAULT_CONFIG,
@ -215,28 +210,12 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps)
};
useEffect(() => {
fetchTableList()
.then(setTables)
.catch(() => setTables([]));
}, []);
useEffect(() => {
if (!cfg.dataSource.tableName) {
setColumns([]);
return;
}
if (!cfg.dataSource.tableName) { setColumns([]); return; }
fetchTableColumns(cfg.dataSource.tableName)
.then(setColumns)
.catch(() => setColumns([]));
}, [cfg.dataSource.tableName]);
useEffect(() => {
if (cfg.selectedColumns && cfg.selectedColumns.length > 0) {
setSelectedColumns(cfg.selectedColumns);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cfg.dataSource.tableName]);
return (
<div className="flex flex-col gap-3">
{/* 탭 바 */}
@ -257,56 +236,142 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps)
))}
</div>
{/* 탭 컨텐츠 */}
{tab === "data" && (
<TabData
cfg={cfg}
tables={tables}
columns={columns}
selectedColumns={selectedColumns}
onTableChange={(tableName) => {
setSelectedColumns([]);
update({
dataSource: { ...cfg.dataSource, tableName },
selectedColumns: [],
cardGrid: { ...cfg.cardGrid, cells: [] },
});
}}
onColumnsChange={(cols) => {
setSelectedColumns(cols);
update({ selectedColumns: cols });
}}
onDataSourceChange={(dataSource) => update({ dataSource })}
onSortChange={(sort) =>
update({ dataSource: { ...cfg.dataSource, sort } })
}
/>
)}
{tab === "design" && (
<TabCardDesign
cfg={cfg}
columns={columns}
selectedColumns={selectedColumns}
tables={tables}
onGridChange={(cardGrid) => update({ cardGrid })}
onGridColumnsChange={(gridColumns) => update({ gridColumns })}
onCardGapChange={(cardGap) => update({ cardGap })}
/>
)}
{tab === "info" && <TabInfo cfg={cfg} onUpdate={update} />}
{tab === "actions" && (
<TabActions
cfg={cfg}
onUpdate={update}
columns={columns}
/>
<TabActions cfg={cfg} onUpdate={update} columns={columns} />
)}
</div>
);
}
// ===== 탭 1: 데이터 =====
// ===== 탭 1: 정보 (연결 흐름 요약) =====
function TabInfo({
cfg,
onUpdate,
}: {
cfg: PopCardListV2Config;
onUpdate: (partial: Partial<PopCardListV2Config>) => void;
}) {
const ds = cfg.dataSource;
const joins = ds.joins || [];
const clickAction = cfg.cardClickAction || "none";
const cellTypes = cfg.cardGrid.cells.map((c) => c.type);
const hasTimeline = cellTypes.includes("timeline");
const hasActionButtons = cellTypes.includes("action-buttons");
const currentCols = cfg.gridColumns || 3;
return (
<div className="space-y-3">
{/* 카드 열 수 (편집 가능) */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex gap-1">
{[1, 2, 3, 4].map((n) => (
<button
key={n}
type="button"
onClick={() => onUpdate({ gridColumns: n })}
className={cn(
"flex-1 rounded border py-1.5 text-xs font-medium transition-colors",
currentCols === n
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{n}
</button>
))}
</div>
</div>
{/* 데이터 소스 */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="mt-1 rounded border bg-muted/10 p-2 space-y-1">
{ds.tableName ? (
<>
<div className="text-xs font-medium">{ds.tableName}</div>
{joins.map((j, i) => (
<div key={i} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="text-[8px]">+</span>
<span>{j.targetTable}</span>
<span className="text-[8px]">({j.joinType})</span>
</div>
))}
{ds.sort?.[0] && (
<div className="text-[10px] text-muted-foreground">
: {ds.sort[0].column} ({ds.sort[0].direction === "asc" ? "오름차순" : "내림차순"})
</div>
)}
</>
) : (
<span className="text-[10px] text-muted-foreground"> </span>
)}
</div>
</div>
{/* 카드 구성 */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="mt-1 rounded border bg-muted/10 p-2 space-y-1 text-[10px]">
<div>{cfg.cardGrid.rows} x {cfg.cardGrid.cols} , {cfg.cardGrid.cells.length}</div>
<div className="flex flex-wrap gap-1 mt-1">
{hasTimeline && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[9px] text-blue-700"></span>
)}
{hasActionButtons && (
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[9px] text-green-700"> </span>
)}
{cellTypes.includes("status-badge") && (
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-[9px] text-purple-700"> </span>
)}
{cellTypes.includes("number-input") && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] text-amber-700"> </span>
)}
{cellTypes.filter((t) => t === "field" || t === "text").length > 0 && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[9px] text-gray-700">
/ {cellTypes.filter((t) => t === "field" || t === "text").length}
</span>
)}
</div>
</div>
</div>
{/* 동작 흐름 */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="mt-1 rounded border bg-muted/10 p-2 text-[10px]">
{clickAction === "none" && (
<span className="text-muted-foreground"> </span>
)}
{clickAction === "modal-open" && (
<div className="space-y-0.5">
<div className="font-medium"> </div>
{cfg.cardClickModalConfig?.screenId ? (
<div className="text-muted-foreground">
: {cfg.cardClickModalConfig.screenId}
{cfg.cardClickModalConfig.modalTitle && ` (${cfg.cardClickModalConfig.modalTitle})`}
</div>
) : (
<div className="text-muted-foreground"> - </div>
)}
</div>
)}
{clickAction === "built-in-work-detail" && (
<div className="space-y-0.5">
<div className="font-medium"> ()</div>
<div className="text-muted-foreground">(in_progress) </div>
</div>
)}
</div>
</div>
</div>
);
}
// ===== (레거시) 탭: 데이터 =====
function TabData({
cfg,
@ -1414,7 +1479,7 @@ function CellDetailEditor({
<SelectTrigger className={cn("h-7 text-[10px]", cell.type === "action-buttons" ? "flex-1" : "w-24")}><SelectValue /></SelectTrigger>
<SelectContent>
{CELL_TYPE_GROUPS.map((group) => {
const types = (Object.entries(V2_CELL_TYPE_LABELS) as [CardCellType, { label: string; group: string }][]).filter(([, v]) => v.group === group);
const types = Object.entries(V2_CELL_TYPE_LABELS).filter(([, v]) => v.group === group);
if (types.length === 0) return null;
return (
<Fragment key={group}>
@ -1491,15 +1556,6 @@ function CellDetailEditor({
</div>
</div>
)}
{cell.type === "cart-button" && (
<div className="space-y-1">
<span className="text-[9px] font-medium text-muted-foreground"> </span>
<div className="flex gap-1">
<Input value={cell.cartLabel || ""} onChange={(e) => onUpdate({ cartLabel: e.target.value })} placeholder="담기" className="h-7 flex-1 text-[10px]" />
<Input value={cell.cartCancelLabel || ""} onChange={(e) => onUpdate({ cartCancelLabel: e.target.value })} placeholder="취소" className="h-7 flex-1 text-[10px]" />
</div>
</div>
)}
</div>
);
}
@ -2942,9 +2998,9 @@ function TabActions({
columns: ColumnInfo[];
}) {
const designerCtx = usePopDesignerContext();
const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 };
const clickAction = cfg.cardClickAction || "none";
const modalConfig = cfg.cardClickModalConfig || { screenId: "" };
const [advancedOpen, setAdvancedOpen] = useState(false);
const [processColumns, setProcessColumns] = useState<ColumnInfo[]>([]);
const timelineCell = cfg.cardGrid?.cells?.find((c) => c.type === "timeline" && c.timelineSource?.processTable);
@ -2971,31 +3027,11 @@ function TabActions({
return (
<div className="space-y-3">
{/* 소유자 우선 정렬 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex items-center gap-1">
<Select
value={cfg.ownerSortColumn || "__none__"}
onValueChange={(v) => onUpdate({ ownerSortColumn: v === "__none__" ? undefined : v })}
>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="사용 안 함" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"> </SelectItem>
{renderColumnOptionGroups(ownerColumnGroups)}
</SelectContent>
</Select>
</div>
<p className="mt-0.5 text-[9px] text-muted-foreground">
</p>
</div>
{/* 카드 선택 시 */}
{/* 카드 선택 시 동작 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 space-y-1">
{(["none", "publish", "navigate", "modal-open"] as V2CardClickAction[]).map((action) => (
{(["none", "modal-open", "built-in-work-detail"] as V2CardClickAction[]).map((action) => (
<label key={action} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
<input
type="radio"
@ -3006,16 +3042,16 @@ function TabActions({
/>
<span className="text-xs">
{action === "none" && "없음"}
{action === "publish" && "상세 데이터 전달 (다른 컴포넌트 연결)"}
{action === "navigate" && "화면 이동"}
{action === "modal-open" && "모달 열기"}
{action === "built-in-work-detail" && "작업 상세 (내장)"}
</span>
</label>
))}
</div>
{/* 모달 열기 설정 */}
{clickAction === "modal-open" && (
<div className="mt-2 space-y-1.5 rounded border bg-muted/20 p-2">
{/* 모달 캔버스 (디자이너 모드) */}
{designerCtx && (
<div>
{modalConfig.screenId?.startsWith("modal-") ? (
@ -3049,7 +3085,6 @@ function TabActions({
)}
</div>
)}
{/* 뷰어 모드 또는 직접 입력 폴백 */}
{!designerCtx && (
<div className="flex items-center gap-1">
<span className="w-16 shrink-0 text-[9px] text-muted-foreground"> ID</span>
@ -3122,118 +3157,137 @@ function TabActions({
)}
</div>
)}
{/* 작업 상세 내장 모드 안내 */}
{clickAction === "built-in-work-detail" && (
<p className="mt-2 text-[9px] text-muted-foreground rounded border bg-muted/20 p-2">
.
(in_progress) .
.
</p>
)}
</div>
{/* 필터 전 비표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={!!cfg.hideUntilFiltered}
onCheckedChange={(checked) => onUpdate({ hideUntilFiltered: checked })}
/>
</div>
{cfg.hideUntilFiltered && (
<p className="text-[9px] text-muted-foreground -mt-2 pl-1">
.
{/* 내 작업 표시 모드 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex gap-1">
{([
{ value: "off", label: "전체 보기" },
{ value: "priority", label: "우선 표시" },
{ value: "only", label: "내 작업만" },
] as const).map((opt) => {
const current = !cfg.ownerSortColumn
? "off"
: cfg.ownerFilterMode === "only"
? "only"
: "priority";
return (
<button
key={opt.value}
type="button"
onClick={() => {
if (opt.value === "off") {
onUpdate({ ownerSortColumn: undefined, ownerFilterMode: undefined });
} else {
onUpdate({ ownerSortColumn: "worker", ownerFilterMode: opt.value });
}
}}
className={cn(
"flex-1 rounded border py-1.5 text-[10px] font-medium transition-colors",
current === opt.value
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{opt.label}
</button>
);
})}
</div>
<p className="mt-1 text-[9px] text-muted-foreground">
{!cfg.ownerSortColumn
? "모든 작업자의 카드가 동일하게 표시됩니다"
: cfg.ownerFilterMode === "only"
? "내가 담당인 작업만 표시되고, 다른 작업은 숨겨집니다"
: "내가 담당인 작업이 상단에 표시되고, 다른 작업은 비활성화로 표시됩니다"}
</p>
)}
{/* 스크롤 방향 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex gap-1">
{(["vertical", "horizontal"] as const).map((dir) => (
<button
key={dir}
type="button"
onClick={() => onUpdate({ scrollDirection: dir })}
className={cn(
"flex-1 rounded border py-1 text-xs transition-colors",
(cfg.scrollDirection || "vertical") === dir
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{dir === "vertical" ? "세로" : "가로"}
</button>
))}
</div>
</div>
{/* 오버플로우 */}
{/* 고급 설정 (접이식) */}
<div>
<Label className="text-xs"></Label>
<div className="mt-1 flex gap-1">
{(["loadMore", "pagination"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => onUpdate({ overflow: { ...overflow, mode } })}
className={cn(
"flex-1 rounded border py-1 text-xs transition-colors",
overflow.mode === mode
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{mode === "loadMore" ? "더보기" : "페이지네이션"}
</button>
))}
</div>
<div className="mt-2 space-y-2">
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={50}
value={overflow.visibleCount}
onChange={(e) => onUpdate({ overflow: { ...overflow, visibleCount: Number(e.target.value) || 6 } })}
className="mt-0.5 h-7 text-[10px]"
/>
</div>
{overflow.mode === "loadMore" && (
<button
type="button"
onClick={() => setAdvancedOpen(!advancedOpen)}
className="flex w-full items-center gap-1 text-xs font-medium text-muted-foreground"
>
{advancedOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</button>
{advancedOpen && (
<div className="mt-2 space-y-3 rounded border bg-muted/10 p-2">
{/* 내장 상태 탭 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={!!cfg.showStatusTabs}
onCheckedChange={(checked) => onUpdate({ showStatusTabs: checked })}
/>
</div>
{cfg.showStatusTabs && (
<p className="text-[9px] text-muted-foreground -mt-2 pl-1">
MES (////) .
.
</p>
)}
{/* 필터 전 비표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={!!cfg.hideUntilFiltered}
onCheckedChange={(checked) => onUpdate({ hideUntilFiltered: checked })}
/>
</div>
{cfg.hideUntilFiltered && (
<div className="space-y-1.5 -mt-1">
<p className="text-[9px] text-muted-foreground pl-1">
.
</p>
<div>
<Label className="text-[9px] text-muted-foreground"> </Label>
<Input
value={cfg.hideUntilFilteredMessage || ""}
onChange={(e) => onUpdate({ hideUntilFilteredMessage: e.target.value })}
placeholder="필터를 먼저 선택해주세요."
className="h-7 text-[10px]"
/>
</div>
</div>
)}
{/* 기본 표시 수 */}
<div>
<Label className="text-[10px]"> </Label>
<Label className="text-xs"> </Label>
<Input
type="number"
min={1}
max={50}
value={overflow.loadMoreCount ?? 6}
onChange={(e) => onUpdate({ overflow: { ...overflow, loadMoreCount: Number(e.target.value) || 6 } })}
value={(cfg.overflow || { visibleCount: 6 }).visibleCount}
onChange={(e) => onUpdate({
overflow: {
...(cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }),
visibleCount: Number(e.target.value) || 6,
},
})}
className="mt-0.5 h-7 text-[10px]"
/>
<p className="mt-0.5 text-[9px] text-muted-foreground">
(기본: 6개)
</p>
</div>
)}
{overflow.mode === "pagination" && (
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={100}
value={overflow.pageSize ?? overflow.visibleCount}
onChange={(e) => onUpdate({ overflow: { ...overflow, pageSize: Number(e.target.value) || 6 } })}
className="mt-0.5 h-7 text-[10px]"
/>
</div>
)}
</div>
</div>
{/* 장바구니 */}
<div className="flex items-center justify-between">
<Label className="text-xs">() </Label>
<Switch
checked={!!cfg.cartAction}
onCheckedChange={(checked) => {
if (checked) {
onUpdate({ cartAction: { saveMode: "cart", label: "담기", cancelLabel: "취소" } });
} else {
onUpdate({ cartAction: undefined });
}
}}
/>
</div>
)}
</div>
</div>
);

View File

@ -29,7 +29,7 @@ export function PopCardListV2PreviewComponent({ config }: PopCardListV2PreviewPr
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-muted-foreground">
<LayoutGrid className="h-4 w-4" />
<span className="text-xs font-medium"> V2</span>
<span className="text-xs font-medium">MES </span>
</div>
<div className="flex gap-1">
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[9px] text-primary">

View File

@ -9,8 +9,8 @@
import React, { useMemo, useState } from "react";
import {
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
Loader2, CheckCircle2, CircleDot, Clock,
X, Package, Truck, Box, Archive, Heart, Star,
Loader2, CheckCircle2, CircleDot, Clock, Check, ChevronRight,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -27,16 +27,9 @@ type RowData = Record<string, unknown>;
// ===== 공통 유틸 =====
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
ShoppingCart, Package, Truck, Box, Archive, Heart, Star,
Package, Truck, Box, Archive, Heart, Star,
};
function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) {
if (!name) return <ShoppingCart size={size} />;
const IconComp = LUCIDE_ICON_MAP[name];
if (!IconComp) return <ShoppingCart size={size} />;
return <IconComp size={size} />;
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return "-";
if (typeof value === "number") return value.toLocaleString();
@ -60,11 +53,8 @@ export interface CellRendererProps {
cell: CardCellDefinitionV2;
row: RowData;
inputValue?: number;
isCarted?: boolean;
isButtonLoading?: boolean;
onInputClick?: (e: React.MouseEvent) => void;
onCartAdd?: () => void;
onCartCancel?: () => void;
onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void;
onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record<string, unknown>) => void;
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
@ -89,8 +79,6 @@ export function renderCellV2(props: CellRendererProps): React.ReactNode {
return <ButtonCell {...props} />;
case "number-input":
return <NumberInputCell {...props} />;
case "cart-button":
return <CartButtonCell {...props} />;
case "package-summary":
return <PackageSummaryCell {...props} />;
case "status-badge":
@ -101,6 +89,10 @@ export function renderCellV2(props: CellRendererProps): React.ReactNode {
return <ActionButtonsCell {...props} />;
case "footer-status":
return <FooterStatusCell {...props} />;
case "process-qty-summary":
return <ProcessQtySummaryCell {...props} />;
case "mes-process-card":
return <MesProcessCardCell {...props} />;
default:
return <span className="text-[10px] text-muted-foreground"> </span>;
}
@ -258,43 +250,7 @@ function NumberInputCell({ cell, row, inputValue, onInputClick }: CellRendererPr
);
}
// ===== 7. cart-button =====
function CartButtonCell({ cell, row, isCarted, onCartAdd, onCartCancel }: CellRendererProps) {
const iconSize = 18;
const label = cell.cartLabel || "담기";
const cancelLabel = cell.cartCancelLabel || "취소";
if (isCarted) {
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onCartCancel?.(); }}
className="flex w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-destructive px-2 py-1.5 text-destructive-foreground transition-colors duration-150 hover:bg-destructive/90 active:bg-destructive/80"
>
<X size={iconSize} />
<span className="text-[10px] font-semibold leading-tight">{cancelLabel}</span>
</button>
);
}
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onCartAdd?.(); }}
className="flex w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-linear-to-br from-amber-400 to-orange-500 px-2 py-1.5 text-white transition-colors duration-150 hover:from-amber-500 hover:to-orange-600 active:from-amber-600 active:to-orange-700"
>
{cell.cartIconType === "emoji" && cell.cartIconValue ? (
<span style={{ fontSize: `${iconSize}px` }}>{cell.cartIconValue}</span>
) : (
<DynamicLucideIcon name={cell.cartIconValue} size={iconSize} />
)}
<span className="text-[10px] font-semibold leading-tight">{label}</span>
</button>
);
}
// ===== 8. package-summary =====
// ===== 7. package-summary =====
function PackageSummaryCell({ cell, packageEntries, inputUnit }: CellRendererProps) {
if (!packageEntries || packageEntries.length === 0) return null;
@ -349,17 +305,21 @@ function StatusBadgeCell({ cell, row }: CellRendererProps) {
);
}
const defaultColors = STATUS_COLORS[strValue];
// in_progress + 접수분 전부 생산 완료 → "접수분완료" 표시
const displayValue = strValue;
const defaultColors = STATUS_COLORS[displayValue];
if (defaultColors) {
const labelMap: Record<string, string> = {
waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료",
waiting: "대기", accepted: "접수", in_progress: "진행중",
completed: "완료",
};
return (
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
style={{ backgroundColor: defaultColors.bg, color: defaultColors.text }}
>
{labelMap[strValue] || strValue}
{labelMap[displayValue] || displayValue}
</span>
);
}
@ -514,6 +474,8 @@ function TimelineCell({ cell, row }: CellRendererProps) {
})}
</div>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
@ -587,6 +549,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
</div>
</DialogContent>
</Dialog>
</div>
</>
);
}
@ -601,7 +564,11 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId
if (cond.type === "timeline-status") {
const subStatus = row[VIRTUAL_SUB_STATUS];
matched = subStatus !== undefined && String(subStatus) === cond.value;
if (Array.isArray(cond.value)) {
matched = subStatus !== undefined && cond.value.includes(String(subStatus));
} else {
matched = subStatus !== undefined && String(subStatus) === cond.value;
}
} else if (cond.type === "column-value" && cond.column) {
matched = String(row[cond.column] ?? "") === (cond.value ?? "");
} else if (cond.type === "owner-match" && cond.column) {
@ -618,13 +585,25 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId
function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, currentUserId }: CellRendererProps) {
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
const currentProcess = processFlow?.find((s) => s.isCurrent);
const currentProcessId = currentProcess?.processId;
const currentProcessId = (row.__splitProcessId ?? row.__process_id ?? currentProcess?.processId) as string | number | undefined;
if (cell.actionButtons && cell.actionButtons.length > 0) {
const evaluated = cell.actionButtons.map((btn) => ({
btn,
state: evaluateShowCondition(btn, row, currentUserId),
}));
const evaluated = cell.actionButtons.map((btn) => {
let state = evaluateShowCondition(btn, row, currentUserId);
// 접수가능 조건 버튼이 원본 카드의 in_progress에서 보이지 않도록 차단
// (접수는 접수가능 탭의 클론 카드에서만 가능)
if (state === "visible" && !row.__isAcceptClone) {
const cond = btn.showCondition;
if (cond?.type === "timeline-status") {
const condValues = Array.isArray(cond.value) ? cond.value : [cond.value];
const currentSubStatus = String(row[VIRTUAL_SUB_STATUS] ?? "");
if (condValues.includes("acceptable") && currentSubStatus === "in_progress") {
state = "hidden";
}
}
}
return { btn, state };
});
const activeBtn = evaluated.find((e) => e.state === "visible");
const disabledBtn = activeBtn ? null : evaluated.find((e) => e.state === "disabled");
@ -633,6 +612,14 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode,
const { btn, state } = pick;
// in_progress 상태 + 미소진 접수분 존재 시 접수취소 버튼 추가
const subStatus = row[VIRTUAL_SUB_STATUS];
const effectiveStatus = subStatus !== undefined ? String(subStatus) : "";
const rowInputQty = parseInt(String(row.__process_input_qty ?? row.input_qty ?? "0"), 10) || 0;
const totalProduced = parseInt(String(row.__process_total_production_qty ?? row.total_production_qty ?? "0"), 10) || 0;
const hasUnproduced = rowInputQty > totalProduced;
const showCancelBtn = effectiveStatus === "in_progress" && hasUnproduced && currentProcessId;
return (
<div className="flex items-center gap-1">
<Button
@ -644,6 +631,7 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode,
e.stopPropagation();
const actions = (btn.clickActions && btn.clickActions.length > 0) ? btn.clickActions : [btn.clickAction];
const firstAction = actions[0];
if (!firstAction) return;
const config: Record<string, unknown> = {
...firstAction,
@ -664,6 +652,22 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode,
>
{btn.label}
</Button>
{showCancelBtn && (
<Button
variant="ghost"
size="sm"
className="h-7 text-[10px] text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onActionButtonClick?.("__cancelAccept", row, {
__processId: currentProcessId,
type: "cancel-accept",
});
}}
>
</Button>
)}
</div>
);
}
@ -703,7 +707,199 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode,
);
}
// ===== 12. footer-status =====
// ===== 12. process-qty-summary =====
function ProcessQtySummaryCell({ cell, row }: CellRendererProps) {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
const status = String(row[VIRTUAL_SUB_STATUS] ?? row.status ?? "");
const isClone = !!row.__isAcceptClone;
const instructionQty = parseInt(String(row.instruction_qty ?? row.qty ?? "0"), 10) || 0;
const inputQty = parseInt(String(row.__process_input_qty ?? row.input_qty ?? "0"), 10) || 0;
const totalProd = parseInt(String(row.__process_total_production_qty ?? row.total_production_qty ?? "0"), 10) || 0;
const goodQty = parseInt(String(row.__process_good_qty ?? row.good_qty ?? "0"), 10) || 0;
const defectQty = parseInt(String(row.__process_defect_qty ?? row.defect_qty ?? "0"), 10) || 0;
const availableQty = parseInt(String(row.__availableQty ?? "0"), 10) || 0;
const prevGoodQty = parseInt(String(row.__prevGoodQty ?? String(instructionQty)), 10) || 0;
const currentStep = processFlow?.find((s) => s.isCurrent);
const currentIdx = processFlow?.findIndex((s) => s.isCurrent) ?? -1;
const isFirstProcess = currentIdx === 0;
const totalSteps = processFlow?.length ?? 0;
const remainingQty = Math.max(0, inputQty - totalProd);
const progressPct = inputQty > 0 ? Math.min(100, Math.round((totalProd / inputQty) * 100)) : 0;
const yieldRate = totalProd > 0 ? Math.round((goodQty / totalProd) * 100) : 0;
// 접수가능 탭 (클론 카드) - 접수 가능 수량 중심
if (isClone || status === "acceptable" || status === "waiting") {
const showQty = isClone ? availableQty : (status === "acceptable" ? availableQty || prevGoodQty : 0);
return (
<div className="flex w-full flex-col gap-1 px-1">
{/* 미니 공정 흐름 바 */}
{processFlow && processFlow.length > 1 && (
<MiniProcessBar steps={processFlow} currentIdx={currentIdx} />
)}
{/* 핵심 수량 */}
<div className="flex items-center justify-between gap-1">
<div className="flex items-baseline gap-1">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-xs font-semibold">{instructionQty.toLocaleString()}</span>
</div>
{!isFirstProcess && (
<div className="flex items-baseline gap-1">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-xs font-medium text-emerald-600">{prevGoodQty.toLocaleString()}</span>
</div>
)}
<div className="flex items-baseline gap-1">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-xs font-bold text-primary">{(showQty || prevGoodQty).toLocaleString()}</span>
</div>
</div>
</div>
);
}
// 진행중 / 접수분완료 - 작업 현황 중심
if (status === "in_progress") {
const isBatchDone = inputQty > 0 && totalProd >= inputQty;
return (
<div className="flex w-full flex-col gap-1 px-1">
{/* 미니 공정 흐름 바 */}
{processFlow && processFlow.length > 1 && (
<MiniProcessBar steps={processFlow} currentIdx={currentIdx} />
)}
{/* 프로그레스 바 */}
<div className="flex items-center gap-1.5">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
<div
className={cn(
"h-full rounded-full transition-all duration-300",
isBatchDone ? "bg-violet-500" : "bg-primary",
)}
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
{/* 수량 상세 */}
<div className="flex items-center justify-between gap-0.5">
<QtyChip label="접수" value={inputQty} color="#3b82f6" />
<QtyChip label="양품" value={goodQty} color="#10b981" />
<QtyChip label="불량" value={defectQty} color="#ef4444" showZero={false} />
<QtyChip label="잔여" value={remainingQty} color={remainingQty > 0 ? "#f59e0b" : "#10b981"} />
</div>
{/* 추가접수가능 수량 (있을 때만) */}
{availableQty > 0 && (
<div className="flex items-center justify-end gap-1">
<span className="text-[9px] text-muted-foreground"></span>
<span className="text-[10px] font-semibold text-violet-600">{availableQty.toLocaleString()}</span>
</div>
)}
</div>
);
}
// 완료 상태 - 최종 결과 요약
if (status === "completed") {
return (
<div className="flex w-full flex-col gap-1 px-1">
{/* 미니 공정 흐름 바 */}
{processFlow && processFlow.length > 1 && (
<MiniProcessBar steps={processFlow} currentIdx={currentIdx} />
)}
{/* 완료 프로그레스 */}
<div className="flex items-center gap-1.5">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
<div className="h-full rounded-full bg-emerald-500" style={{ width: "100%" }} />
</div>
<span className="text-[10px] font-bold text-emerald-600"></span>
</div>
{/* 최종 수량 */}
<div className="flex items-center justify-between gap-0.5">
<QtyChip label="총생산" value={totalProd} color="#059669" />
<QtyChip label="양품" value={goodQty} color="#10b981" />
<QtyChip label="불량" value={defectQty} color="#ef4444" showZero={false} />
{totalProd > 0 && (
<div className="flex items-baseline gap-0.5">
<span className="text-[9px] text-muted-foreground"></span>
<span className="text-[10px] font-bold text-emerald-600">{yieldRate}%</span>
</div>
)}
</div>
</div>
);
}
// fallback
return (
<div className="flex w-full items-center justify-between px-1">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-xs font-semibold">{instructionQty.toLocaleString()}</span>
</div>
);
}
// --- 미니 공정 흐름 바 ---
function MiniProcessBar({ steps, currentIdx }: { steps: TimelineProcessStep[]; currentIdx: number }) {
return (
<div className="flex items-center gap-px">
{steps.map((step, idx) => {
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
const isCurrent = idx === currentIdx;
let bg = "#e2e8f0"; // pending
if (sem === "done") bg = "#10b981";
else if (sem === "active") bg = "#3b82f6";
const pct = step.totalProductionQty && step.inputQty && step.inputQty > 0
? Math.round((step.totalProductionQty / step.inputQty) * 100)
: undefined;
return (
<div
key={step.seqNo}
className={cn(
"relative flex-1 overflow-hidden rounded-sm",
isCurrent ? "h-2.5" : "h-1.5",
)}
style={{ backgroundColor: `${bg}30` }}
title={`${step.processName}: ${step.status}${pct !== undefined ? ` (${pct}%)` : ""}`}
>
<div
className="absolute inset-y-0 left-0 rounded-sm transition-all duration-300"
style={{
backgroundColor: bg,
width: sem === "done" ? "100%" : pct !== undefined ? `${pct}%` : "0%",
}}
/>
</div>
);
})}
</div>
);
}
// --- 수량 칩 ---
function QtyChip({
label, value, color, showZero = true,
}: {
label: string; value: number; color: string; showZero?: boolean;
}) {
if (!showZero && value === 0) return null;
return (
<div className="flex items-baseline gap-0.5">
<span className="text-[9px] text-muted-foreground">{label}</span>
<span
className="text-[10px] font-semibold tabular-nums"
style={{ color }}
>
{value.toLocaleString()}
</span>
</div>
);
}
// ===== 13. footer-status =====
function FooterStatusCell({ cell, row }: CellRendererProps) {
const value = cell.footerStatusColumn ? row[cell.footerStatusColumn] : "";
@ -735,3 +931,514 @@ function FooterStatusCell({ cell, row }: CellRendererProps) {
</div>
);
}
// ===== 14. mes-process-card (MES 공정 전용 카드) =====
const MES_STATUS: Record<string, { label: string; color: string; bg: string }> = {
waiting: { label: "대기", color: "#94a3b8", bg: "#f8fafc" },
acceptable: { label: "접수가능", color: "#2563eb", bg: "#eff6ff" },
in_progress: { label: "진행중", color: "#d97706", bg: "#fffbeb" },
completed: { label: "완료", color: "#059669", bg: "#ecfdf5" },
};
function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: CellRendererProps) {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
const rawStatus = String(row[VIRTUAL_SUB_STATUS] ?? row.status ?? "");
const isClone = !!row.__isAcceptClone;
const [flowModalOpen, setFlowModalOpen] = useState(false);
const instrQty = parseInt(String(row.qty ?? row.instruction_qty ?? "0"), 10) || 0;
const inputQty = parseInt(String(row.__process_input_qty ?? row.input_qty ?? "0"), 10) || 0;
const totalProd = parseInt(String(row.__process_total_production_qty ?? row.total_production_qty ?? "0"), 10) || 0;
const goodQty = parseInt(String(row.__process_good_qty ?? row.good_qty ?? "0"), 10) || 0;
const defectQty = parseInt(String(row.__process_defect_qty ?? row.defect_qty ?? "0"), 10) || 0;
const concessionQty = parseInt(String(row.__process_concession_qty ?? row.concession_qty ?? "0"), 10) || 0;
const isRework = String(row.__process_is_rework ?? row.is_rework ?? "N") === "Y";
const availableQty = parseInt(String(row.__availableQty ?? "0"), 10) || 0;
const prevGoodQty = parseInt(String(row.__prevGoodQty ?? String(instrQty)), 10) || 0;
const resultStatus = String(row.__process_result_status ?? "");
const currentStep = processFlow?.find((s) => s.isCurrent);
const currentIdx = processFlow?.findIndex((s) => s.isCurrent) ?? -1;
const isFirstProcess = currentIdx === 0;
const processId = currentStep?.processId;
const remainingQty = Math.max(0, inputQty - totalProd);
const progressPct = inputQty > 0 ? Math.min(100, Math.round((totalProd / inputQty) * 100)) : 0;
const yieldRate = totalProd > 0 ? Math.round((goodQty / totalProd) * 100) : 0;
const displayStatus = rawStatus;
const st = MES_STATUS[displayStatus] || MES_STATUS.waiting;
const processName = currentStep?.processName || String(row.__process_process_name ?? "");
const woNo = String(row.work_instruction_no ?? "");
const itemId = String(row.item_id ?? "");
const itemName = String(row.item_name ?? "");
// MES 워크플로우 상태 기반 버튼 결정
const acceptBtn = (cell.actionButtons || []).find((b) => b.showCondition?.type === "timeline-status");
const cancelBtn = (cell.actionButtons || []).find((b) => b.showCondition?.type === "owner-match");
let activeBtn: ActionButtonDef | undefined;
let showManualComplete = false;
const isFullyProduced = inputQty > 0 && totalProd >= inputQty;
if (isClone) {
activeBtn = acceptBtn;
} else if (rawStatus === "acceptable") {
activeBtn = acceptBtn;
} else if (rawStatus === "in_progress") {
if (isFullyProduced) {
if (availableQty > 0) activeBtn = acceptBtn;
} else if (totalProd > 0) {
showManualComplete = true;
} else {
activeBtn = cancelBtn;
}
}
return (
<>
<div
className="flex h-full w-full flex-col overflow-hidden"
style={{ borderLeft: `4px solid ${st.color}`, backgroundColor: st.bg }}
>
{/* ── 헤더 ── */}
<div className="flex items-start justify-between px-5 pt-5 pb-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-medium text-muted-foreground">{woNo}</span>
{processName && (
<span className="text-[14px] font-semibold" style={{ color: st.color }}>
{processName}
{processFlow && processFlow.length > 1 && ` (${currentIdx + 1}/${processFlow.length})`}
</span>
)}
</div>
<div className="mt-1">
<span className="text-[20px] font-bold leading-tight">{itemName || itemId || "-"}</span>
</div>
</div>
<div className="ml-3 flex shrink-0 items-center gap-2">
{isRework && (
<span className="rounded-md bg-amber-500 px-2.5 py-1 text-[12px] font-bold text-white">
</span>
)}
<span
className="rounded-full px-3.5 py-1.5 text-[14px] font-bold"
style={{ backgroundColor: st.color, color: "#fff" }}
>
{st.label}
</span>
</div>
</div>
{/* ── 수량 메트릭 (상태별) ── */}
<div className="flex-1 px-5 py-3">
{(isClone || rawStatus === "acceptable" || rawStatus === "waiting") && (
<MesAcceptableMetrics
instrQty={instrQty}
prevGoodQty={prevGoodQty}
availableQty={availableQty}
inputQty={inputQty}
isFirstProcess={isFirstProcess}
isClone={isClone}
isRework={isRework}
/>
)}
{rawStatus === "in_progress" && (
<MesInProgressMetrics
inputQty={inputQty}
totalProd={totalProd}
goodQty={goodQty}
defectQty={defectQty}
concessionQty={concessionQty}
remainingQty={remainingQty}
progressPct={progressPct}
availableQty={availableQty}
isBatchDone={false}
statusColor={st.color}
/>
)}
{rawStatus === "completed" && (
<MesCompletedMetrics
instrQty={instrQty}
goodQty={goodQty}
defectQty={defectQty}
concessionQty={concessionQty}
yieldRate={yieldRate}
/>
)}
</div>
{/* ── 공정 흐름 스트립 (클릭 시 모달) ── */}
{processFlow && processFlow.length > 0 && (
<div
className="cursor-pointer border-t px-5 py-3 transition-colors hover:bg-black/3"
style={{ borderColor: `${st.color}20` }}
onClick={(e) => { e.stopPropagation(); setFlowModalOpen(true); }}
title="클릭하여 공정 상세 보기"
>
<ProcessFlowStrip steps={processFlow} currentIdx={currentIdx} instrQty={instrQty} />
</div>
)}
{/* ── 부가정보 ── */}
{(row.end_date || row.equipment_id || row.work_team) && (
<div
className="border-t px-5 py-2"
style={{ borderColor: `${st.color}20` }}
>
<div className="flex items-center gap-4 text-[14px] text-muted-foreground">
{row.end_date && <span> <b className="text-foreground">{formatValue(row.end_date)}</b></span>}
{row.equipment_id && <span>{String(row.equipment_id)}</span>}
{row.work_team && <span>{String(row.work_team)}</span>}
</div>
</div>
)}
{/* ── 액션 버튼 ── */}
{(activeBtn || showManualComplete) && (
<div
className="mt-auto border-t px-5 py-3"
style={{ borderColor: `${st.color}20` }}
>
<div className="flex gap-3">
{activeBtn && (
<Button
variant={activeBtn.variant || "default"}
className="h-14 flex-1 rounded-xl text-[17px] font-bold"
onClick={(e) => {
e.stopPropagation();
const actions = activeBtn.clickActions?.length ? activeBtn.clickActions : [activeBtn.clickAction];
const firstAction = actions[0];
const config: Record<string, unknown> = { ...firstAction, __allActions: actions };
if (processId !== undefined) config.__processId = processId;
onActionButtonClick?.(activeBtn.label, row, config);
}}
>
{activeBtn.label}
</Button>
)}
{showManualComplete && (
<Button
variant="outline"
className="h-14 flex-1 rounded-xl text-[17px] font-bold"
onClick={(e) => {
e.stopPropagation();
onActionButtonClick?.("__manualComplete", row, { __processId: processId });
}}
>
</Button>
)}
</div>
</div>
)}
</div>
{/* ── 공정 상세 모달 ── */}
<Dialog open={flowModalOpen} onOpenChange={setFlowModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="text-base">{woNo} </DialogTitle>
<DialogDescription className="text-xs">
{processFlow?.length ?? 0} {processFlow?.filter(s => (s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status]) === "done").length ?? 0}
</DialogDescription>
</DialogHeader>
<div className="space-y-0">
{processFlow?.map((step, idx) => {
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
const styles = getTimelineStyle(step);
const sInstr = instrQty;
const sInput = step.inputQty || 0;
const sProd = step.totalProductionQty || 0;
const sGood = step.goodQty || 0;
const sDefect = step.defectQty || 0;
const sYield = step.yieldRate || 0;
const sPct = sInput > 0 ? Math.round((sProd / sInput) * 100) : (sem === "done" ? 100 : 0);
const statusLabel = sem === "done" ? "완료" : sem === "active" ? "진행" : "대기";
return (
<div key={step.seqNo} className="flex items-center">
<div className="flex w-8 shrink-0 flex-col items-center">
{idx > 0 && <div className="h-3 w-px bg-border" />}
<div
className="flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold"
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
>
{step.seqNo}
</div>
{idx < (processFlow?.length ?? 0) - 1 && <div className="h-3 w-px bg-border" />}
</div>
<div className={cn(
"ml-2 flex flex-1 items-center justify-between rounded-md px-3 py-2",
step.isCurrent && "ring-1 ring-primary/30 bg-primary/5",
)}>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className={cn("text-sm", step.isCurrent ? "font-bold" : "font-medium")}>
{step.processName}
</span>
<span className="rounded px-1.5 py-0.5 text-[9px] font-medium"
style={{ backgroundColor: `${styles.chipBg}30`, color: styles.chipBg }}>
{statusLabel}
</span>
</div>
{(sInput > 0 || sem === "done") && (
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground">
<span> <b className="text-foreground">{sGood.toLocaleString()}</b></span>
{sDefect > 0 && <span> <b className="text-destructive">{sDefect.toLocaleString()}</b></span>}
<span> <b style={{ color: sYield >= 95 ? "#059669" : sYield >= 80 ? "#d97706" : "#ef4444" }}>{sYield}%</b></span>
</div>
)}
</div>
<div className="ml-3 flex w-16 flex-col items-end">
<span className="text-[11px] font-bold tabular-nums">{sProd}/{sInput || sInstr}</span>
<div className="mt-0.5 h-1.5 w-full overflow-hidden rounded-full bg-secondary">
<div className="h-full rounded-full transition-all" style={{
width: `${sPct}%`,
backgroundColor: styles.chipBg,
}} />
</div>
</div>
</div>
</div>
);
})}
</div>
</DialogContent>
</Dialog>
</>
);
}
// ── 공정 흐름 스트립 (노드 기반: 지나온 + 이전 + 현재 + 다음 + 남은) ──
function ProcessFlowStrip({ steps, currentIdx, instrQty }: {
steps: TimelineProcessStep[]; currentIdx: number; instrQty: number;
}) {
const safeIdx = currentIdx >= 0 && currentIdx < steps.length ? currentIdx : -1;
const prevStep = safeIdx > 0 ? steps[safeIdx - 1] : null;
const currStep = safeIdx >= 0 ? steps[safeIdx] : null;
const nextStep = safeIdx >= 0 && safeIdx < steps.length - 1 ? steps[safeIdx + 1] : null;
const hiddenBefore = safeIdx > 1 ? safeIdx - 1 : 0;
const hiddenAfter = safeIdx >= 0 && safeIdx < steps.length - 2 ? steps.length - safeIdx - 2 : 0;
const allBeforeDone = hiddenBefore > 0 && safeIdx > 1 && steps.slice(0, safeIdx - 1).every(s => {
const sem = s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status] || "pending";
return sem === "done";
});
const renderNode = (step: TimelineProcessStep, isCurrent: boolean) => {
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
return (
<div className="flex flex-col items-center gap-1">
<div className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] border-2 text-[14px] font-bold",
isCurrent
? "border-primary bg-primary text-primary-foreground shadow-sm shadow-primary/20"
: sem === "done"
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-border bg-muted text-muted-foreground",
)}>
{sem === "done" && !isCurrent ? <Check className="h-4 w-4" /> : step.seqNo}
</div>
<span className={cn(
"max-w-[56px] truncate text-center text-[11px] font-medium",
isCurrent ? "font-bold text-primary" : "text-muted-foreground",
)}>
{step.processName}
</span>
</div>
);
};
const connDone = "mt-[18px] h-[3px] w-5 shrink-0 bg-emerald-400";
const connPending = "mt-[18px] h-[3px] w-5 shrink-0 bg-border";
return (
<div className="flex items-start">
{hiddenBefore > 0 && (
<>
<div className="flex flex-col items-center gap-1">
<div className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] border-2 text-[13px] font-bold tabular-nums",
allBeforeDone
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-border bg-muted text-muted-foreground",
)}>
+{hiddenBefore}
</div>
</div>
<div className={allBeforeDone ? connDone : connPending} />
</>
)}
{prevStep && (
<>
{renderNode(prevStep, false)}
<div className={connDone} />
</>
)}
{currStep && renderNode(currStep, true)}
{nextStep && (
<>
<div className={connPending} />
{renderNode(nextStep, false)}
</>
)}
{hiddenAfter > 0 && (
<>
<div className={connPending} />
<div className="flex flex-col items-center gap-1">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] border-2 border-amber-200 bg-amber-50 text-[13px] font-bold tabular-nums text-amber-600">
+{hiddenAfter}
</div>
</div>
</>
)}
</div>
);
}
// ── 접수가능 메트릭 ──
function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, isFirstProcess, isClone, isRework }: {
instrQty: number; prevGoodQty: number; availableQty: number; inputQty: number; isFirstProcess: boolean; isClone: boolean; isRework?: boolean;
}) {
if (isRework) {
return (
<div className="space-y-3">
<div className="flex items-center gap-3 text-[14px]">
<span className="font-medium text-amber-600"> </span>
</div>
<div className="flex items-center justify-center gap-2.5 rounded-lg py-3.5" style={{ backgroundColor: "rgba(245,158,11,0.08)" }}>
<span className="text-[16px] font-medium text-muted-foreground"> </span>
<span className="text-[36px] font-extrabold tabular-nums leading-none tracking-tight text-amber-600">{inputQty.toLocaleString()}</span>
<span className="text-[16px] font-medium text-muted-foreground">EA</span>
</div>
</div>
);
}
const displayAvail = isClone ? availableQty : (availableQty || prevGoodQty);
return (
<div className="space-y-3">
<div className="flex items-center gap-4 text-[14px]">
<span className="text-muted-foreground"> <b className="text-foreground">{instrQty.toLocaleString()}</b></span>
{!isFirstProcess && (
<span className="text-muted-foreground"> <b className="text-emerald-600">{prevGoodQty.toLocaleString()}</b></span>
)}
{inputQty > 0 && (
<span className="text-muted-foreground"> <b className="text-foreground">{inputQty.toLocaleString()}</b></span>
)}
</div>
<div className="flex items-center justify-center gap-2.5 rounded-lg py-3.5" style={{ backgroundColor: "rgba(37,99,235,0.06)" }}>
<span className="text-[16px] font-medium text-muted-foreground"></span>
<span className="text-[36px] font-extrabold tabular-nums leading-none tracking-tight text-primary">{displayAvail.toLocaleString()}</span>
<span className="text-[16px] font-medium text-muted-foreground">EA</span>
</div>
</div>
);
}
// ── 진행중 메트릭 ──
function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, concessionQty, remainingQty, progressPct, availableQty, isBatchDone, statusColor }: {
inputQty: number; totalProd: number; goodQty: number; defectQty: number; concessionQty: number; remainingQty: number; progressPct: number; availableQty: number; isBatchDone: boolean; statusColor: string;
}) {
return (
<div className="space-y-3">
<div className="flex items-center gap-4 text-[14px]">
<span className="text-muted-foreground"> <b className="text-foreground">{inputQty.toLocaleString()}</b></span>
{availableQty > 0 && (
<span className="text-muted-foreground"> <b className="text-violet-600">{availableQty.toLocaleString()}</b></span>
)}
</div>
<div className="flex items-center justify-center gap-2.5 rounded-lg py-3.5" style={{ backgroundColor: `${statusColor}0F` }}>
<span className="text-[16px] font-medium text-muted-foreground"></span>
<span className="text-[36px] font-extrabold tabular-nums leading-none tracking-tight" style={{ color: statusColor }}>
{totalProd.toLocaleString()}
</span>
<span className="text-[20px] font-normal text-muted-foreground">/ {inputQty.toLocaleString()}</span>
<span className="text-[16px] font-medium text-muted-foreground">EA</span>
</div>
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-50 px-3 py-1.5 text-[14px] font-semibold text-emerald-600">
<span className="font-medium opacity-70"></span> {goodQty.toLocaleString()}
</span>
{defectQty > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-red-50 px-3 py-1.5 text-[14px] font-semibold text-red-600">
<span className="font-medium opacity-70"></span> {defectQty.toLocaleString()}
</span>
)}
{concessionQty > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-violet-50 px-3 py-1.5 text-[14px] font-semibold text-violet-600">
<span className="font-medium opacity-70"></span> {concessionQty.toLocaleString()}
</span>
)}
<span className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[14px] font-semibold",
remainingQty > 0 ? "bg-amber-50 text-amber-600" : "bg-emerald-50 text-emerald-600",
)}>
<span className="font-medium opacity-70"></span> {remainingQty.toLocaleString()}
</span>
</div>
</div>
);
}
// ── 완료 메트릭 ──
function MesCompletedMetrics({ instrQty, goodQty, defectQty, concessionQty, yieldRate }: {
instrQty: number; goodQty: number; defectQty: number; concessionQty: number; yieldRate: number;
}) {
return (
<div className="space-y-3">
<div className="flex items-center gap-4 text-[14px]">
<span className="text-muted-foreground"> <b className="text-foreground">{instrQty.toLocaleString()}</b></span>
<span
className="ml-auto rounded-full px-3.5 py-1 text-[14px] font-bold"
style={{
backgroundColor: yieldRate >= 95 ? "#f0fdf4" : yieldRate >= 80 ? "#fffbeb" : "#fef2f2",
color: yieldRate >= 95 ? "#059669" : yieldRate >= 80 ? "#d97706" : "#ef4444",
}}
>
{yieldRate}%
</span>
</div>
<div className="flex items-center justify-center gap-2.5 rounded-lg py-3.5" style={{ backgroundColor: "rgba(5,150,105,0.06)" }}>
<span className="text-[16px] font-medium text-muted-foreground"></span>
<span className="text-[36px] font-extrabold tabular-nums leading-none tracking-tight text-emerald-600">{goodQty.toLocaleString()}</span>
<span className="text-[16px] font-medium text-muted-foreground">EA</span>
</div>
{(defectQty > 0 || concessionQty > 0) && (
<div className="flex flex-wrap gap-2">
{defectQty > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-red-50 px-3 py-1.5 text-[14px] font-semibold text-red-600">
<span className="font-medium opacity-70"></span> {defectQty.toLocaleString()}
</span>
)}
{concessionQty > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-violet-50 px-3 py-1.5 text-[14px] font-semibold text-violet-600">
<span className="font-medium opacity-70"></span> {concessionQty.toLocaleString()}
</span>
)}
</div>
)}
</div>
);
}
// ── 메트릭 박스 ──
function MesMetricBox({ label, value, color, dimZero = false }: {
label: string; value: number; color: string; dimZero?: boolean;
}) {
const isDim = dimZero && value === 0;
return (
<div className={cn("flex flex-col items-center rounded px-1 py-0.5", isDim && "opacity-40")}
style={{ backgroundColor: `${color}08` }}>
<span className="text-[8px] text-muted-foreground">{label}</span>
<span className="text-[11px] font-bold tabular-nums" style={{ color }}>{value.toLocaleString()}</span>
</div>
);
}

View File

@ -32,8 +32,8 @@ const defaultConfig: PopCardListV2Config = {
PopComponentRegistry.registerComponent({
id: "pop-card-list-v2",
name: "카드 목록 V2",
description: "슬롯 기반 카드 레이아웃 (CSS Grid + 셀 타입별 렌더링)",
name: "MES 공정흐름",
description: "MES 생산실적 카드 레이아웃 (공정 흐름 + 상태 관리)",
category: "display",
icon: "LayoutGrid",
component: PopCardListV2Component,
@ -44,15 +44,10 @@ PopComponentRegistry.registerComponent({
sendable: [
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" },
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
{ key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" },
{ key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (항목 + 매핑)" },
],
receivable: [
{ key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
{ key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
{ key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" },
{ key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" },
],
},

View File

@ -66,7 +66,7 @@ export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Conf
// 3. 본문 필드들 (이미지 오른쪽)
const fieldStartCol = old.cardTemplate?.image?.enabled ? 2 : 1;
const fieldColSpan = old.cardTemplate?.image?.enabled ? 2 : 3;
const hasRightActions = !!(old.inputField?.enabled || old.cartAction);
const hasRightActions = !!old.inputField?.enabled;
(old.cardTemplate?.body?.fields || []).forEach((field, i) => {
cells.push({
@ -102,20 +102,7 @@ export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Conf
limitColumn: old.inputField.limitColumn || old.inputField.maxColumn,
});
}
if (old.cartAction) {
cells.push({
id: "cart",
row: nextRow + Math.ceil(bodyRowSpan / 2),
col: rightCol,
rowSpan: Math.floor(bodyRowSpan / 2) || 1,
colSpan: 1,
type: "cart-button",
cartLabel: old.cartAction.label,
cartCancelLabel: old.cartAction.cancelLabel,
cartIconType: old.cartAction.iconType,
cartIconValue: old.cartAction.iconValue,
});
}
// 5. 포장 요약 (마지막 행, full-width)
if (old.packageConfig?.enabled) {
@ -156,8 +143,6 @@ export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Conf
responsiveDisplay: old.responsiveDisplay,
inputField: old.inputField,
packageConfig: old.packageConfig,
cartAction: old.cartAction,
cartListMode: old.cartListMode,
saveMapping: old.saveMapping,
};
}

View File

@ -229,6 +229,8 @@ export function NumberInputModal({
<DialogOverlay />
<DialogPrimitive.Content
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] w-full max-w-[95vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden border shadow-lg duration-200 sm:max-w-[360px] sm:rounded-lg"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<VisuallyHidden><DialogTitle> </DialogTitle></VisuallyHidden>
{/* 헤더 */}

View File

@ -14,7 +14,7 @@ import { useRouter } from "next/navigation";
import {
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight,
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
Trash2,
Trash2, Search,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
@ -770,6 +770,13 @@ export function PopCardListComponent({
.
</p>
</div>
) : !isCartListMode && config?.requireFilter && externalFilters.size === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6">
<Search className="h-8 w-8 text-muted-foreground/50" />
<p className="text-center text-sm text-muted-foreground">
{config.requireFilterMessage || "필터를 먼저 선택해주세요."}
</p>
</div>
) : loading ? (
<div className="flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />

View File

@ -9,8 +9,11 @@
*/
import React, { useState, useEffect, useMemo } from "react";
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react";
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check, ChevronsUpDown } from "lucide-react";
import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections";
import { useAuth } from "@/hooks/useAuth";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import type { GridMode } from "@/components/pop/designer/types/pop-layout";
import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout";
import { Button } from "@/components/ui/button";
@ -431,6 +434,32 @@ function BasicSettingsTab({
</CollapsibleSection>
)}
{/* 필터 필수 설정 (장바구니 모드 아닐 때만) */}
{!isCartListMode && dataSource.tableName && (
<CollapsibleSection sectionKey="basic-require-filter" title="필터 필수" sections={sections}>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={!!config.requireFilter}
onCheckedChange={(checked) => onUpdate({ requireFilter: checked })}
/>
</div>
{config.requireFilter && (
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
value={config.requireFilterMessage || ""}
onChange={(e) => onUpdate({ requireFilterMessage: e.target.value })}
placeholder="필터를 먼저 선택해주세요."
className="h-7 text-[10px]"
/>
</div>
)}
</div>
</CollapsibleSection>
)}
{/* 저장 매핑 (장바구니 모드일 때만) */}
{isCartListMode && (
<CollapsibleSection
@ -842,28 +871,29 @@ function CartListModeSection({
onUpdate: (config: CartListModeConfig) => void;
}) {
const mode: CartListModeConfig = cartListMode || { enabled: false };
const [screens, setScreens] = useState<{ id: number; name: string }[]>([]);
const [screens, setScreens] = useState<{ id: number; name: string; code: string }[]>([]);
const [sourceCardLists, setSourceCardLists] = useState<SourceCardListInfo[]>([]);
const [loadingComponents, setLoadingComponents] = useState(false);
const [screenOpen, setScreenOpen] = useState(false);
const { companyCode } = useAuth();
// 화면 목록 로드
useEffect(() => {
screenApi
.getScreens({ size: 500 })
.getScreens({ size: 500, companyCode: companyCode || undefined })
.then((res) => {
if (res?.data) {
setScreens(
res.data.map((s) => ({
id: s.screenId,
name: s.screenName || `화면 ${s.screenId}`,
code: s.screenCode || "",
}))
);
}
})
.catch(() => {});
}, []);
}, [companyCode]);
// 원본 화면 선택 시 -> 해당 화면의 pop-card-list 컴포넌트 목록 로드
useEffect(() => {
if (!mode.sourceScreenId) {
setSourceCardLists([]);
@ -889,22 +919,7 @@ function CartListModeSection({
.finally(() => setLoadingComponents(false));
}, [mode.sourceScreenId]);
const handleScreenChange = (val: string) => {
const screenId = val === "__none__" ? undefined : Number(val);
onUpdate({ ...mode, sourceScreenId: screenId });
};
const handleComponentSelect = (val: string) => {
if (val === "__none__") {
onUpdate({ ...mode, sourceComponentId: undefined });
return;
}
const compId = val.startsWith("__comp_") ? val.replace("__comp_", "") : val;
const found = sourceCardLists.find((c) => c.componentId === compId);
if (found) {
onUpdate({ ...mode, sourceComponentId: found.componentId });
}
};
const selectedScreen = screens.find((s) => s.id === mode.sourceScreenId);
return (
<div className="space-y-3">
@ -923,28 +938,69 @@ function CartListModeSection({
{mode.enabled && (
<>
{/* 원본 화면 선택 */}
{/* 원본 화면 선택 (검색 가능 Combobox) */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={mode.sourceScreenId ? String(mode.sourceScreenId) : "__none__"}
onValueChange={handleScreenChange}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{screens.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={screenOpen} onOpenChange={setScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={screenOpen}
className="mt-1 h-7 w-full justify-between text-xs"
>
{selectedScreen
? `${selectedScreen.name} (${selectedScreen.id})`
: "화면 검색..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="화면 이름 또는 ID 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
{screens.map((s) => (
<CommandItem
key={s.id}
value={`${s.name} ${s.id} ${s.code}`}
onSelect={() => {
onUpdate({
...mode,
sourceScreenId: mode.sourceScreenId === s.id ? undefined : s.id,
sourceComponentId: mode.sourceScreenId === s.id ? undefined : mode.sourceComponentId,
});
setScreenOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mode.sourceScreenId === s.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{s.name}</span>
<span className="text-[9px] text-muted-foreground">ID: {s.id}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 원본 컴포넌트 선택 (원본 화면에서 자동 로드) */}
{/* 원본 컴포넌트 선택 */}
{mode.sourceScreenId && (
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
@ -959,7 +1015,14 @@ function CartListModeSection({
) : (
<Select
value={mode.sourceComponentId ? `__comp_${mode.sourceComponentId}` : "__none__"}
onValueChange={handleComponentSelect}
onValueChange={(val) => {
if (val === "__none__") {
onUpdate({ ...mode, sourceComponentId: undefined });
} else {
const compId = val.replace("__comp_", "");
onUpdate({ ...mode, sourceComponentId: compId });
}
}}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="카드 목록 선택" />

View File

@ -42,7 +42,7 @@ export function PopCardListPreviewComponent({
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-muted-foreground">
<LayoutGrid className="h-4 w-4" />
<span className="text-xs font-medium"> </span>
<span className="text-xs font-medium"> </span>
</div>
{/* 설정 배지 */}

View File

@ -50,8 +50,8 @@ const defaultConfig: PopCardListConfig = {
// 레지스트리 등록
PopComponentRegistry.registerComponent({
id: "pop-card-list",
name: "카드 목록",
description: "테이블 데이터를 카드 형태로 표시 (헤더 + 이미지 + 필드 목록)",
name: "장바구니 목록",
description: "장바구니 담기/확정 카드 목록 (입고, 출고, 수주 등)",
category: "display",
icon: "LayoutGrid",
component: PopCardListComponent,

View File

@ -356,7 +356,10 @@ function SaveTabContent({
};
const syncAndUpdateSaveMappings = useCallback(
(updater?: (prev: PopFieldSaveMapping[]) => PopFieldSaveMapping[]) => {
(
updater?: (prev: PopFieldSaveMapping[]) => PopFieldSaveMapping[],
extraPartial?: Partial<PopFieldConfig>,
) => {
const fieldIds = new Set(allFields.map(({ field }) => field.id));
const prev = saveMappings.filter((m) => fieldIds.has(m.fieldId));
const next = updater ? updater(prev) : prev;
@ -381,6 +384,7 @@ function SaveTabContent({
tableName: saveTableName,
fieldMappings: merged,
},
...extraPartial,
});
}
},
@ -395,22 +399,27 @@ function SaveTabContent({
const updateSaveMapping = useCallback(
(fieldId: string, partial: Partial<PopFieldSaveMapping>) => {
syncAndUpdateSaveMappings((prev) =>
prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m))
);
let extraPartial: Partial<PopFieldConfig> | undefined;
if (partial.targetColumn !== undefined) {
const newFieldName = partial.targetColumn || "";
const sections = cfg.sections.map((s) => ({
...s,
fields: (s.fields ?? []).map((f) =>
f.id === fieldId ? { ...f, fieldName: newFieldName } : f
),
}));
onUpdateConfig({ sections });
extraPartial = {
sections: cfg.sections.map((s) => ({
...s,
fields: (s.fields ?? []).map((f) =>
f.id === fieldId ? { ...f, fieldName: newFieldName } : f
),
})),
};
}
syncAndUpdateSaveMappings(
(prev) =>
prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m)),
extraPartial,
);
},
[syncAndUpdateSaveMappings, cfg, onUpdateConfig]
[syncAndUpdateSaveMappings, cfg.sections]
);
// --- 숨은 필드 매핑 로직 ---
@ -2086,23 +2095,24 @@ function JsonKeySelect({
onOpen?: () => void;
}) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (nextOpen) onOpen?.();
if (nextOpen) {
onOpen?.();
setInputValue("");
}
};
if (keys.length === 0 && !value) {
return (
<Input
placeholder="키"
value={value}
onChange={(e) => onValueChange(e.target.value)}
onFocus={() => onOpen?.()}
className="h-7 w-24 text-xs"
/>
);
}
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && inputValue.trim()) {
e.preventDefault();
onValueChange(inputValue.trim());
setInputValue("");
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
@ -2117,33 +2127,51 @@ function JsonKeySelect({
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandInput placeholder="키 검색..." className="text-xs" />
<Command shouldFilter={keys.length > 0}>
<CommandInput
placeholder={keys.length > 0 ? "키 검색..." : "키 직접 입력..."}
className="text-xs"
value={inputValue}
onValueChange={setInputValue}
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
{keys.length === 0 ? "데이터를 불러오는 중..." : "일치하는 키가 없습니다."}
</CommandEmpty>
<CommandGroup>
{keys.map((k) => (
<CommandItem
key={k}
value={k}
onSelect={(v) => {
onValueChange(v === value ? "" : v);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
value === k ? "opacity-100" : "opacity-0"
)}
/>
{k}
</CommandItem>
))}
</CommandGroup>
{keys.length === 0 ? (
<div className="px-3 py-2 text-center text-xs text-muted-foreground">
{inputValue.trim()
? "Enter로 입력 확정"
: "테이블에 데이터가 없습니다. 키를 직접 입력하세요."}
</div>
) : (
<>
<CommandEmpty className="py-2 text-center text-xs">
{inputValue.trim()
? "Enter로 직접 입력 확정"
: "일치하는 키가 없습니다."}
</CommandEmpty>
<CommandGroup>
{keys.map((k) => (
<CommandItem
key={k}
value={k}
onSelect={(v) => {
onValueChange(v === value ? "" : v);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
value === k ? "opacity-100" : "opacity-0"
)}
/>
{k}
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>

View File

@ -103,6 +103,7 @@ export interface PopIconConfig {
labelColor?: string;
labelFontSize?: number;
backgroundColor?: string;
iconColor?: string;
gradient?: GradientConfig;
borderRadiusPercent?: number;
sizeMode: IconSizeMode;
@ -337,12 +338,14 @@ export function PopIconComponent({
setPendingNavigate(null);
};
// 배경 스타일 (이미지 타입일 때는 배경 없음)
// 배경 스타일: transparent 설정이 최우선
const backgroundStyle: React.CSSProperties = iconType === "image"
? { backgroundColor: "transparent" }
: config?.gradient
? buildGradientStyle(config.gradient)
: { backgroundColor: config?.backgroundColor || "#e0e0e0" };
: config?.backgroundColor === "transparent"
? { backgroundColor: "transparent" }
: config?.gradient
? buildGradientStyle(config.gradient)
: { backgroundColor: config?.backgroundColor || "hsl(var(--muted))" };
// 테두리 반경 (0% = 사각형, 100% = 원형)
const radiusPercent = config?.borderRadiusPercent ?? 20;
@ -352,6 +355,8 @@ export function PopIconComponent({
const isLabelRight = config?.labelPosition === "right";
const showLabel = config?.labelPosition !== "none" && (config?.label || label);
const effectiveIconColor = config?.iconColor || "#ffffff";
// 아이콘 렌더링
const renderIcon = () => {
// 빠른 선택
@ -361,7 +366,7 @@ export function PopIconComponent({
<DynamicLucideIcon
name={config.quickSelectValue}
size={iconSize * 0.5}
className="text-white"
style={{ color: effectiveIconColor }}
/>
);
} else if (config?.quickSelectType === "emoji" && config?.quickSelectValue) {
@ -398,36 +403,40 @@ export function PopIconComponent({
return <span style={{ fontSize: iconSize * 0.5 }}>📦</span>;
};
const hasLabel = showLabel && (config?.label || label);
const labelFontSize = config?.labelFontSize || 12;
return (
<div
className={cn(
"flex items-center justify-center cursor-pointer transition-transform hover:scale-105",
"flex h-full w-full items-center justify-center cursor-pointer transition-transform hover:scale-105",
isLabelRight ? "flex-row gap-2" : "flex-col"
)}
onClick={handleClick}
>
{/* 아이콘 컨테이너 */}
{/* 아이콘 컨테이너: 라벨이 있으면 라벨 공간만큼 축소 */}
<div
className="flex items-center justify-center"
className="flex shrink-0 items-center justify-center"
style={{
...backgroundStyle,
borderRadius,
width: iconSize,
height: iconSize,
minWidth: iconSize,
minHeight: iconSize,
maxWidth: "100%",
maxHeight: hasLabel && !isLabelRight ? `calc(100% - ${labelFontSize + 6}px)` : "100%",
aspectRatio: "1 / 1",
}}
>
{renderIcon()}
</div>
{/* 라벨 */}
{showLabel && (
{hasLabel && (
<span
className={cn("truncate max-w-full", !isLabelRight && "mt-1")}
className={cn("shrink-0 truncate max-w-full leading-tight", !isLabelRight && "mt-0.5")}
style={{
color: config?.labelColor || "hsl(var(--foreground))",
fontSize: config?.labelFontSize || 12,
fontSize: labelFontSize,
}}
>
{config?.label || label}
@ -453,8 +462,6 @@ export function PopIconComponent({
<AlertDialogFooter>
<AlertDialogAction
onClick={handleConfirmNavigate}
className="text-white"
style={{ backgroundColor: "#0984e3" }}
>
</AlertDialogAction>
@ -853,23 +860,69 @@ function LabelSettings({ config, onUpdate }: PopIconConfigPanelProps) {
// 스타일 설정
function StyleSettings({ config, onUpdate }: PopIconConfigPanelProps) {
const bgColor = config?.backgroundColor || "";
const iconColor = config?.iconColor || "#ffffff";
const isTransparent = bgColor === "transparent";
return (
<div className="space-y-2">
<Label className="text-xs">
: {config?.borderRadiusPercent ?? 20}%
</Label>
<input
type="range"
min={0}
max={100}
step={5}
value={config?.borderRadiusPercent ?? 20}
onChange={(e) => onUpdate({
...config,
borderRadiusPercent: Number(e.target.value)
})}
className="w-full"
/>
<div className="space-y-3">
{/* 배경색 */}
<div className="space-y-1">
<Label className="text-xs"></Label>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
<input
type="checkbox"
checked={isTransparent}
onChange={(e) => onUpdate({
...config,
backgroundColor: e.target.checked ? "transparent" : "",
iconColor: e.target.checked && iconColor === "#ffffff" ? "hsl(var(--foreground))" : iconColor,
})}
className="h-3.5 w-3.5 rounded"
/>
</label>
{!isTransparent && (
<Input
type="color"
value={bgColor || "#d1d5db"}
onChange={(e) => onUpdate({ ...config, backgroundColor: e.target.value })}
className="h-8 w-12 cursor-pointer p-0.5"
/>
)}
</div>
</div>
{/* 아이콘 색상 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="color"
value={iconColor.startsWith("hsl") ? "#000000" : iconColor}
onChange={(e) => onUpdate({ ...config, iconColor: e.target.value })}
className="h-8 w-12 cursor-pointer p-0.5"
/>
</div>
{/* 모서리 */}
<div className="space-y-1">
<Label className="text-xs">
: {config?.borderRadiusPercent ?? 20}%
</Label>
<input
type="range"
min={0}
max={100}
step={5}
value={config?.borderRadiusPercent ?? 20}
onChange={(e) => onUpdate({
...config,
borderRadiusPercent: Number(e.target.value)
})}
className="w-full"
/>
</div>
</div>
);
}

View File

@ -111,7 +111,7 @@ function PopProfileComponent({ config: rawConfig }: PopProfileComponentProps) {
sizeInfo.container,
sizeInfo.text,
)}
style={{ minWidth: sizeInfo.px, minHeight: sizeInfo.px }}
style={{ width: sizeInfo.px, height: sizeInfo.px, maxWidth: "100%", maxHeight: "100%" }}
>
{user?.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<img

View File

@ -28,6 +28,7 @@ import {
import { format, startOfWeek, endOfWeek, startOfMonth, endOfMonth } from "date-fns";
import { ko } from "date-fns/locale";
import { usePopEvent } from "@/hooks/pop";
import { useAuth } from "@/hooks/useAuth";
import { dataApi } from "@/lib/api/data";
import type {
PopSearchConfig,
@ -67,9 +68,11 @@ export function PopSearchComponent({
}: PopSearchComponentProps) {
const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) };
const { publish, subscribe, setSharedData } = usePopEvent(screenId || "");
const { user } = useAuth();
const [value, setValue] = useState<unknown>(config.defaultValue ?? "");
const [modalDisplayText, setModalDisplayText] = useState("");
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
const initialValueAppliedRef = useRef(false);
const normalizedType = normalizeInputType(config.inputType as string);
const isModalType = normalizedType === "modal";
@ -107,6 +110,21 @@ export function PopSearchComponent({
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns]
);
// 초기값 고정 세팅: 사용자 프로필에서 자동으로 값 설정
useEffect(() => {
if (initialValueAppliedRef.current) return;
if (!config.initialValueSource || config.initialValueSource.type !== "user_profile") return;
if (!user) return;
const col = config.initialValueSource.column;
const profileValue = (user as Record<string, unknown>)[col];
if (profileValue != null && profileValue !== "") {
initialValueAppliedRef.current = true;
const timer = setTimeout(() => emitFilterChanged(profileValue), 100);
return () => clearTimeout(timer);
}
}, [user, config.initialValueSource, emitFilterChanged]);
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
@ -238,12 +256,6 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa
return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
case "modal":
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} onClear={onModalClear} />;
case "status-chip":
return (
<div className="flex h-full items-center px-2 text-[10px] text-muted-foreground">
pop-status-bar
</div>
);
default:
return <PlaceholderInput inputType={config.inputType} />;
}
@ -1014,8 +1026,11 @@ function IconView({
return (
<div
key={i}
role="button"
tabIndex={0}
className="flex w-20 cursor-pointer flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-accent"
onClick={() => onSelect(row)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect(row); }}
>
<div className={cn("flex h-14 w-14 items-center justify-center rounded-xl text-xl font-bold text-white", color)}>
{firstChar}

View File

@ -209,6 +209,39 @@ function StepBasicSettings({ cfg, update }: StepProps) {
</div>
)}
{/* 초기값 고정 세팅 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.initialValueSource?.column || "__none__"}
onValueChange={(v) => {
if (v === "__none__") {
update({ initialValueSource: undefined });
} else {
update({ initialValueSource: { type: "user_profile", column: v } });
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="사용 안 함" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs"> </SelectItem>
<SelectItem value="userId" className="text-xs"> ID</SelectItem>
<SelectItem value="userName" className="text-xs"> </SelectItem>
<SelectItem value="deptCode" className="text-xs"> </SelectItem>
<SelectItem value="deptName" className="text-xs"></SelectItem>
<SelectItem value="positionCode" className="text-xs"> </SelectItem>
<SelectItem value="positionName" className="text-xs"></SelectItem>
</SelectContent>
</Select>
{cfg.initialValueSource && (
<p className="text-[9px] text-muted-foreground">
{cfg.initialValueSource.column}
</p>
)}
</div>
</div>
);
}
@ -231,15 +264,6 @@ function StepDetailSettings({ cfg, update, allComponents, connections, component
return <DatePresetDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "modal":
return <ModalDetailSettings cfg={cfg} update={update} />;
case "status-chip":
return (
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-[10px] text-muted-foreground">
pop-status-bar .
&quot; &quot; .
</p>
</div>
);
case "toggle":
return (
<div className="rounded-lg bg-muted/50 p-3">

View File

@ -1,25 +1,20 @@
// ===== pop-search 전용 타입 =====
// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
/** 검색 필드 입력 타입 (10종) */
/** 검색 필드 입력 타입 */
export type SearchInputType =
| "text"
| "number"
| "date"
| "date-preset"
| "select"
| "multi-select"
| "combo"
| "modal"
| "toggle"
| "status-chip";
| "toggle";
/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */
export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid";
/** 레거시 타입 -> modal로 정규화 */
/** 레거시 입력 타입 정규화 (DB 호환) */
export function normalizeInputType(t: string): SearchInputType {
if (t === "modal-table" || t === "modal-card" || t === "modal-icon-grid") return "modal";
if (t === "status-chip" || t === "multi-select" || t === "combo") return "text";
return t as SearchInputType;
}
@ -38,15 +33,6 @@ export interface SelectOption {
label: string;
}
/** 셀렉트 옵션 데이터 소스 (DB에서 동적 로딩) */
export interface SelectDataSource {
tableName: string;
valueColumn: string;
labelColumn: string;
sortColumn?: string;
sortDirection?: "asc" | "desc";
}
/** 모달 보여주기 방식: 테이블 or 아이콘 */
export type ModalDisplayStyle = "table" | "icon";
@ -79,22 +65,9 @@ export interface ModalSelectConfig {
distinct?: boolean;
}
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
export type StatusChipStyle = "tab" | "pill";
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
export interface StatusChipConfig {
showCount?: boolean;
countColumn?: string;
allowAll?: boolean;
allLabel?: string;
chipStyle?: StatusChipStyle;
useSubCount?: boolean;
}
/** pop-search 전체 설정 */
export interface PopSearchConfig {
inputType: SearchInputType | LegacySearchInputType;
inputType: SearchInputType | string;
fieldName: string;
placeholder?: string;
defaultValue?: unknown;
@ -103,9 +76,8 @@ export interface PopSearchConfig {
debounceMs?: number;
triggerOnEnter?: boolean;
// select/multi-select 전용
// select 전용
options?: SelectOption[];
optionsDataSource?: SelectDataSource;
// date 전용
dateSelectionMode?: DateSelectionMode;
@ -117,9 +89,6 @@ export interface PopSearchConfig {
// modal 전용
modalConfig?: ModalSelectConfig;
// status-chip 전용
statusChipConfig?: StatusChipConfig;
// 라벨
labelText?: string;
labelVisible?: boolean;
@ -129,6 +98,12 @@ export interface PopSearchConfig {
// 필터 대상 컬럼 복수 선택 (fieldName은 대표 컬럼, filterColumns는 전체 대상)
filterColumns?: string[];
// 초기값 고정 세팅 (사용자 프로필에서 자동으로 값 설정)
initialValueSource?: {
type: "user_profile";
column: string;
};
}
/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */
@ -157,17 +132,8 @@ export const SEARCH_INPUT_TYPE_LABELS: Record<SearchInputType, string> = {
date: "날짜",
"date-preset": "날짜 프리셋",
select: "단일 선택",
"multi-select": "다중 선택",
combo: "자동완성",
modal: "모달",
toggle: "토글",
"status-chip": "상태 칩 (대시보드)",
};
/** 상태 칩 스타일 라벨 (설정 패널용) */
export const STATUS_CHIP_STYLE_LABELS: Record<StatusChipStyle, string> = {
tab: "탭 (큰 숫자)",
pill: "알약 (작은 뱃지)",
};
/** 모달 보여주기 방식 라벨 */

View File

@ -25,6 +25,7 @@ export function PopStatusBarComponent({
const [selectedValue, setSelectedValue] = useState<string>("");
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
const [autoSubStatusColumn, setAutoSubStatusColumn] = useState<string | null>(null);
const [originalCount, setOriginalCount] = useState<number | null>(null);
// all_rows 이벤트 구독
useEffect(() => {
@ -47,13 +48,16 @@ export function PopStatusBarComponent({
const envelope = inner as {
rows?: unknown;
subStatusColumn?: string | null;
originalCount?: number;
};
if (Array.isArray(envelope.rows))
setAllRows(envelope.rows as Record<string, unknown>[]);
setAutoSubStatusColumn(envelope.subStatusColumn ?? null);
setOriginalCount(envelope.originalCount ?? null);
} else if (Array.isArray(inner)) {
setAllRows(inner as Record<string, unknown>[]);
setAutoSubStatusColumn(null);
setOriginalCount(null);
}
}
);
@ -130,7 +134,7 @@ export function PopStatusBarComponent({
return map;
}, [allRows, effectiveCountColumn, showCount]);
const totalCount = allRows.length;
const totalCount = originalCount ?? allRows.length;
const chipItems = useMemo(() => {
const items: { value: string; label: string; count: number }[] = [];

View File

@ -17,6 +17,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { icons as lucideIcons } from "lucide-react";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import {
FontSize,
@ -70,6 +71,9 @@ export interface PopTextConfig {
fontWeight?: FontWeight;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign; // 상하 정렬
marquee?: boolean; // 마키(흐르는 텍스트) 활성화
marqueeSpeed?: number; // 마키 속도 (초, 기본 15)
marqueeIcon?: string; // 마키 앞 아이콘 (lucide 이름)
}
const TEXT_TYPE_LABELS: Record<PopTextType, string> = {
@ -223,6 +227,16 @@ function DesignModePreview({
);
default:
// 일반 텍스트 미리보기
if (config?.marquee) {
return (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="shrink-0 pl-1 pr-2 text-muted-foreground text-[10px]">[]</span>
<span className={cn("truncate", FONT_SIZE_CLASSES[config?.fontSize || "base"])}>
{config?.content || label || "텍스트"}
</span>
</div>
);
}
return (
<div className={alignWrapperClass}>
<span
@ -369,8 +383,12 @@ function TextDisplay({
label?: string;
}) {
const sizeClass = FONT_SIZE_CLASSES[config?.fontSize || "base"];
const text = config?.content || label || "텍스트";
if (config?.marquee) {
return <MarqueeDisplay config={config} text={text} sizeClass={sizeClass} />;
}
// 정렬 래퍼 클래스
const alignWrapperClass = cn(
"flex w-full h-full",
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
@ -380,12 +398,56 @@ function TextDisplay({
return (
<div className={alignWrapperClass}>
<span className={cn("whitespace-pre-wrap", sizeClass)}>
{config?.content || label || "텍스트"}
{text}
</span>
</div>
);
}
function MarqueeDisplay({
config,
text,
sizeClass,
}: {
config?: PopTextConfig;
text: string;
sizeClass: string;
}) {
const speed = config?.marqueeSpeed || 15;
const iconName = config?.marqueeIcon;
const weightClass = FONT_WEIGHT_CLASSES[config?.fontWeight || "normal"];
const uniqueId = React.useId().replace(/:/g, "");
return (
<div className="flex h-full w-full items-center overflow-hidden">
{iconName && (() => {
const pascalName = iconName.replace(/(^|-)(\w)/g, (_: string, __: string, c: string) => c.toUpperCase());
const LucideIcon = (lucideIcons as Record<string, React.ComponentType<{ size?: number; className?: string }>>)[pascalName];
return LucideIcon ? (
<div className="shrink-0 pl-2 pr-3 text-muted-foreground">
<LucideIcon size={18} />
</div>
) : null;
})()}
<div className="relative flex-1 overflow-hidden">
<div
className="inline-flex whitespace-nowrap"
style={{ animation: `marquee-${uniqueId} ${speed}s linear infinite` }}
>
<span className={cn("inline-block", sizeClass, weightClass)} style={{ paddingRight: "100vw" }}>{text}</span>
<span className={cn("inline-block", sizeClass, weightClass)} style={{ paddingRight: "100vw" }}>{text}</span>
</div>
<style>{`
@keyframes marquee-${uniqueId} {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
`}</style>
</div>
</div>
);
}
// ========================================
// 설정 패널
// ========================================
@ -450,6 +512,44 @@ export function PopTextConfigPanel({
className="text-xs resize-none"
/>
</div>
{/* 마키(흐르는 텍스트) 설정 */}
<SectionDivider label="흐르는 텍스트" />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<Switch
checked={config?.marquee ?? false}
onCheckedChange={(v) => onUpdate({ ...config, marquee: v })}
/>
</div>
{config?.marquee && (
<>
<div className="space-y-1">
<Label className="text-xs">: {config?.marqueeSpeed || 15}</Label>
<input
type="range"
min={5}
max={60}
step={5}
value={config?.marqueeSpeed || 15}
onChange={(e) => onUpdate({ ...config, marqueeSpeed: Number(e.target.value) })}
className="w-full"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> (lucide )</Label>
<Input
value={config?.marqueeIcon || ""}
onChange={(e) => onUpdate({ ...config, marqueeIcon: e.target.value })}
placeholder="예: flag, megaphone, info"
className="h-8 text-xs"
/>
</div>
</>
)}
</div>
<SectionDivider label="스타일 설정" />
<FontSizeSelect config={config} onUpdate={onUpdate} />
<AlignmentSelect config={config} onUpdate={onUpdate} />

View File

@ -1,72 +1,327 @@
"use client";
import React, { useState } from "react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import type { PopWorkDetailConfig } from "../types";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, ChevronUp, ChevronDown } from "lucide-react";
import type { PopWorkDetailConfig, WorkDetailInfoBarField, ResultSectionConfig, ResultSectionType } from "../types";
interface PopWorkDetailConfigPanelProps {
config?: PopWorkDetailConfig;
onChange?: (config: PopWorkDetailConfig) => void;
}
const SECTION_TYPE_META: Record<ResultSectionType, { label: string }> = {
"total-qty": { label: "생산수량" },
"good-defect": { label: "양품/불량" },
"defect-types": { label: "불량 유형 상세" },
"note": { label: "비고" },
"box-packing": { label: "박스 포장" },
"label-print": { label: "라벨 출력" },
"photo": { label: "사진" },
"document": { label: "문서" },
"material-input": { label: "자재 투입" },
"barcode-scan": { label: "바코드 스캔" },
"plc-data": { label: "PLC 데이터" },
};
const ALL_SECTION_TYPES = Object.keys(SECTION_TYPE_META) as ResultSectionType[];
const DEFAULT_PHASE_LABELS: Record<string, string> = {
PRE: "작업 전",
IN: "작업 중",
POST: "작업 후",
};
const DEFAULT_INFO_BAR = {
enabled: true,
fields: [] as WorkDetailInfoBarField[],
};
const DEFAULT_STEP_CONTROL = {
requireStartBeforeInput: false,
autoAdvance: true,
};
const DEFAULT_NAVIGATION = {
showPrevNext: true,
showCompleteButton: true,
};
export function PopWorkDetailConfigPanel({
config,
onChange,
}: PopWorkDetailConfigPanelProps) {
const cfg: PopWorkDetailConfig = {
showTimer: config?.showTimer ?? true,
showQuantityInput: config?.showQuantityInput ?? true,
showQuantityInput: config?.showQuantityInput ?? false,
displayMode: config?.displayMode ?? "list",
phaseLabels: config?.phaseLabels ?? { ...DEFAULT_PHASE_LABELS },
infoBar: config?.infoBar ?? { ...DEFAULT_INFO_BAR },
stepControl: config?.stepControl ?? { ...DEFAULT_STEP_CONTROL },
navigation: config?.navigation ?? { ...DEFAULT_NAVIGATION },
resultSections: config?.resultSections ?? [],
};
const update = (partial: Partial<PopWorkDetailConfig>) => {
onChange?.({ ...cfg, ...partial });
};
const [newFieldLabel, setNewFieldLabel] = useState("");
const [newFieldColumn, setNewFieldColumn] = useState("");
const addInfoBarField = () => {
if (!newFieldLabel || !newFieldColumn) return;
const fields = [...(cfg.infoBar.fields ?? []), { label: newFieldLabel, column: newFieldColumn }];
update({ infoBar: { ...cfg.infoBar, fields } });
setNewFieldLabel("");
setNewFieldColumn("");
};
const removeInfoBarField = (idx: number) => {
const fields = (cfg.infoBar.fields ?? []).filter((_, i) => i !== idx);
update({ infoBar: { ...cfg.infoBar, fields } });
};
// --- 실적 입력 섹션 관리 ---
const sections = cfg.resultSections ?? [];
const usedTypes = new Set(sections.map((s) => s.type));
const availableTypes = ALL_SECTION_TYPES.filter((t) => !usedTypes.has(t));
const updateSections = (next: ResultSectionConfig[]) => {
update({ resultSections: next });
};
const addSection = (type: ResultSectionType) => {
updateSections([
...sections,
{ id: type, type, enabled: true, showCondition: { type: "always" } },
]);
};
const removeSection = (idx: number) => {
updateSections(sections.filter((_, i) => i !== idx));
};
const toggleSection = (idx: number, enabled: boolean) => {
const next = [...sections];
next[idx] = { ...next[idx], enabled };
updateSections(next);
};
const moveSection = (idx: number, dir: -1 | 1) => {
const target = idx + dir;
if (target < 0 || target >= sections.length) return;
const next = [...sections];
[next[idx], next[target]] = [next[target], next[idx]];
updateSections(next);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={cfg.showTimer}
onCheckedChange={(v) => update({ showTimer: v })}
/>
</div>
<div className="space-y-5">
{/* 기본 설정 */}
<Section title="기본 설정">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Select value={cfg.displayMode} onValueChange={(v) => update({ displayMode: v as "list" | "step" })}>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="list"></SelectItem>
<SelectItem value="step"></SelectItem>
</SelectContent>
</Select>
</div>
<ToggleRow label="타이머 표시" checked={cfg.showTimer} onChange={(v) => update({ showTimer: v })} />
</Section>
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={cfg.showQuantityInput}
onCheckedChange={(v) => update({ showQuantityInput: v })}
/>
</div>
{/* 실적 입력 섹션 */}
<Section title="실적 입력 섹션">
{sections.length === 0 ? (
<p className="text-xs text-muted-foreground py-1"> </p>
) : (
<div className="space-y-1">
{sections.map((s, i) => (
<div
key={s.id}
className="flex items-center gap-1 rounded-md border px-2 py-1"
>
<div className="flex flex-col">
<button
type="button"
className="h-3.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
disabled={i === 0}
onClick={() => moveSection(i, -1)}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
className="h-3.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
disabled={i === sections.length - 1}
onClick={() => moveSection(i, 1)}
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
<span className="flex-1 truncate text-xs font-medium">
{SECTION_TYPE_META[s.type]?.label ?? s.type}
</span>
<Switch
checked={s.enabled}
onCheckedChange={(v) => toggleSection(i, v)}
className="scale-75"
/>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0"
onClick={() => removeSection(i)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{availableTypes.length > 0 && <SectionAdder types={availableTypes} onAdd={addSection} />}
</Section>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
{/* 정보 바 */}
<Section title="작업지시 정보 바">
<ToggleRow
label="정보 바 표시"
checked={cfg.infoBar.enabled}
onChange={(v) => update({ infoBar: { ...cfg.infoBar, enabled: v } })}
/>
{cfg.infoBar.enabled && (
<div className="space-y-2 pt-1">
{(cfg.infoBar.fields ?? []).map((f, i) => (
<div key={i} className="flex items-center gap-1">
<span className="w-16 truncate text-xs text-muted-foreground">{f.label}</span>
<span className="flex-1 truncate text-xs font-mono">{f.column}</span>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => removeInfoBarField(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<div className="flex items-center gap-1">
<Input className="h-7 text-xs" placeholder="라벨" value={newFieldLabel} onChange={(e) => setNewFieldLabel(e.target.value)} />
<Input className="h-7 text-xs" placeholder="컬럼명" value={newFieldColumn} onChange={(e) => setNewFieldColumn(e.target.value)} />
<Button size="icon" variant="outline" className="h-7 w-7 shrink-0" onClick={addInfoBarField}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</Section>
{/* 단계 제어 */}
<Section title="단계 제어">
<ToggleRow
label="시작 전 입력 잠금"
checked={cfg.stepControl.requireStartBeforeInput}
onChange={(v) => update({ stepControl: { ...cfg.stepControl, requireStartBeforeInput: v } })}
/>
<ToggleRow
label="완료 시 자동 다음 이동"
checked={cfg.stepControl.autoAdvance}
onChange={(v) => update({ stepControl: { ...cfg.stepControl, autoAdvance: v } })}
/>
</Section>
{/* 네비게이션 */}
<Section title="네비게이션">
<ToggleRow
label="이전/다음 버튼"
checked={cfg.navigation.showPrevNext}
onChange={(v) => update({ navigation: { ...cfg.navigation, showPrevNext: v } })}
/>
<ToggleRow
label="공정 완료 버튼"
checked={cfg.navigation.showCompleteButton}
onChange={(v) => update({ navigation: { ...cfg.navigation, showCompleteButton: v } })}
/>
</Section>
{/* 단계 라벨 */}
<Section title="단계 라벨">
{(["PRE", "IN", "POST"] as const).map((phase) => (
<div key={phase} className="flex items-center gap-2">
<span className="w-12 text-xs font-medium text-muted-foreground">
{phase}
</span>
<span className="w-12 text-xs font-medium text-muted-foreground">{phase}</span>
<Input
className="h-8 text-xs"
className="h-7 text-xs"
value={cfg.phaseLabels[phase] ?? DEFAULT_PHASE_LABELS[phase]}
onChange={(e) =>
update({
phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value },
})
}
onChange={(e) => update({ phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value } })}
/>
</div>
))}
</div>
</Section>
</div>
);
}
function SectionAdder({
types,
onAdd,
}: {
types: ResultSectionType[];
onAdd: (type: ResultSectionType) => void;
}) {
const [selected, setSelected] = useState<string>("");
const handleAdd = () => {
if (!selected) return;
onAdd(selected as ResultSectionType);
setSelected("");
};
return (
<div className="flex items-center gap-1 pt-1">
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="섹션 선택" />
</SelectTrigger>
<SelectContent>
{types.map((t) => (
<SelectItem key={t} value={t} className="text-xs">
{SECTION_TYPE_META[t]?.label ?? t}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
className="h-7 shrink-0 gap-1 px-2 text-xs"
disabled={!selected}
onClick={handleAdd}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground">{title}</div>
{children}
</div>
);
}
function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<div className="flex items-center justify-between">
<Label className="text-xs">{label}</Label>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
}

View File

@ -8,8 +8,32 @@ import type { PopWorkDetailConfig } from "../types";
const defaultConfig: PopWorkDetailConfig = {
showTimer: true,
showQuantityInput: true,
showQuantityInput: false,
displayMode: "list",
phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" },
infoBar: {
enabled: true,
fields: [
{ label: "작업지시", column: "wo_no" },
{ label: "품목", column: "item_name" },
{ label: "공정", column: "__process_name" },
{ label: "지시수량", column: "qty" },
],
},
stepControl: {
requireStartBeforeInput: false,
autoAdvance: true,
},
navigation: {
showPrevNext: true,
showCompleteButton: true,
},
resultSections: [
{ id: "total-qty", type: "total-qty", enabled: true, showCondition: { type: "always" } },
{ id: "good-defect", type: "good-defect", enabled: true, showCondition: { type: "always" } },
{ id: "defect-types", type: "defect-types", enabled: true, showCondition: { type: "always" } },
{ id: "note", type: "note", enabled: true, showCondition: { type: "always" } },
],
};
PopComponentRegistry.registerComponent({

View File

@ -721,6 +721,9 @@ export interface PopCardListConfig {
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
requireFilter?: boolean;
requireFilterMessage?: string;
}
// =============================================
@ -736,12 +739,13 @@ export type CardCellType =
| "badge"
| "button"
| "number-input"
| "cart-button"
| "package-summary"
| "status-badge"
| "timeline"
| "action-buttons"
| "footer-status";
| "footer-status"
| "process-qty-summary"
| "mes-process-card";
// timeline 셀에서 사용하는 하위 단계 데이터
export interface TimelineProcessStep {
@ -752,6 +756,12 @@ export interface TimelineProcessStep {
isCurrent: boolean;
processId?: string | number; // 공정 테이블 레코드 PK (접수 등 UPDATE 대상 특정용)
rawData?: Record<string, unknown>; // 하위 테이블 원본 행 (하위 필터 매칭용)
// 수량 필드 (process-flow-summary 셀용)
inputQty?: number; // 접수량
totalProductionQty?: number; // 총생산량
goodQty?: number; // 양품
defectQty?: number; // 불량
yieldRate?: number; // 수율 (양품/총생산*100)
}
// timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정
@ -814,12 +824,6 @@ export interface CardCellDefinitionV2 {
limitColumn?: string;
autoInitMax?: boolean;
// cart-button 타입 전용
cartLabel?: string;
cartCancelLabel?: string;
cartIconType?: "lucide" | "emoji";
cartIconValue?: string;
// status-badge 타입 전용
statusColumn?: string;
statusMap?: Array<{ value: string; label: string; color: string }>;
@ -846,6 +850,9 @@ export interface CardCellDefinitionV2 {
footerStatusColumn?: string;
footerStatusMap?: Array<{ value: string; label: string; color: string }>;
showTopBorder?: boolean;
// process-qty-summary 타입 전용 - 공정별 수량 흐름 요약
qtyDisplayMode?: "current" | "flow"; // current: 현재 공정만, flow: 전체 공정 흐름
}
export interface ActionButtonUpdate {
@ -948,7 +955,7 @@ export interface CardGridConfigV2 {
// ----- V2 카드 선택 동작 -----
export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open";
export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open" | "built-in-work-detail";
export interface V2CardClickModalConfig {
screenId: string;
@ -986,13 +993,15 @@ export interface PopCardListV2Config {
cardClickModalConfig?: V2CardClickModalConfig;
/** 연결된 필터 값이 전달되기 전까지 데이터 비표시 */
hideUntilFiltered?: boolean;
hideUntilFilteredMessage?: string;
responsiveDisplay?: CardResponsiveConfig;
inputField?: CardInputFieldConfig;
packageConfig?: CardPackageConfig;
cartAction?: CardCartActionConfig;
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
ownerSortColumn?: string;
ownerFilterMode?: "priority" | "only";
workDetailConfig?: PopWorkDetailConfig;
showStatusTabs?: boolean;
}
/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */
@ -1006,8 +1015,55 @@ export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const;
// pop-work-detail 전용 타입
// =============================================
export interface WorkDetailInfoBarField {
label: string;
column: string;
}
export interface WorkDetailInfoBarConfig {
enabled: boolean;
fields: WorkDetailInfoBarField[];
}
export interface WorkDetailStepControl {
requireStartBeforeInput: boolean;
autoAdvance: boolean;
}
export interface WorkDetailNavigationConfig {
showPrevNext: boolean;
showCompleteButton: boolean;
}
export type ResultSectionType =
| "total-qty"
| "good-defect"
| "defect-types"
| "note"
| "box-packing"
| "label-print"
| "photo"
| "document"
| "material-input"
| "barcode-scan"
| "plc-data";
export interface ResultSectionConfig {
id: string;
type: ResultSectionType;
enabled: boolean;
showCondition?: { type: "always" | "last-process" };
}
export interface PopWorkDetailConfig {
showTimer: boolean;
/** @deprecated result-input 타입으로 대체 */
showQuantityInput: boolean;
/** 표시 모드: list(기존 리스트), step(한 항목씩 진행) */
displayMode: "list" | "step";
phaseLabels: Record<string, string>;
infoBar: WorkDetailInfoBarConfig;
stepControl: WorkDetailStepControl;
navigation: WorkDetailNavigationConfig;
resultSections?: ResultSectionConfig[];
}

View File

@ -266,6 +266,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -307,6 +308,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -340,6 +342,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@ -3055,6 +3058,7 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0",
@ -3708,6 +3712,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.6"
},
@ -3802,6 +3807,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@ -4115,6 +4121,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@ -6615,6 +6622,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -6625,6 +6633,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -6667,6 +6676,7 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@ -6749,6 +6759,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@ -7381,6 +7392,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -8531,7 +8543,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/d3": {
"version": "7.9.0",
@ -8853,6 +8866,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -9612,6 +9626,7 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -9700,6 +9715,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@ -9801,6 +9817,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -10972,6 +10989,7 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@ -11752,7 +11770,8 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/levn": {
"version": "0.4.1",
@ -13091,6 +13110,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -13384,6 +13404,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@ -13413,6 +13434,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@ -13461,6 +13483,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@ -13664,6 +13687,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -13733,6 +13757,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -13783,6 +13808,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -13815,7 +13841,8 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-leaflet": {
"version": "5.0.0",
@ -14123,6 +14150,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -14145,7 +14173,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/recharts/node_modules/redux-thunk": {
"version": "3.1.0",
@ -15175,7 +15204,8 @@
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@ -15263,6 +15293,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -15611,6 +15642,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"