Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node
This commit is contained in:
commit
02ac36c94f
|
|
@ -193,7 +193,9 @@ scripts/browser-test-*.js
|
|||
|
||||
# 개인 작업 문서
|
||||
popdocs/
|
||||
kshdocs/
|
||||
.cursor/rules/popdocs-safety.mdc
|
||||
.cursor/rules/overtime-registration.mdc
|
||||
|
||||
# 멀티 에이전트 MCP 태스크 큐
|
||||
mcp-task-queue/
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"lastSentAt": "2026-03-25T01:37:37.051Z"
|
||||
"lastSentAt": "2026-03-25T05:06:13.529Z"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 필터 설정 모달 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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": "리스트 목록",
|
||||
|
|
|
|||
|
|
@ -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 + 셀 타입별 렌더링)",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ export interface AdditionalTabConfig {
|
|||
}>;
|
||||
};
|
||||
|
||||
addButton?: {
|
||||
enabled: boolean;
|
||||
mode: "auto" | "modal";
|
||||
modalScreenId?: number;
|
||||
};
|
||||
|
||||
addConfig?: {
|
||||
targetTable?: string;
|
||||
autoFillColumns?: Record<string, any>;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "버튼에서 데이터+매핑 수집 요청 수신" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{/* 헤더 */}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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="카드 목록 선택" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 설정 배지 */}
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ const defaultConfig: PopCardListConfig = {
|
|||
// 레지스트리 등록
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-card-list",
|
||||
name: "카드 목록",
|
||||
description: "테이블 데이터를 카드 형태로 표시 (헤더 + 이미지 + 필드 목록)",
|
||||
name: "장바구니 목록",
|
||||
description: "장바구니 담기/확정 카드 목록 (입고, 출고, 수주 등)",
|
||||
category: "display",
|
||||
icon: "LayoutGrid",
|
||||
component: PopCardListComponent,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 컴포넌트로 분리되었습니다.
|
||||
새로운 "상태 바" 컴포넌트를 사용해주세요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "toggle":
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
|
|
|
|||
|
|
@ -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: "알약 (작은 뱃지)",
|
||||
};
|
||||
|
||||
/** 모달 보여주기 방식 라벨 */
|
||||
|
|
|
|||
|
|
@ -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 }[] = [];
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue