jskim-node #426

Merged
kjs merged 18 commits from jskim-node into main 2026-03-25 11:45:37 +09:00
71 changed files with 16200 additions and 548 deletions

View File

@ -1,7 +1,7 @@
{
"version": "1.0.0",
"lastScanned": 1772609393905,
"projectRoot": "/Users/johngreen/Dev/vexplor",
"lastScanned": 1774313213052,
"projectRoot": "/Users/kimjuseok/ERP-node",
"techStack": {
"languages": [
{
@ -13,7 +13,13 @@
]
}
],
"frameworks": [],
"frameworks": [
{
"name": "playwright",
"version": "1.58.2",
"category": "testing"
}
],
"packageManager": "npm",
"runtime": null
},
@ -28,16 +34,14 @@
"namingStyle": null,
"importStyle": null,
"testPattern": null,
"fileOrganization": "type-based"
"fileOrganization": null
},
"structure": {
"isMonorepo": false,
"workspaces": [],
"mainDirectories": [
"docs",
"lib",
"scripts",
"src"
"scripts"
],
"gitBranches": {
"defaultBranch": "main",
@ -46,37 +50,39 @@
},
"customNotes": [],
"directoryMap": {
"WebContent": {
"path": "WebContent",
"_local": {
"path": "_local",
"purpose": null,
"fileCount": 1,
"lastAccessed": 1774313213033,
"keyFiles": [
"pipeline-progress.json"
]
},
"ai-assistant": {
"path": "ai-assistant",
"purpose": null,
"fileCount": 5,
"lastAccessed": 1772609393856,
"lastAccessed": 1774313213036,
"keyFiles": [
"init.jsp",
"init_jqGrid.jsp",
"init_no_login.jsp",
"init_toastGrid.jsp",
"viewImage.jsp"
"Dockerfile.win",
"README.md",
"package-lock.json",
"package.json"
]
},
"backend": {
"path": "backend",
"purpose": null,
"fileCount": 6,
"lastAccessed": 1772609393857,
"keyFiles": [
"Dockerfile",
"Dockerfile.mac",
"build.gradle",
"gradlew",
"gradlew.bat"
]
"fileCount": 0,
"lastAccessed": 1774313213038,
"keyFiles": []
},
"backend-node": {
"path": "backend-node",
"purpose": null,
"fileCount": 14,
"lastAccessed": 1772609393872,
"fileCount": 17,
"lastAccessed": 1774313213039,
"keyFiles": [
"API_연동_가이드.md",
"API_키_정리.md",
@ -85,70 +91,88 @@
"README.md"
]
},
"backup": {
"path": "backup",
"purpose": null,
"fileCount": 6,
"lastAccessed": 1774313213040,
"keyFiles": [
"Dockerfile",
"README.md",
"backup.py",
"docker-compose.backup.yml"
]
},
"db": {
"path": "db",
"purpose": null,
"fileCount": 2,
"lastAccessed": 1772609393873,
"fileCount": 14,
"lastAccessed": 1774313213041,
"keyFiles": [
"00-create-roles.sh",
"migrate_company13_export.sh"
"check_category_values.sql",
"check_numbering_rules.sql",
"cleanup_duplicate_screens_daejin.sql",
"company7_screen_backup.sql"
]
},
"deploy": {
"path": "deploy",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1772609393873,
"lastAccessed": 1774313213041,
"keyFiles": []
},
"digitalTwin": {
"path": "digitalTwin",
"purpose": null,
"fileCount": 4,
"lastAccessed": 1774313213041,
"keyFiles": [
"architecture-v4.md",
"fleet-management-plan.md",
"디지털트윈 아키텍쳐_v3.png",
"디지털트윈 아키텍쳐_v4.png"
]
},
"docker": {
"path": "docker",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1772609393873,
"lastAccessed": 1774313213042,
"keyFiles": []
},
"docs": {
"path": "docs",
"purpose": "Documentation",
"fileCount": 23,
"lastAccessed": 1772609393873,
"fileCount": 35,
"lastAccessed": 1774313213042,
"keyFiles": [
"AI_화면생성_시스템_설계서.md",
"BOM_개발_현황.md",
"DB_ARCHITECTURE_ANALYSIS.md",
"DB_STRUCTURE_DIAGRAM.html",
"DB_WORKFLOW_ANALYSIS.md",
"KUBERNETES_DEPLOYMENT_GUIDE.md"
"DB_WORKFLOW_ANALYSIS.md"
]
},
"frontend": {
"path": "frontend",
"purpose": null,
"fileCount": 14,
"lastAccessed": 1772609393873,
"fileCount": 17,
"lastAccessed": 1774313213043,
"keyFiles": [
"MODAL_REPEATER_TABLE_DEBUG.md",
"README.md",
"approval-box-result.png",
"components.json",
"eslint.config.mjs",
"middleware.ts"
]
},
"hooks": {
"path": "hooks",
"purpose": null,
"fileCount": 1,
"lastAccessed": 1772609393879,
"keyFiles": [
"useScreenStandards.ts"
"eslint.config.mjs"
]
},
"k8s": {
"path": "k8s",
"purpose": null,
"fileCount": 7,
"lastAccessed": 1772609393882,
"lastAccessed": 1774313213043,
"keyFiles": [
"local-path-provisioner.yaml",
"namespace.yaml",
@ -157,18 +181,11 @@
"vexplor-frontend-deployment.yaml"
]
},
"lib": {
"path": "lib",
"purpose": "Library code",
"fileCount": 0,
"lastAccessed": 1772609393883,
"keyFiles": []
},
"mcp-agent-orchestrator": {
"path": "mcp-agent-orchestrator",
"purpose": null,
"fileCount": 4,
"lastAccessed": 1772609393883,
"lastAccessed": 1774313213043,
"keyFiles": [
"README.md",
"package-lock.json",
@ -176,91 +193,68 @@
"tsconfig.json"
]
},
"popdocs": {
"path": "popdocs",
"mcp-task-queue": {
"path": "mcp-task-queue",
"purpose": null,
"fileCount": 12,
"lastAccessed": 1772609393884,
"fileCount": 4,
"lastAccessed": 1774313213043,
"keyFiles": [
"ARCHITECTURE.md",
"CHANGELOG.md",
"FILES.md",
"INDEX.md",
"PLAN.md"
"package-lock.json",
"package.json",
"tsconfig.json"
]
},
"mcp-task-server": {
"path": "mcp-task-server",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313213043,
"keyFiles": []
},
"scripts": {
"path": "scripts",
"purpose": "Build/utility scripts",
"fileCount": 2,
"lastAccessed": 1772609393884,
"fileCount": 11,
"lastAccessed": 1774313213044,
"keyFiles": [
"add-modal-ids.py",
"remove-logs.js"
"analyze-company-info-layout.js",
"browser-test-admin-switch-button.js",
"browser-test-customer-crud.js",
"browser-test-customer-via-menu.js"
]
},
"src": {
"path": "src",
"purpose": "Source code",
"fileCount": 0,
"lastAccessed": 1772609393884,
"keyFiles": []
"test-output": {
"path": "test-output",
"purpose": null,
"fileCount": 2,
"lastAccessed": 1774313213044,
"keyFiles": [
"screen-149-field-type-verification-guide.md",
"unified-field-type-config-panel-test-guide.md"
]
},
"tomcat-conf": {
"path": "tomcat-conf",
"test-results": {
"path": "test-results",
"purpose": null,
"fileCount": 1,
"lastAccessed": 1772609393884,
"keyFiles": [
"context.xml"
]
},
"backend/build": {
"path": "backend/build",
"purpose": "Build output",
"fileCount": 0,
"lastAccessed": 1772609393884,
"lastAccessed": 1774313213044,
"keyFiles": []
},
"backend/src": {
"path": "backend/src",
"ai-assistant/src": {
"path": "ai-assistant/src",
"purpose": "Source code",
"fileCount": 0,
"lastAccessed": 1772609393884,
"keyFiles": []
},
"backend-node/data": {
"path": "backend-node/data",
"purpose": "Data files",
"fileCount": 0,
"lastAccessed": 1772609393884,
"keyFiles": []
},
"db/migrations": {
"path": "db/migrations",
"purpose": "Database migrations",
"fileCount": 16,
"lastAccessed": 1772609393884,
"keyFiles": [
"046_MIGRATION_FIX.md",
"046_QUICK_FIX.md",
"README_1003.md"
]
},
"db/scripts": {
"path": "db/scripts",
"purpose": "Build/utility scripts",
"fileCount": 1,
"lastAccessed": 1772609393884,
"lastAccessed": 1774313213045,
"keyFiles": [
"README_cleanup.md"
"app.js"
]
},
"frontend/app": {
"path": "frontend/app",
"purpose": "Application code",
"fileCount": 5,
"lastAccessed": 1772609393885,
"lastAccessed": 1774313213046,
"keyFiles": [
"favicon.ico",
"globals.css",
@ -271,7 +265,7 @@
"path": "frontend/components",
"purpose": "UI components",
"fileCount": 1,
"lastAccessed": 1772609393885,
"lastAccessed": 1774313213046,
"keyFiles": [
"GlobalFileViewer.tsx"
]
@ -280,49 +274,168 @@
"path": "mcp-agent-orchestrator/src",
"purpose": "Source code",
"fileCount": 1,
"lastAccessed": 1772609393885,
"lastAccessed": 1774313213047,
"keyFiles": [
"index.ts"
]
},
"src/controllers": {
"path": "src/controllers",
"purpose": "Controllers",
"fileCount": 1,
"lastAccessed": 1772609393885,
"keyFiles": [
"dataflowDiagramController.ts"
]
},
"src/routes": {
"path": "src/routes",
"purpose": "Route handlers",
"fileCount": 1,
"lastAccessed": 1772609393885,
"keyFiles": [
"dataflowDiagramRoutes.ts"
]
},
"src/services": {
"path": "src/services",
"purpose": "Business logic services",
"fileCount": 1,
"lastAccessed": 1772609393885,
"keyFiles": [
"dataflowDiagramService.ts"
]
},
"src/utils": {
"path": "src/utils",
"purpose": "Utility functions",
"mcp-task-queue/data": {
"path": "mcp-task-queue/data",
"purpose": "Data files",
"fileCount": 2,
"lastAccessed": 1772609393885,
"lastAccessed": 1774313213047,
"keyFiles": [
"databaseValidator.ts",
"queryBuilder.ts"
"knowledge.json",
"tasks.json"
]
},
"mcp-task-queue/dist": {
"path": "mcp-task-queue/dist",
"purpose": "Distribution/build output",
"fileCount": 28,
"lastAccessed": 1774313213048,
"keyFiles": [
"agent-runner.d.ts",
"agent-runner.d.ts.map",
"agent-runner.js"
]
},
"mcp-task-queue/node_modules": {
"path": "mcp-task-queue/node_modules",
"purpose": "Dependencies",
"fileCount": 1,
"lastAccessed": 1774313213049,
"keyFiles": []
},
"mcp-task-queue/src": {
"path": "mcp-task-queue/src",
"purpose": "Source code",
"fileCount": 7,
"lastAccessed": 1774313213049,
"keyFiles": [
"agent-runner.ts",
"index.ts",
"knowledge-store.ts"
]
},
"mcp-task-server/data": {
"path": "mcp-task-server/data",
"purpose": "Data files",
"fileCount": 0,
"lastAccessed": 1774313213049,
"keyFiles": []
},
"mcp-task-server/dist": {
"path": "mcp-task-server/dist",
"purpose": "Distribution/build output",
"fileCount": 6,
"lastAccessed": 1774313213050,
"keyFiles": [
"index.d.ts",
"index.js",
"taskStore.d.ts"
]
},
"mcp-task-server/node_modules": {
"path": "mcp-task-server/node_modules",
"purpose": "Dependencies",
"fileCount": 1,
"lastAccessed": 1774313213050,
"keyFiles": []
},
"mcp-task-server/src": {
"path": "mcp-task-server/src",
"purpose": "Source code",
"fileCount": 0,
"lastAccessed": 1774313213052,
"keyFiles": []
}
},
"hotPaths": [],
"hotPaths": [
{
"path": "frontend/app/(main)/sales/order/page.tsx",
"accessCount": 16,
"lastAccessed": 1774313958064,
"type": "file"
},
{
"path": "frontend/app/(main)/sales/shipping-plan/page.tsx",
"accessCount": 4,
"lastAccessed": 1774313720455,
"type": "file"
},
{
"path": "frontend/components/common/DataGrid.tsx",
"accessCount": 3,
"lastAccessed": 1774313504763,
"type": "file"
},
{
"path": "frontend/components/common/DynamicSearchFilter.tsx",
"accessCount": 2,
"lastAccessed": 1774313460662,
"type": "file"
},
{
"path": "frontend/app/(main)/production/plan-management/page.tsx",
"accessCount": 2,
"lastAccessed": 1774313461313,
"type": "file"
},
{
"path": "frontend/app/(main)",
"accessCount": 2,
"lastAccessed": 1774313529384,
"type": "directory"
},
{
"path": "frontend/lib/api/shipping.ts",
"accessCount": 2,
"lastAccessed": 1774313725308,
"type": "file"
},
{
"path": ".claude/plans/lively-wishing-yeti.md",
"accessCount": 2,
"lastAccessed": 1774313824670,
"type": "file"
},
{
"path": "frontend/app/(main)/sales/shipping-order/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313447495,
"type": "file"
},
{
"path": "frontend/app/(main)/sales/claim/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313450420,
"type": "file"
},
{
"path": "frontend/app/(main)/production/process-info/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313450623,
"type": "file"
},
{
"path": "frontend/components/common/ExcelUploadModal.tsx",
"accessCount": 1,
"lastAccessed": 1774313454238,
"type": "file"
},
{
"path": "frontend/app/(main)/master-data/item-info/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313528166,
"type": "file"
},
{
"path": "frontend/components/common/ShippingPlanModal.tsx",
"accessCount": 1,
"lastAccessed": 1774313925751,
"type": "file"
}
],
"userDirectives": []
}

View File

@ -0,0 +1,8 @@
{
"session_id": "d2bc3862-569e-4904-a3f9-6b20e3f14c43",
"ended_at": "2026-03-24T01:15:06.127Z",
"reason": "other",
"agents_spawned": 1,
"agents_completed": 1,
"modes_used": []
}

View File

@ -0,0 +1,8 @@
{
"session_id": "d6a10e69-4ebc-48f9-b451-c1d0587badc8",
"ended_at": "2026-03-24T01:15:07.644Z",
"reason": "other",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}

View File

@ -1,6 +0,0 @@
{
"timestamp": "2026-03-04T07:29:57.315Z",
"backgroundTasks": [],
"sessionStartTimestamp": "2026-03-04T07:29:53.176Z",
"sessionId": "591d357c-df9d-4bbc-8dfa-1b98a9184e23"
}

View File

@ -1 +0,0 @@
{"session_id":"591d357c-df9d-4bbc-8dfa-1b98a9184e23","transcript_path":"/Users/johngreen/.claude/projects/-Users-johngreen-Dev-vexplor/591d357c-df9d-4bbc-8dfa-1b98a9184e23.jsonl","cwd":"/Users/johngreen/Dev/vexplor","model":{"id":"claude-opus-4-6","display_name":"Opus 4.6"},"workspace":{"current_dir":"/Users/johngreen/Dev/vexplor","project_dir":"/Users/johngreen/Dev/vexplor","added_dirs":[]},"version":"2.1.66","output_style":{"name":"default"},"cost":{"total_cost_usd":0.516748,"total_duration_ms":65256,"total_api_duration_ms":28107,"total_lines_added":0,"total_lines_removed":0},"context_window":{"total_input_tokens":604,"total_output_tokens":838,"context_window_size":200000,"current_usage":{"input_tokens":1,"output_tokens":277,"cache_creation_input_tokens":1836,"cache_read_input_tokens":55498},"used_percentage":29,"remaining_percentage":71},"exceeds_200k_tokens":false}

View File

@ -1,3 +1,3 @@
{
"lastSentAt": "2026-03-04T07:30:30.883Z"
"lastSentAt": "2026-03-24T02:36:44.477Z"
}

View File

@ -0,0 +1,53 @@
{
"updatedAt": "2026-03-24T00:51:37.962Z",
"missions": [
{
"id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none",
"source": "session",
"name": "none",
"objective": "Session mission",
"createdAt": "2026-03-24T00:50:40.568Z",
"updatedAt": "2026-03-24T00:51:37.962Z",
"status": "done",
"workerCount": 1,
"taskCounts": {
"total": 1,
"pending": 0,
"blocked": 0,
"inProgress": 0,
"completed": 1,
"failed": 0
},
"agents": [
{
"name": "Explore:a9237b1",
"role": "Explore",
"ownership": "a9237b1b6af985371",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-24T00:51:37.962Z"
}
],
"timeline": [
{
"id": "session-start:a9237b1b6af985371:2026-03-24T00:50:40.568Z",
"at": "2026-03-24T00:50:40.568Z",
"kind": "update",
"agent": "Explore:a9237b1",
"detail": "started Explore:a9237b1",
"sourceKey": "session-start:a9237b1b6af985371"
},
{
"id": "session-stop:a9237b1b6af985371:2026-03-24T00:51:37.962Z",
"at": "2026-03-24T00:51:37.962Z",
"kind": "completion",
"agent": "Explore:a9237b1",
"detail": "completed",
"sourceKey": "session-stop:a9237b1b6af985371"
}
]
}
]
}

View File

@ -151,6 +151,7 @@ import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
@ -353,6 +354,7 @@ app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
app.use("/api/design", designRoutes); // 설계 모듈
app.use("/api/receiving", receivingRoutes); // 입고관리
app.use("/api/outbound", outboundRoutes); // 출고관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리

View File

@ -561,6 +561,34 @@ export class EntityJoinController {
});
}
}
/**
* ( )
* GET /api/table-management/tables/:tableName/column-values/:columnName
*/
async getColumnUniqueValues(req: Request, res: Response): Promise<void> {
try {
const { tableName, columnName } = req.params;
const companyCode = (req as any).user?.companyCode;
const data = await tableManagementService.getColumnDistinctValues(
tableName,
columnName,
companyCode
);
res.status(200).json({
success: true,
data,
});
} catch (error) {
logger.error(`컬럼 고유값 조회 실패: ${req.params.tableName}.${req.params.columnName}`, error);
res.status(500).json({
success: false,
message: "컬럼 고유값 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
}
export const entityJoinController = new EntityJoinController();

View File

@ -0,0 +1,509 @@
/**
*
*
* :
* - shipment_instruction + shipment_instruction_detail ()
* - purchase_order_mng (/)
* - item_info ()
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// 출고 목록 조회
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const {
outbound_type,
outbound_status,
search_keyword,
date_from,
date_to,
} = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIdx = 1;
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`om.company_code = $${paramIdx}`);
params.push(companyCode);
paramIdx++;
}
if (outbound_type && outbound_type !== "all") {
conditions.push(`om.outbound_type = $${paramIdx}`);
params.push(outbound_type);
paramIdx++;
}
if (outbound_status && outbound_status !== "all") {
conditions.push(`om.outbound_status = $${paramIdx}`);
params.push(outbound_status);
paramIdx++;
}
if (search_keyword) {
conditions.push(
`(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx} OR om.customer_name ILIKE $${paramIdx} OR om.reference_number ILIKE $${paramIdx})`
);
params.push(`%${search_keyword}%`);
paramIdx++;
}
if (date_from) {
conditions.push(`om.outbound_date >= $${paramIdx}`);
params.push(date_from);
paramIdx++;
}
if (date_to) {
conditions.push(`om.outbound_date <= $${paramIdx}`);
params.push(date_to);
paramIdx++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
om.*,
wh.warehouse_name
FROM outbound_mng om
LEFT JOIN warehouse_info wh
ON om.warehouse_code = wh.warehouse_code
AND om.company_code = wh.company_code
${whereClause}
ORDER BY om.created_date DESC
`;
const pool = getPool();
const result = await pool.query(query, params);
logger.info("출고 목록 조회", {
companyCode,
rowCount: result.rowCount,
});
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("출고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 출고 등록 (다건)
export async function create(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { items, outbound_number, outbound_date, warehouse_code, location_code, manager_id, memo } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "출고 품목이 없습니다." });
}
await client.query("BEGIN");
const insertedRows: any[] = [];
for (const item of items) {
const result = await client.query(
`INSERT INTO outbound_mng (
company_code, outbound_number, outbound_type, outbound_date,
reference_number, customer_code, customer_name,
item_code, item_name, specification, material, unit,
outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, manager_id, memo,
source_type, sales_order_id, shipment_plan_id, item_info_id,
destination_code, delivery_destination, delivery_address,
created_date, created_by, writer, status
) VALUES (
$1, $2, $3, $4,
$5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20, $21,
$22, $23, $24, $25,
$26, $27, $28,
NOW(), $29, $29, '출고'
) RETURNING *`,
[
companyCode,
outbound_number || item.outbound_number,
item.outbound_type,
outbound_date || item.outbound_date,
item.reference_number || null,
item.customer_code || null,
item.customer_name || null,
item.item_code || item.item_number || null,
item.item_name || null,
item.spec || item.specification || null,
item.material || null,
item.unit || "EA",
item.outbound_qty || 0,
item.unit_price || 0,
item.total_amount || 0,
item.lot_number || null,
warehouse_code || item.warehouse_code || null,
location_code || item.location_code || null,
item.outbound_status || "대기",
manager_id || item.manager_id || null,
memo || item.memo || null,
item.source_type || null,
item.sales_order_id || null,
item.shipment_plan_id || null,
item.item_info_id || null,
item.destination_code || null,
item.delivery_destination || null,
item.delivery_address || null,
userId,
]
);
insertedRows.push(result.rows[0]);
// 재고 업데이트 (inventory_stock): 출고 수량 차감
const itemCode = item.item_code || item.item_number || null;
const whCode = warehouse_code || item.warehouse_code || null;
const locCode = location_code || item.location_code || null;
const outQty = Number(item.outbound_qty) || 0;
if (itemCode && outQty > 0) {
const existingStock = await client.query(
`SELECT id FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
if (existingStock.rows.length > 0) {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text),
last_out_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[outQty, existingStock.rows[0].id]
);
} else {
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
await client.query(
`INSERT INTO inventory_stock (
company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_out_date,
created_date, updated_date, writer
) VALUES ($1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
[companyCode, itemCode, whCode, locCode, userId]
);
}
}
// 판매출고인 경우 출하지시의 ship_qty 업데이트
if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") {
await client.query(
`UPDATE shipment_instruction_detail
SET ship_qty = COALESCE(ship_qty, 0) + $1,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.outbound_qty || 0, item.source_id, companyCode]
);
}
}
await client.query("COMMIT");
logger.info("출고 등록 완료", {
companyCode,
userId,
count: insertedRows.length,
outbound_number,
});
return res.json({
success: true,
data: insertedRows,
message: `${insertedRows.length}건 출고 등록 완료`,
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("출고 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// 출고 수정
export async function update(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
const {
outbound_date, outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, manager_id: mgr, memo,
} = req.body;
const pool = getPool();
const result = await pool.query(
`UPDATE outbound_mng SET
outbound_date = COALESCE($1, outbound_date),
outbound_qty = COALESCE($2, outbound_qty),
unit_price = COALESCE($3, unit_price),
total_amount = COALESCE($4, total_amount),
lot_number = COALESCE($5, lot_number),
warehouse_code = COALESCE($6, warehouse_code),
location_code = COALESCE($7, location_code),
outbound_status = COALESCE($8, outbound_status),
manager_id = COALESCE($9, manager_id),
memo = COALESCE($10, memo),
updated_date = NOW(),
updated_by = $11
WHERE id = $12 AND company_code = $13
RETURNING *`,
[
outbound_date, outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, mgr, memo,
userId, id, companyCode,
]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
}
logger.info("출고 수정", { companyCode, userId, id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("출고 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 출고 삭제
export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
}
logger.info("출고 삭제", { companyCode, id });
return res.json({ success: true, message: "삭제 완료" });
} catch (error: any) {
logger.error("출고 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 판매출고용: 출하지시 데이터 조회
export async function getShipmentInstructions(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["si.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
sid.id AS detail_id,
si.id AS instruction_id,
si.instruction_no,
si.instruction_date,
si.partner_id,
si.status AS instruction_status,
sid.item_code,
sid.item_name,
sid.spec,
sid.material,
COALESCE(sid.plan_qty, 0) AS plan_qty,
COALESCE(sid.ship_qty, 0) AS ship_qty,
COALESCE(sid.order_qty, 0) AS order_qty,
GREATEST(COALESCE(sid.plan_qty, 0) - COALESCE(sid.ship_qty, 0), 0) AS remain_qty,
sid.source_type
FROM shipment_instruction si
JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id
AND si.company_code = sid.company_code
WHERE ${conditions.join(" AND ")}
AND COALESCE(sid.plan_qty, 0) > COALESCE(sid.ship_qty, 0)
ORDER BY si.instruction_date DESC, si.instruction_no`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("출하지시 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 반품출고용: 발주(입고) 데이터 조회
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
// 입고된 것만 (반품 대상)
conditions.push(
`COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0`
);
if (keyword) {
conditions.push(
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
id, purchase_no, order_date, supplier_code, supplier_name,
item_code, item_name, spec, material,
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty,
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty,
COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price,
status, due_date
FROM purchase_order_mng
WHERE ${conditions.join(" AND ")}
ORDER BY order_date DESC, purchase_no`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("발주 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 기타출고용: 품목 데이터 조회
export async function getItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
id, item_number, item_name, size AS spec, material, unit,
COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 출고번호 자동생성
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const today = new Date();
const yyyy = today.getFullYear();
const prefix = `OUT-${yyyy}-`;
const result = await pool.query(
`SELECT outbound_number FROM outbound_mng
WHERE company_code = $1 AND outbound_number LIKE $2
ORDER BY outbound_number DESC LIMIT 1`,
[companyCode, `${prefix}%`]
);
let seq = 1;
if (result.rows.length > 0) {
const lastNo = result.rows[0].outbound_number;
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
if (!isNaN(lastSeq)) seq = lastSeq + 1;
}
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
return res.json({ success: true, data: newNumber });
} catch (error: any) {
logger.error("출고번호 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 창고 목록 조회
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const result = await pool.query(
`SELECT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
WHERE company_code = $1 AND status != '삭제'
ORDER BY warehouse_name`,
[companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("창고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -173,7 +173,11 @@ export async function getPkgUnitItems(
const pool = getPool();
const result = await pool.query(
`SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
`SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit
FROM pkg_unit_item pui
LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code
WHERE pui.pkg_code=$1 AND pui.company_code=$2
ORDER BY pui.created_date DESC`,
[pkgCode, companyCode]
);
@ -410,7 +414,11 @@ export async function getLoadingUnitPkgs(
const pool = getPool();
const result = await pool.query(
`SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
`SELECT lup.*, pu.pkg_name, pu.pkg_type
FROM loading_unit_pkg lup
LEFT JOIN pkg_unit pu ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code
WHERE lup.loading_code=$1 AND lup.company_code=$2
ORDER BY lup.created_date DESC`,
[loadingCode, companyCode]
);
@ -476,3 +484,112 @@ export async function deleteLoadingUnitPkg(
res.status(500).json({ success: false, message: error.message });
}
}
// ──────────────────────────────────────────────
// 품목정보 연동 (division별 item_info 조회)
// ──────────────────────────────────────────────
export async function getItemsByDivision(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { divisionLabel } = req.params;
const { keyword } = req.query;
const pool = getPool();
// division 카테고리에서 해당 라벨의 코드 찾기
const catResult = await pool.query(
`SELECT value_code FROM category_values
WHERE table_name = 'item_info' AND column_name = 'division'
AND value_label = $1 AND company_code = $2
LIMIT 1`,
[divisionLabel, companyCode]
);
if (catResult.rows.length === 0) {
res.json({ success: true, data: [] });
return;
}
const divisionCode = catResult.rows[0].value_code;
const conditions: string[] = ["company_code = $1", `$2 = ANY(string_to_array(division, ','))`];
const params: any[] = [companyCode, divisionCode];
let paramIdx = 3;
if (keyword) {
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
params.push(`%${keyword}%`);
paramIdx++;
}
const result = await pool.query(
`SELECT id, item_number, item_name, size, material, unit, division
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name`,
params
);
logger.info(`품목 조회 (division=${divisionLabel})`, { companyCode, count: result.rowCount });
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// 일반 품목 조회 (포장재/적재함 제외, 매칭용)
export async function getGeneralItems(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const pool = getPool();
// 포장재/적재함 division 코드 조회
const catResult = await pool.query(
`SELECT value_code FROM category_values
WHERE table_name = 'item_info' AND column_name = 'division'
AND value_label IN ('포장재', '적재함') AND company_code = $1`,
[companyCode]
);
const excludeCodes = catResult.rows.map((r: any) => r.value_code);
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (excludeCodes.length > 0) {
// 다중 값(콤마 구분) 지원: 포장재/적재함 코드가 포함된 품목 제외
const excludeConditions = excludeCodes.map((_: any, i: number) => `$${paramIdx + i} = ANY(string_to_array(division, ','))`);
conditions.push(`(division IS NULL OR division = '' OR NOT (${excludeConditions.join(" OR ")}))`);
params.push(...excludeCodes);
paramIdx += excludeCodes.length;
}
if (keyword) {
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
params.push(`%${keyword}%`);
paramIdx++;
}
const result = await pool.query(
`SELECT id, item_number, item_name, size AS spec, material, unit, division
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name
LIMIT 200`,
params
);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("일반 품목 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -40,6 +40,28 @@ export async function getStockShortage(req: AuthenticatedRequest, res: Response)
}
}
// ─── 생산계획 목록 조회 ───
export async function getPlans(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { productType, status, startDate, endDate, itemCode } = req.query;
const data = await productionService.getPlans(companyCode, {
productType: productType as string,
status: status as string,
startDate: startDate as string,
endDate: endDate as string,
itemCode: itemCode as string,
});
return res.json({ success: true, data });
} catch (error: any) {
logger.error("생산계획 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 생산계획 상세 조회 ───
export async function getPlanById(req: AuthenticatedRequest, res: Response) {

View File

@ -170,6 +170,42 @@ export async function create(req: AuthenticatedRequest, res: Response) {
insertedRows.push(result.rows[0]);
// 재고 업데이트 (inventory_stock): 입고 수량 증가
const itemCode = item.item_number || null;
const whCode = warehouse_code || item.warehouse_code || null;
const locCode = location_code || item.location_code || null;
const inQty = Number(item.inbound_qty) || 0;
if (itemCode && inQty > 0) {
const existingStock = await client.query(
`SELECT id FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
if (existingStock.rows.length > 0) {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
last_in_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[inQty, existingStock.rows[0].id]
);
} else {
await client.query(
`INSERT INTO inventory_stock (
company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_in_date,
created_date, updated_date, writer
) VALUES ($1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`,
[companyCode, itemCode, whCode, locCode, String(inQty), userId]
);
}
}
// 구매입고인 경우 발주의 received_qty 업데이트
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
await client.query(

View File

@ -55,6 +55,15 @@ router.get(
entityJoinController.getTableDataWithJoins.bind(entityJoinController)
);
/**
* ( )
* GET /api/table-management/tables/:tableName/column-values/:columnName
*/
router.get(
"/tables/:tableName/column-values/:columnName",
entityJoinController.getColumnUniqueValues.bind(entityJoinController)
);
// ========================================
// 🎯 Entity 조인 설정 관리
// ========================================

View File

@ -0,0 +1,40 @@
/**
*
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as outboundController from "../controllers/outboundController";
const router = Router();
router.use(authenticateToken);
// 출고 목록 조회
router.get("/list", outboundController.getList);
// 출고번호 자동생성
router.get("/generate-number", outboundController.generateNumber);
// 창고 목록 조회
router.get("/warehouses", outboundController.getWarehouses);
// 소스 데이터: 출하지시 (판매출고)
router.get("/source/shipment-instructions", outboundController.getShipmentInstructions);
// 소스 데이터: 발주 (반품출고)
router.get("/source/purchase-orders", outboundController.getPurchaseOrders);
// 소스 데이터: 품목 (기타출고)
router.get("/source/items", outboundController.getItems);
// 출고 등록
router.post("/", outboundController.create);
// 출고 수정
router.put("/:id", outboundController.update);
// 출고 삭제
router.delete("/:id", outboundController.deleteOutbound);
export default router;

View File

@ -5,6 +5,7 @@ import {
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
getItemsByDivision, getGeneralItems,
} from "../controllers/packagingController";
const router = Router();
@ -33,4 +34,8 @@ router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
// 품목정보 연동 (division별)
router.get("/items/general", getGeneralItems);
router.get("/items/:divisionLabel", getItemsByDivision);
export default router;

View File

@ -16,6 +16,9 @@ router.get("/order-summary", productionController.getOrderSummary);
// 안전재고 부족분 조회
router.get("/stock-shortage", productionController.getStockShortage);
// 생산계획 목록 조회
router.get("/plans", productionController.getPlans);
// 생산계획 CRUD
router.get("/plan/:id", productionController.getPlanById);
router.put("/plan/:id", productionController.updatePlan);

View File

@ -35,6 +35,33 @@ export async function getOrderSummary(
const whereClause = conditions.join(" AND ");
// item_info에 lead_time 컬럼이 존재하는지 확인
const leadTimeColCheck = await pool.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'item_info' AND column_name = 'lead_time'
) AS has_lead_time
`);
const hasLeadTime = leadTimeColCheck.rows[0]?.has_lead_time === true;
const itemLeadTimeCte = hasLeadTime
? `item_lead_time AS (
SELECT
item_number,
id AS item_id,
COALESCE(lead_time::int, 0) AS lead_time
FROM item_info
WHERE company_code = $1
),`
: `item_lead_time AS (
SELECT
item_number,
id AS item_id,
0 AS lead_time
FROM item_info
WHERE company_code = $1
),`;
const query = `
WITH order_summary AS (
SELECT
@ -49,6 +76,7 @@ export async function getOrderSummary(
WHERE ${whereClause}
GROUP BY so.part_code, so.part_name
),
${itemLeadTimeCte}
stock_info AS (
SELECT
item_code,
@ -85,10 +113,12 @@ export async function getOrderSummary(
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
0
) AS required_plan_qty
) AS required_plan_qty,
COALESCE(ilt.lead_time, 0) AS lead_time
FROM order_summary os
LEFT JOIN stock_info si ON os.item_code = si.item_code
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
LEFT JOIN item_lead_time ilt ON (os.item_code = ilt.item_number OR os.item_code = ilt.item_id)
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""}
ORDER BY os.item_code;
`;
@ -155,6 +185,80 @@ export async function getStockShortage(companyCode: string) {
return result.rows;
}
// ─── 생산계획 목록 조회 ───
export async function getPlans(
companyCode: string,
options?: {
productType?: string;
status?: string;
startDate?: string;
endDate?: string;
itemCode?: string;
}
) {
const pool = getPool();
const conditions: string[] = ["p.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (companyCode !== "*") {
// 일반 회사: 자사 데이터만
} else {
// 최고관리자: 전체 데이터 (company_code 조건 제거)
conditions.length = 0;
}
if (options?.productType) {
conditions.push(`COALESCE(p.product_type, '완제품') = $${paramIdx}`);
params.push(options.productType);
paramIdx++;
}
if (options?.status && options.status !== "all") {
conditions.push(`p.status = $${paramIdx}`);
params.push(options.status);
paramIdx++;
}
if (options?.startDate) {
conditions.push(`p.end_date >= $${paramIdx}::date`);
params.push(options.startDate);
paramIdx++;
}
if (options?.endDate) {
conditions.push(`p.start_date <= $${paramIdx}::date`);
params.push(options.endDate);
paramIdx++;
}
if (options?.itemCode) {
conditions.push(`(p.item_code ILIKE $${paramIdx} OR p.item_name ILIKE $${paramIdx})`);
params.push(`%${options.itemCode}%`);
paramIdx++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
p.id, p.company_code, p.plan_no, p.plan_date,
p.item_code, p.item_name, p.product_type,
p.plan_qty, p.completed_qty, p.progress_rate,
p.start_date, p.end_date, p.due_date,
p.equipment_id, p.equipment_code, p.equipment_name,
p.status, p.priority, p.work_shift,
p.work_order_no, p.manager_name,
p.order_no, p.parent_plan_id, p.remarks,
p.hourly_capacity, p.daily_capacity, p.lead_time,
p.created_date, p.updated_date
FROM production_plan_mng p
${whereClause}
ORDER BY p.start_date ASC, p.item_code ASC
`;
const result = await pool.query(query, params);
logger.info("생산계획 목록 조회", { companyCode, count: result.rowCount });
return result.rows;
}
// ─── 생산계획 CRUD ───
export async function getPlanById(companyCode: string, planId: number) {
@ -267,49 +371,81 @@ export async function previewSchedule(
const deletedSchedules: any[] = [];
const keptSchedules: any[] = [];
for (const item of items) {
if (options.recalculate_unstarted) {
// 삭제 대상(planned) 상세 조회
// 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행
if (options.recalculate_unstarted) {
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
for (const itemCode of uniqueItemCodes) {
const deleteResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'`,
[companyCode, item.item_code, productType]
[companyCode, itemCode, productType]
);
deletedSchedules.push(...deleteResult.rows);
// 유지 대상(진행중 등) 상세 조회
const keptResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status NOT IN ('planned', 'completed', 'cancelled')`,
[companyCode, item.item_code, productType]
[companyCode, itemCode, productType]
);
keptSchedules.push(...keptResult.rows);
}
}
for (const item of items) {
const dailyCapacity = item.daily_capacity || 800;
const requiredQty = item.required_qty;
const itemLeadTime = item.lead_time || 0;
let requiredQty = item.required_qty;
// recalculate_unstarted 시, 삭제된 수량을 비율로 분배
if (options.recalculate_unstarted) {
const deletedQtyForItem = deletedSchedules
.filter((d: any) => d.item_code === item.item_code)
.reduce((sum: number, d: any) => sum + (parseFloat(d.plan_qty) || 0), 0);
if (deletedQtyForItem > 0) {
const totalRequestedForItem = items
.filter((i) => i.item_code === item.item_code)
.reduce((sum, i) => sum + i.required_qty, 0);
if (totalRequestedForItem > 0) {
requiredQty += Math.round(deletedQtyForItem * (item.required_qty / totalRequestedForItem));
}
}
}
if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity);
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
const dueDate = new Date(item.earliest_due_date);
const endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
let startDate: Date;
let endDate: Date;
if (itemLeadTime > 0) {
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
endDate = new Date(dueDate);
startDate = new Date(dueDate);
startDate.setDate(startDate.getDate() - itemLeadTime);
} else {
// 리드타임이 없으면 기존 로직 (생산능력 기반)
const productionDays = Math.ceil(requiredQty / dailyCapacity);
endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
}
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + productionDays);
endDate.setDate(endDate.getDate() + duration);
}
// 해당 품목의 수주 건수 확인
@ -326,10 +462,11 @@ export async function previewSchedule(
required_qty: requiredQty,
daily_capacity: dailyCapacity,
hourly_capacity: item.hourly_capacity || 100,
production_days: productionDays,
production_days: itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity),
start_date: startDate.toISOString().split("T")[0],
end_date: endDate.toISOString().split("T")[0],
due_date: item.earliest_due_date,
lead_time: itemLeadTime,
order_count: orderCount,
status: "planned",
});
@ -343,7 +480,7 @@ export async function previewSchedule(
};
logger.info("자동 스케줄 미리보기", { companyCode, summary });
return { summary, previews, deletedSchedules, keptSchedules };
return { summary, schedules: previews, deletedSchedules, keptSchedules };
}
export async function generateSchedule(
@ -363,10 +500,22 @@ export async function generateSchedule(
let deletedCount = 0;
let keptCount = 0;
const newSchedules: any[] = [];
const deletedQtyByItem = new Map<string, number>();
// 같은 item_code에 대한 삭제는 한 번만 수행
if (options.recalculate_unstarted) {
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
for (const itemCode of uniqueItemCodes) {
const deletedQtyResult = await client.query(
`SELECT COALESCE(SUM(COALESCE(plan_qty::numeric, 0)), 0) AS deleted_qty
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'`,
[companyCode, itemCode, productType]
);
deletedQtyByItem.set(itemCode, parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0);
for (const item of items) {
// 기존 미진행(planned) 스케줄 처리
if (options.recalculate_unstarted) {
const deleteResult = await client.query(
`DELETE FROM production_plan_mng
WHERE company_code = $1
@ -374,7 +523,7 @@ export async function generateSchedule(
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'
RETURNING id`,
[companyCode, item.item_code, productType]
[companyCode, itemCode, productType]
);
deletedCount += deleteResult.rowCount || 0;
@ -384,32 +533,58 @@ export async function generateSchedule(
AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status NOT IN ('planned', 'completed', 'cancelled')`,
[companyCode, item.item_code, productType]
[companyCode, itemCode, productType]
);
keptCount += parseInt(keptResult.rows[0].cnt, 10);
}
}
// 생산일수 계산
for (const item of items) {
// 필요 수량 계산 (삭제된 planned 수량을 비율로 분배)
const dailyCapacity = item.daily_capacity || 800;
const requiredQty = item.required_qty;
const itemLeadTime = item.lead_time || 0;
let requiredQty = item.required_qty;
if (options.recalculate_unstarted) {
const deletedQty = deletedQtyByItem.get(item.item_code) || 0;
if (deletedQty > 0) {
const totalRequestedForItem = items
.filter((i) => i.item_code === item.item_code)
.reduce((sum, i) => sum + i.required_qty, 0);
if (totalRequestedForItem > 0) {
requiredQty += Math.round(deletedQty * (item.required_qty / totalRequestedForItem));
}
}
}
if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity);
// 시작일 = 납기일 - 생산일수 - 안전리드타임
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
const dueDate = new Date(item.earliest_due_date);
const endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
let startDate: Date;
let endDate: Date;
if (itemLeadTime > 0) {
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
endDate = new Date(dueDate);
startDate = new Date(dueDate);
startDate.setDate(startDate.getDate() - itemLeadTime);
} else {
// 리드타임이 없으면 기존 로직 (생산능력 기반)
const productionDays = Math.ceil(requiredQty / dailyCapacity);
endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
}
// 시작일이 오늘보다 이전이면 오늘로 조정
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + productionDays);
endDate.setDate(endDate.getDate() + duration);
}
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
@ -576,13 +751,24 @@ async function getBomChildItems(
companyCode: string,
itemCode: string
) {
// item_info에 lead_time 컬럼 존재 여부 확인
const colCheck = await client.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'item_info' AND column_name = 'lead_time'
) AS has_lead_time
`);
const hasLeadTime = colCheck.rows[0]?.has_lead_time === true;
const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time::int, 0)" : "0";
const bomQuery = `
SELECT
bd.child_item_id,
ii.item_name AS child_item_name,
ii.item_number AS child_item_code,
bd.quantity AS bom_qty,
bd.unit
bd.unit,
${leadTimeCol} AS child_lead_time
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code
@ -641,9 +827,12 @@ export async function previewSemiSchedule(
if (requiredQty <= 0) continue;
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
const semiDueDate = plan.start_date;
const semiEndDate = new Date(plan.start_date);
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
previews.push({
parent_plan_id: plan.id,
@ -653,13 +842,14 @@ export async function previewSemiSchedule(
item_name: bomItem.child_item_name || bomItem.child_item_id,
plan_qty: requiredQty,
bom_qty: parseFloat(bomItem.bom_qty) || 1,
lead_time: childLeadTime,
start_date: semiStartDate.toISOString().split("T")[0],
end_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
: semiEndDate.toISOString().split("T")[0],
due_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
: semiEndDate.toISOString().split("T")[0],
product_type: "반제품",
status: "planned",
});
@ -683,7 +873,7 @@ export async function previewSemiSchedule(
parent_count: plansResult.rowCount,
};
return { summary, previews, deletedSchedules, keptSchedules };
return { summary, schedules: previews, deletedSchedules, keptSchedules };
}
// ─── 반제품 계획 자동 생성 ───
@ -740,10 +930,12 @@ export async function generateSemiSchedule(
if (requiredQty <= 0) continue;
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
const semiDueDate = plan.start_date;
const semiEndDate = plan.start_date;
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
const planNoResult = await client.query(

View File

@ -211,7 +211,8 @@ class TableCategoryValueService {
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
updated_by AS "updatedBy",
path
FROM category_values
WHERE table_name = $1
AND column_name = $2
@ -1441,7 +1442,7 @@ class TableCategoryValueService {
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
if (companyCode === "*") {
query = `
SELECT DISTINCT value_code, value_label
SELECT DISTINCT value_code, value_label, path
FROM category_values
WHERE value_code IN (${placeholders1})
`;
@ -1449,7 +1450,7 @@ class TableCategoryValueService {
} else {
const companyIdx = n + 1;
query = `
SELECT DISTINCT value_code, value_label
SELECT DISTINCT value_code, value_label, path
FROM category_values
WHERE value_code IN (${placeholders1})
AND (company_code = $${companyIdx} OR company_code = '*')
@ -1460,10 +1461,15 @@ class TableCategoryValueService {
const result = await pool.query(query, params);
// { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선)
// path가 있고 '/'를 포함하면(depth>1) 전체 경로를 ' > ' 구분자로 표시
const labels: Record<string, string> = {};
for (const row of result.rows) {
if (!labels[row.value_code]) {
labels[row.value_code] = row.value_label;
if (row.path && row.path.includes('/')) {
labels[row.value_code] = row.path.replace(/\//g, ' > ');
} else {
labels[row.value_code] = row.value_label;
}
}
}

View File

@ -1575,7 +1575,7 @@ export class TableManagementService {
switch (operator) {
case "equals":
return {
whereClause: `${columnName}::text = $${paramIndex}`,
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
values: [actualValue],
paramCount: 1,
};
@ -1859,10 +1859,10 @@ export class TableManagementService {
};
}
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
// select 필터(equals)인 경우 — 다중 값(콤마 구분) 지원
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
values: [String(value)],
paramCount: 1,
};
@ -3357,16 +3357,20 @@ export class TableManagementService {
const safeColumn = `main."${columnName}"`;
switch (operator) {
case "equals":
case "equals": {
const safeVal = String(value).replace(/'/g, "''");
filterConditions.push(
`${safeColumn} = '${String(value).replace(/'/g, "''")}'`
`('${safeVal}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal}')`
);
break;
case "not_equals":
}
case "not_equals": {
const safeVal2 = String(value).replace(/'/g, "''");
filterConditions.push(
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
`NOT ('${safeVal2}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal2}')`
);
break;
}
case "in": {
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
if (inArr.length > 0) {
@ -3408,6 +3412,31 @@ export class TableManagementService {
case "is_not_null":
filterConditions.push(`${safeColumn} IS NOT NULL`);
break;
case "not_contains":
filterConditions.push(
`${safeColumn}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`
);
break;
case "greater_than":
filterConditions.push(
`(${safeColumn})::numeric > ${parseFloat(String(value))}`
);
break;
case "less_than":
filterConditions.push(
`(${safeColumn})::numeric < ${parseFloat(String(value))}`
);
break;
case "greater_or_equal":
filterConditions.push(
`(${safeColumn})::numeric >= ${parseFloat(String(value))}`
);
break;
case "less_or_equal":
filterConditions.push(
`(${safeColumn})::numeric <= ${parseFloat(String(value))}`
);
break;
}
}
@ -3424,6 +3453,89 @@ export class TableManagementService {
}
}
// 🆕 filterGroups 처리 (런타임 필터 빌더 - 그룹별 AND/OR 지원)
if (
options.dataFilter &&
options.dataFilter.filterGroups &&
options.dataFilter.filterGroups.length > 0
) {
const groupConditions: string[] = [];
for (const group of options.dataFilter.filterGroups) {
if (!group.conditions || group.conditions.length === 0) continue;
const conditions: string[] = [];
for (const condition of group.conditions) {
const { columnName, operator, value } = condition;
if (!columnName) continue;
const safeCol = `main."${columnName}"`;
switch (operator) {
case "equals":
conditions.push(`${safeCol}::text = '${String(value).replace(/'/g, "''")}'`);
break;
case "not_equals":
conditions.push(`${safeCol}::text != '${String(value).replace(/'/g, "''")}'`);
break;
case "contains":
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}%'`);
break;
case "not_contains":
conditions.push(`${safeCol}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`);
break;
case "starts_with":
conditions.push(`${safeCol}::text LIKE '${String(value).replace(/'/g, "''")}%'`);
break;
case "ends_with":
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}'`);
break;
case "greater_than":
conditions.push(`(${safeCol})::numeric > ${parseFloat(String(value))}`);
break;
case "less_than":
conditions.push(`(${safeCol})::numeric < ${parseFloat(String(value))}`);
break;
case "greater_or_equal":
conditions.push(`(${safeCol})::numeric >= ${parseFloat(String(value))}`);
break;
case "less_or_equal":
conditions.push(`(${safeCol})::numeric <= ${parseFloat(String(value))}`);
break;
case "is_null":
conditions.push(`(${safeCol} IS NULL OR ${safeCol}::text = '')`);
break;
case "is_not_null":
conditions.push(`(${safeCol} IS NOT NULL AND ${safeCol}::text != '')`);
break;
case "in": {
const inArr = Array.isArray(value) ? value : [String(value)];
if (inArr.length > 0) {
const vals = inArr.map((v) => `'${String(v).replace(/'/g, "''")}'`).join(", ");
conditions.push(`${safeCol}::text IN (${vals})`);
}
break;
}
}
}
if (conditions.length > 0) {
const logic = group.logic === "OR" ? " OR " : " AND ";
groupConditions.push(`(${conditions.join(logic)})`);
}
}
if (groupConditions.length > 0) {
const groupWhere = groupConditions.join(" AND ");
whereClause = whereClause
? `${whereClause} AND ${groupWhere}`
: groupWhere;
logger.info(`🔍 필터 그룹 적용 (Entity 조인): ${groupWhere}`);
}
}
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
if (options.excludeFilter && options.excludeFilter.enabled) {
const {
@ -5387,4 +5499,40 @@ export class TableManagementService {
return [];
}
}
/**
* ( )
*/
async getColumnDistinctValues(
tableName: string,
columnName: string,
companyCode?: string
): Promise<{ value: string; label: string }[]> {
try {
// 테이블명/컬럼명 안전성 검증 (영문, 숫자, 언더스코어만 허용)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) {
logger.warn(`잘못된 테이블/컬럼명: ${tableName}.${columnName}`);
return [];
}
let sql = `SELECT DISTINCT "${columnName}"::text as value FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND "${columnName}"::text != ''`;
const params: any[] = [];
if (companyCode) {
params.push(companyCode);
sql += ` AND "company_code" = $${params.length}`;
}
sql += ` ORDER BY value LIMIT 500`;
const rows = await query<{ value: string }>(sql, params);
return rows.map((row) => ({
value: row.value,
label: row.value,
}));
} catch (error) {
logger.error(`컬럼 고유값 조회 실패: ${tableName}.${columnName}`, error);
return [];
}
}
}

View File

@ -0,0 +1,10 @@
{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":59735}
{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":93607}
{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":136249}
{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":261624}
{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":139427}

View File

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

View File

@ -0,0 +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",
"retry_count": 1
}

View File

@ -0,0 +1,109 @@
{
"updatedAt": "2026-03-25T01:37:19.659Z",
"missions": [
{
"id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none",
"source": "session",
"name": "none",
"objective": "Session mission",
"createdAt": "2026-03-25T00:33:45.197Z",
"updatedAt": "2026-03-25T01:37:19.659Z",
"status": "done",
"workerCount": 5,
"taskCounts": {
"total": 5,
"pending": 0,
"blocked": 0,
"inProgress": 0,
"completed": 5,
"failed": 0
},
"agents": [
{
"name": "Explore:ad233db",
"role": "Explore",
"ownership": "ad233db7fa6f059dd",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:34:44.932Z"
},
{
"name": "Explore:a31a0f7",
"role": "Explore",
"ownership": "a31a0f729d328643f",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:35:24.588Z"
},
{
"name": "executor:a9510b7",
"role": "executor",
"ownership": "a9510b7d8ec5a1ce7",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:42:01.730Z"
},
{
"name": "executor:a1c1d18",
"role": "executor",
"ownership": "a1c1d186f0eb6dfc1",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:40:12.608Z"
},
{
"name": "executor:a9a231d",
"role": "executor",
"ownership": "a9a231d40fd5a150b",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T01:37:19.659Z"
}
],
"timeline": [
{
"id": "session-stop:a1c1d186f0eb6dfc1:2026-03-25T00:40:12.608Z",
"at": "2026-03-25T00:40:12.608Z",
"kind": "completion",
"agent": "executor:a1c1d18",
"detail": "completed",
"sourceKey": "session-stop:a1c1d186f0eb6dfc1"
},
{
"id": "session-stop:a9510b7d8ec5a1ce7:2026-03-25T00:42:01.730Z",
"at": "2026-03-25T00:42:01.730Z",
"kind": "completion",
"agent": "executor:a9510b7",
"detail": "completed",
"sourceKey": "session-stop:a9510b7d8ec5a1ce7"
},
{
"id": "session-start:a9a231d40fd5a150b:2026-03-25T01:35:00.232Z",
"at": "2026-03-25T01:35:00.232Z",
"kind": "update",
"agent": "executor:a9a231d",
"detail": "started executor:a9a231d",
"sourceKey": "session-start:a9a231d40fd5a150b"
},
{
"id": "session-stop:a9a231d40fd5a150b:2026-03-25T01:37:19.659Z",
"at": "2026-03-25T01:37:19.659Z",
"kind": "completion",
"agent": "executor:a9a231d",
"detail": "completed",
"sourceKey": "session-stop:a9a231d40fd5a150b"
}
]
}
]
}

View File

@ -0,0 +1,53 @@
{
"agents": [
{
"agent_id": "ad233db7fa6f059dd",
"agent_type": "Explore",
"started_at": "2026-03-25T00:33:45.197Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:34:44.932Z",
"duration_ms": 59735
},
{
"agent_id": "a31a0f729d328643f",
"agent_type": "Explore",
"started_at": "2026-03-25T00:33:50.981Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:35:24.588Z",
"duration_ms": 93607
},
{
"agent_id": "a9510b7d8ec5a1ce7",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T00:37:40.106Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:42:01.730Z",
"duration_ms": 261624
},
{
"agent_id": "a1c1d186f0eb6dfc1",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T00:37:56.359Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:40:12.608Z",
"duration_ms": 136249
},
{
"agent_id": "a9a231d40fd5a150b",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T01:35:00.232Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T01:37:19.659Z",
"duration_ms": 139427
}
],
"total_spawned": 5,
"total_completed": 5,
"total_failed": 0,
"last_updated": "2026-03-25T01:37:19.762Z"
}

View File

@ -0,0 +1,728 @@
"use client";
/**
*
*
* 좌측: 설비 (equipment_mng)
* 우측: ( / / )
*
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
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,
} 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 { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { ImageUpload } from "@/components/common/ImageUpload";
const EQUIP_TABLE = "equipment_mng";
const INSPECTION_TABLE = "equipment_inspection_item";
const CONSUMABLE_TABLE = "equipment_consumable";
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "equipment_code", label: "설비코드", width: "w-[110px]" },
{ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]" },
{ key: "equipment_type", label: "설비유형", width: "w-[90px]" },
{ key: "manufacturer", label: "제조사", width: "w-[100px]" },
{ key: "installation_location", label: "설치장소", width: "w-[100px]" },
{ key: "operation_status", label: "가동상태", width: "w-[80px]" },
];
const INSPECTION_COLUMNS: DataGridColumn[] = [
{ key: "inspection_item", label: "점검항목", minWidth: "min-w-[120px]", editable: true },
{ key: "inspection_cycle", label: "점검주기", width: "w-[80px]" },
{ key: "inspection_method", label: "점검방법", width: "w-[80px]" },
{ key: "lower_limit", label: "하한치", width: "w-[70px]", editable: true },
{ key: "upper_limit", label: "상한치", width: "w-[70px]", editable: true },
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
{ key: "inspection_content", label: "점검내용", minWidth: "min-w-[150px]", editable: true },
];
const CONSUMABLE_COLUMNS: DataGridColumn[] = [
{ key: "image_path", label: "이미지", width: "w-[50px]", renderType: "image", sortable: false, filterable: false },
{ key: "consumable_name", label: "소모품명", minWidth: "min-w-[120px]", editable: true },
{ key: "replacement_cycle", label: "교체주기", width: "w-[90px]", editable: true },
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
{ key: "specification", label: "규격", width: "w-[100px]", editable: true },
{ key: "manufacturer", label: "제조사", width: "w-[100px]", editable: true },
];
export default function EquipmentInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측
const [equipments, setEquipments] = useState<any[]>([]);
const [equipLoading, setEquipLoading] = useState(false);
const [equipCount, setEquipCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
// 우측 탭
const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info");
const [inspections, setInspections] = useState<any[]>([]);
const [inspectionLoading, setInspectionLoading] = useState(false);
const [consumables, setConsumables] = useState<any[]>([]);
const [consumableLoading, setConsumableLoading] = useState(false);
// 카테고리
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 모달
const [equipModalOpen, setEquipModalOpen] = useState(false);
const [equipEditMode, setEquipEditMode] = useState(false);
const [equipForm, setEquipForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 기본정보 탭 편집 폼
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
const [infoSaving, setInfoSaving] = useState(false);
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySourceEquip, setCopySourceEquip] = useState("");
const [copyItems, setCopyItems] = useState<any[]>([]);
const [copyChecked, setCopyChecked] = useState<Set<string>>(new Set());
const [copyLoading, setCopyLoading] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// equipment_mng 카테고리
for (const col of ["equipment_type", "operation_status"]) {
try {
const res = await apiClient.get(`/table-categories/${EQUIP_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
// inspection 카테고리
for (const col of ["inspection_cycle", "inspection_method"]) {
try {
const res = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCatOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return catOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 설비 조회
const fetchEquipments = useCallback(async () => {
setEquipLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
useEffect(() => { fetchEquipments(); }, [fetchEquipments]);
const selectedEquip = equipments.find((e) => e.id === selectedEquipId);
// 기본정보 탭 폼 초기화 (설비 선택 변경 시)
useEffect(() => {
if (selectedEquip) setInfoForm({ ...selectedEquip });
else setInfoForm({});
}, [selectedEquipId]); // eslint-disable-line react-hooks/exhaustive-deps
// 기본정보 저장
const handleInfoSave = async () => {
if (!infoForm.id) return;
setInfoSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = infoForm;
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("저장되었습니다.");
fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); }
finally { setInfoSaving(false); }
};
// 우측: 점검항목 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetch = async () => {
setInspectionLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setInspections(raw.map((r: any) => ({
...r,
inspection_cycle: resolve("inspection_cycle", r.inspection_cycle),
inspection_method: resolve("inspection_method", r.inspection_method),
})));
} catch { setInspections([]); } finally { setInspectionLoading(false); }
};
fetch();
}, [selectedEquip?.equipment_code, catOptions]);
// 우측: 소모품 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetch = async () => {
setConsumableLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
setConsumables(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setConsumables([]); } finally { setConsumableLoading(false); }
};
fetch();
}, [selectedEquip?.equipment_code]);
// 새로고침 헬퍼
const refreshRight = () => {
const eid = selectedEquipId;
setSelectedEquipId(null);
setTimeout(() => setSelectedEquipId(eid), 50);
};
// 설비 등록/수정
const openEquipRegister = () => { setEquipForm({}); setEquipEditMode(false); setEquipModalOpen(true); };
const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); };
const handleEquipSave = async () => {
if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; }
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = equipForm;
if (equipEditMode && id) {
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, fields);
toast.success("등록되었습니다.");
}
setEquipModalOpen(false); fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
const handleEquipDelete = async () => {
if (!selectedEquipId) return;
const ok = await confirm("설비를 삭제하시겠습니까?", { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: [{ id: selectedEquipId }] });
toast.success("삭제되었습니다."); setSelectedEquipId(null); fetchEquipments();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
setSaving(true);
try {
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...inspectionForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setInspectionModalOpen(false); refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 소모품 추가
// 소모품 품목 로드 (item_info에서 type 또는 division 라벨이 "소모품"인 것)
const loadConsumableItems = async () => {
try {
const flatten = (vals: any[]): any[] => {
const r: any[] = [];
for (const v of vals) { r.push(v); if (v.children?.length) r.push(...flatten(v.children)); }
return r;
};
// type과 division 카테고리 모두에서 "소모품" 코드 찾기
const [typeRes, divRes] = await Promise.all([
apiClient.get(`/table-categories/item_info/type/values`),
apiClient.get(`/table-categories/item_info/division/values`),
]);
const consumableType = flatten(typeRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
const consumableDiv = flatten(divRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
if (!consumableType && !consumableDiv) { setConsumableItemOptions([]); return; }
// 두 필터 결과를 합산 (중복 제거)
const filters: any[] = [];
if (consumableType) filters.push({ columnName: "type", operator: "equals", value: consumableType.valueCode });
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
const results = await Promise.all(filters.map((f) =>
apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [f] },
autoFilter: true,
})
));
const allItems = new Map<string, any>();
for (const res of results) {
const rows = res.data?.data?.data || res.data?.data?.rows || [];
for (const row of rows) allItems.set(row.id, row);
}
setConsumableItemOptions(Array.from(allItems.values()));
} catch { setConsumableItemOptions([]); }
};
const handleConsumableSave = async () => {
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
setSaving(true);
try {
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
...consumableForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setConsumableModalOpen(false); refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 점검항목 복사: 소스 설비 선택 시 점검항목 로드
const loadCopyItems = async (equipCode: string) => {
setCopySourceEquip(equipCode);
setCopyChecked(new Set());
if (!equipCode) { setCopyItems([]); return; }
setCopyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
autoFilter: true,
});
setCopyItems(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setCopyItems([]); } finally { setCopyLoading(false); }
};
const handleCopyApply = async () => {
const selected = copyItems.filter((i) => copyChecked.has(i.id));
if (selected.length === 0) { toast.error("복사할 항목을 선택해주세요."); return; }
setSaving(true);
try {
for (const item of selected) {
const { id, created_date, updated_date, writer, company_code, equipment_code, ...fields } = item;
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...fields, equipment_code: selectedEquip?.equipment_code,
});
}
toast.success(`${selected.length}개 점검항목이 복사되었습니다.`);
setCopyModalOpen(false); refreshRight();
} catch { toast.error("복사 실패"); } finally { setSaving(false); }
};
// 엑셀
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
// 셀렉트 렌더링 헬퍼
const catSelect = (key: string, value: string, onChange: (v: string) => void, placeholder: string) => (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
{(catOptions[key] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
return (
<div className="flex h-full flex-col gap-3 p-3">
<DynamicSearchFilter tableName={EQUIP_TABLE} filterId="equipment-info" onFilterChange={setSearchFilters} dataCount={equipCount}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
onClick={async () => {
setExcelDetecting(true);
try {
const r = await autoDetectMultiTableConfig(EQUIP_TABLE);
if (r.success && r.data) { setExcelChainConfig(r.data); setExcelUploadOpen(true); }
else toast.error("테이블 구조 분석 실패");
} catch { toast.error("오류"); } finally { setExcelDetecting(false); }
}}>
{excelDetecting ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5 mr-1" />}
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 설비 목록 */}
<ResizablePanel defaultSize={40} minSize={25}>
<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">
<Wrench className="w-4 h-4" /> <Badge variant="secondary" className="font-normal">{equipCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={openEquipRegister}><Plus className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={openEquipEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="destructive" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
</div>
</div>
<DataGrid gridId="equip-left" columns={LEFT_COLUMNS} data={equipments} loading={equipLoading}
selectedId={selectedEquipId} onSelect={setSelectedEquipId} onRowDoubleClick={() => openEquipEdit()}
emptyMessage="등록된 설비가 없습니다" />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 탭 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-2 border-b bg-muted/10 shrink-0">
<div className="flex items-center gap-1">
{([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => (
<button key={tab} onClick={() => setRightTab(tab)}
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1",
rightTab === tab ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
<Icon className="w-3.5 h-3.5" />{label}
{tab === "inspection" && inspections.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{inspections.length}</Badge>}
{tab === "consumable" && consumables.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{consumables.length}</Badge>}
</button>
))}
{selectedEquip && <Badge variant="outline" className="font-normal ml-2 text-xs">{selectedEquip.equipment_name}</Badge>}
</div>
<div className="flex gap-1.5">
{rightTab === "inspection" && (
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
</div>
{!selectedEquipId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm"> </div>
) : rightTab === "info" ? (
<div className="p-4 overflow-auto">
<div className="flex justify-end mb-3">
<Button size="sm" onClick={handleInfoSave} disabled={infoSaving}>
{infoSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={infoForm.equipment_code || ""} className="h-9 bg-muted/50" disabled />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.equipment_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.installation_location || ""} onChange={(e) => setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.manufacturer || ""} onChange={(e) => setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.model_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={infoForm.introduction_date || ""} onChange={(v) => setInfoForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")}
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<Input value={infoForm.remarks || ""} onChange={(e) => setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<ImageUpload value={infoForm.image_path} onChange={(v) => setInfoForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" />
</div>
</div>
</div>
) : rightTab === "inspection" ? (
<DataGrid gridId="equip-inspection" columns={INSPECTION_COLUMNS} data={inspections} loading={inspectionLoading}
showRowNumber={false} tableName={INSPECTION_TABLE} emptyMessage="점검항목이 없습니다"
onCellEdit={() => refreshRight()} />
) : (
<DataGrid gridId="equip-consumable" columns={CONSUMABLE_COLUMNS} data={consumables} loading={consumableLoading}
showRowNumber={false} tableName={CONSUMABLE_TABLE} emptyMessage="소모품이 없습니다"
onCellEdit={() => refreshRight()} />
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 설비 등록/수정 모달 */}
<FullscreenDialog open={equipModalOpen} onOpenChange={setEquipModalOpen}
title={equipEditMode ? "설비 수정" : "설비 등록"} description={equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}
defaultMaxWidth="max-w-2xl"
footer={<><Button variant="outline" onClick={() => setEquipModalOpen(false)}></Button>
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} </Button></>}>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<FormDatePicker value={equipForm.introduction_date || ""} onChange={(v) => setEquipForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
</div>
</FullscreenDialog>
{/* 점검항목 추가 모달 */}
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle><DialogDescription>{selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" /></div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.lower_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.upper_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setInspectionModalOpen(false)}></Button>
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
</DialogContent>
</Dialog>
{/* 소모품 추가 모달 */}
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle><DialogDescription>{selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5 col-span-2"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{consumableItemOptions.length > 0 ? (
<Select value={consumableForm.consumable_name || ""} onValueChange={(v) => {
const item = consumableItemOptions.find((i) => (i.item_name || i.item_number) === v);
setConsumableForm((p) => ({
...p,
consumable_name: v,
specification: item?.size || p.specification || "",
unit: item?.unit || p.unit || "",
manufacturer: item?.manufacturer || p.manufacturer || "",
}));
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="소모품 선택" /></SelectTrigger>
<SelectContent>
{consumableItemOptions.map((item) => (
<SelectItem key={item.id} value={item.item_name || item.item_number}>
{item.item_name}{item.size ? ` (${item.size})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div>
<Input value={consumableForm.consumable_name || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))}
placeholder="소모품명 직접 입력" className="h-9" />
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
)}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.replacement_cycle || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.unit || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.specification || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.manufacturer || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setConsumableModalOpen(false)}></Button>
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
</DialogContent>
</Dialog>
{/* 점검항목 복사 모달 */}
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader><DialogTitle> </DialogTitle>
<DialogDescription> {selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={copySourceEquip} onValueChange={(v) => loadCopyItems(v)}>
<SelectTrigger className="h-9"><SelectValue placeholder="복사할 설비 선택" /></SelectTrigger>
<SelectContent>
{equipments.filter((e) => e.equipment_code !== selectedEquip?.equipment_code).map((e) => (
<SelectItem key={e.equipment_code} value={e.equipment_code}>{e.equipment_name} ({e.equipment_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border rounded-lg overflow-auto max-h-[300px]">
{copyLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : copyItems.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없습니다" : "설비를 선택하세요"}</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox" checked={copyItems.length > 0 && copyChecked.size === copyItems.length}
onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} />
</TableHead>
<TableHead></TableHead><TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead><TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[70px]"></TableHead><TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyItems.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", copyChecked.has(item.id) && "bg-primary/5")}
onClick={() => setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}>
<TableCell className="text-center"><input type="checkbox" checked={copyChecked.has(item.id)} readOnly /></TableCell>
<TableCell className="text-sm">{item.inspection_item}</TableCell>
<TableCell className="text-xs">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-xs">{resolve("inspection_method", item.inspection_method)}</TableCell>
<TableCell className="text-xs">{item.lower_limit || "-"}</TableCell>
<TableCell className="text-xs">{item.upper_limit || "-"}</TableCell>
<TableCell className="text-xs">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{copyChecked.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCopyModalOpen(false)}></Button>
<Button onClick={handleCopyApply} disabled={saving || copyChecked.size === 0}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Copy className="w-4 h-4 mr-1.5" />}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 (멀티테이블) */}
{excelChainConfig && (
<MultiTableExcelUploadModal open={excelUploadOpen}
onOpenChange={(open) => { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }}
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
)}
{ConfirmDialogComponent}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,926 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
ResizableHandle, ResizablePanel, ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandInput, CommandList, CommandEmpty, CommandItem } from "@/components/ui/command";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import {
Search, Plus, Trash2, RotateCcw, Loader2, Package, Box, X, Save, Edit2, Download, ChevronsUpDown, Check,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
getItemsByDivision, getGeneralItems,
type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg,
} from "@/lib/api/packaging";
// --- 코드 → 라벨 매핑 ---
const PKG_TYPE_LABEL: Record<string, string> = {
BOX: "박스", PACK: "팩", CANBOARD: "캔보드", AIRCAP: "에어캡",
ZIPCOS: "집코스", CYLINDER: "원통형", POLYCARTON: "포리/카톤",
};
const LOADING_TYPE_LABEL: Record<string, string> = {
PALLET: "파렛트", WOOD_PALLET: "목재파렛트", PLASTIC_PALLET: "플라스틱파렛트",
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
CAGE: "케이지", ETC: "기타",
};
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용" };
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-emerald-100 text-emerald-800" : "bg-gray-100 text-gray-600";
const fmtSize = (w: any, l: any, h: any) => {
const vals = [w, l, h].map(v => Number(v) || 0);
return vals.some(v => v > 0) ? vals.join("×") : "-";
};
// 규격 문자열에서 치수 파싱
function parseSpecDimensions(spec: string | null) {
if (!spec) return { w: 0, l: 0, h: 0 };
const m3 = spec.match(/(\d+)\s*[x×]\s*(\d+)\s*[x×]\s*(\d+)/i);
if (m3) return { w: parseInt(m3[1]), l: parseInt(m3[2]), h: parseInt(m3[3]) };
const m2 = spec.match(/(\d+)\s*[x×]\s*(\d+)/i);
if (m2) return { w: parseInt(m2[1]), l: parseInt(m2[2]), h: 0 };
return { w: 0, l: 0, h: 0 };
}
export default function PackagingPage() {
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [activeTab, setActiveTab] = useState<"packing" | "loading">("packing");
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
// 포장재 데이터
const [pkgUnits, setPkgUnits] = useState<PkgUnit[]>([]);
const [pkgLoading, setPkgLoading] = useState(false);
const [selectedPkg, setSelectedPkg] = useState<PkgUnit | null>(null);
const [pkgItems, setPkgItems] = useState<PkgUnitItem[]>([]);
const [pkgItemsLoading, setPkgItemsLoading] = useState(false);
// 적재함 데이터
const [loadingUnits, setLoadingUnits] = useState<LoadingUnit[]>([]);
const [loadingLoading, setLoadingLoading] = useState(false);
const [selectedLoading, setSelectedLoading] = useState<LoadingUnit | null>(null);
const [loadingPkgs, setLoadingPkgs] = useState<LoadingUnitPkg[]>([]);
const [loadingPkgsLoading, setLoadingPkgsLoading] = useState(false);
// 모달
const [pkgModalOpen, setPkgModalOpen] = useState(false);
const [pkgModalMode, setPkgModalMode] = useState<"create" | "edit">("create");
const [pkgForm, setPkgForm] = useState<Record<string, any>>({});
const [pkgItemOptions, setPkgItemOptions] = useState<ItemInfoForPkg[]>([]);
const [pkgItemPopoverOpen, setPkgItemPopoverOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false);
const [loadModalMode, setLoadModalMode] = useState<"create" | "edit">("create");
const [loadForm, setLoadForm] = useState<Record<string, any>>({});
const [loadItemOptions, setLoadItemOptions] = useState<ItemInfoForPkg[]>([]);
const [loadItemPopoverOpen, setLoadItemPopoverOpen] = useState(false);
const [itemMatchModalOpen, setItemMatchModalOpen] = useState(false);
const [itemMatchKeyword, setItemMatchKeyword] = useState("");
const [itemMatchResults, setItemMatchResults] = useState<ItemInfoForPkg[]>([]);
const [itemMatchSelected, setItemMatchSelected] = useState<ItemInfoForPkg | null>(null);
const [itemMatchQty, setItemMatchQty] = useState(1);
const [pkgMatchModalOpen, setPkgMatchModalOpen] = useState(false);
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);
// --- 데이터 로드 ---
const fetchPkgUnits = useCallback(async () => {
setPkgLoading(true);
try {
const res = await getPkgUnits();
if (res.success) setPkgUnits(res.data);
} catch { /* ignore */ } finally { setPkgLoading(false); }
}, []);
const fetchLoadingUnits = useCallback(async () => {
setLoadingLoading(true);
try {
const res = await getLoadingUnits();
if (res.success) setLoadingUnits(res.data);
} catch { /* ignore */ } finally { setLoadingLoading(false); }
}, []);
useEffect(() => { fetchPkgUnits(); fetchLoadingUnits(); }, [fetchPkgUnits, fetchLoadingUnits]);
// 포장재 선택 시 매칭 품목 로드
const selectPkg = useCallback(async (pkg: PkgUnit) => {
setSelectedPkg(pkg);
setPkgItemsLoading(true);
try {
const res = await getPkgUnitItems(pkg.pkg_code);
if (res.success) setPkgItems(res.data);
} catch { setPkgItems([]); } finally { setPkgItemsLoading(false); }
}, []);
// 적재함 선택 시 포장구성 로드
const selectLoading = useCallback(async (lu: LoadingUnit) => {
setSelectedLoading(lu);
setLoadingPkgsLoading(true);
try {
const res = await getLoadingUnitPkgs(lu.loading_code);
if (res.success) setLoadingPkgs(res.data);
} catch { setLoadingPkgs([]); } finally { setLoadingPkgsLoading(false); }
}, []);
// 검색 필터 적용
const filteredPkgUnits = pkgUnits.filter((p) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw));
});
const filteredLoadingUnits = loadingUnits.filter((l) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (l.loading_code?.toLowerCase().includes(kw) || l.loading_name?.toLowerCase().includes(kw));
});
// --- 포장재 등록/수정 모달 ---
const openPkgModal = async (mode: "create" | "edit") => {
setPkgModalMode(mode);
if (mode === "edit" && selectedPkg) {
setPkgForm({ ...selectedPkg });
} else {
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" });
}
setPkgItemPopoverOpen(false);
try {
const res = await getItemsByDivision("포장재");
if (res.success) setPkgItemOptions(res.data);
} catch { setPkgItemOptions([]); }
setPkgModalOpen(true);
};
const onPkgItemSelect = (item: ItemInfoForPkg) => {
setPkgItemPopoverOpen(false);
const dims = parseSpecDimensions(item.size);
setPkgForm((prev) => ({
...prev,
pkg_code: item.item_number,
pkg_name: item.item_name,
width_mm: dims.w || prev.width_mm,
length_mm: dims.l || prev.length_mm,
height_mm: dims.h || prev.height_mm,
}));
};
const savePkgUnit = async () => {
if (!pkgForm.pkg_code || !pkgForm.pkg_name) { toast.error("포장코드와 포장명은 필수입니다."); return; }
if (!pkgForm.pkg_type) { toast.error("포장유형을 선택해주세요."); return; }
setSaving(true);
try {
if (pkgModalMode === "create") {
const res = await createPkgUnit(pkgForm);
if (res.success) { toast.success("포장재 등록 완료"); setPkgModalOpen(false); fetchPkgUnits(); }
} else {
const res = await updatePkgUnit(pkgForm.id, pkgForm);
if (res.success) { toast.success("포장재 수정 완료"); setPkgModalOpen(false); fetchPkgUnits(); setSelectedPkg(res.data); }
}
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
};
const handleDeletePkg = async (pkg: PkgUnit) => {
const ok = await confirm(`"${pkg.pkg_name}" 포장재를 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deletePkgUnit(pkg.id);
toast.success("삭제 완료");
setSelectedPkg(null); setPkgItems([]);
fetchPkgUnits();
} catch { toast.error("삭제 실패"); }
};
// --- 적재함 등록/수정 모달 ---
const openLoadModal = async (mode: "create" | "edit") => {
setLoadModalMode(mode);
if (mode === "edit" && selectedLoading) {
setLoadForm({ ...selectedLoading });
} else {
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" });
}
setLoadItemPopoverOpen(false);
try {
const res = await getItemsByDivision("적재함");
if (res.success) setLoadItemOptions(res.data);
} catch { setLoadItemOptions([]); }
setLoadModalOpen(true);
};
const onLoadItemSelect = (item: ItemInfoForPkg) => {
setLoadItemPopoverOpen(false);
const dims = parseSpecDimensions(item.size);
setLoadForm((prev) => ({
...prev,
loading_code: item.item_number,
loading_name: item.item_name,
width_mm: dims.w || prev.width_mm,
length_mm: dims.l || prev.length_mm,
height_mm: dims.h || prev.height_mm,
}));
};
const saveLoadingUnit = async () => {
if (!loadForm.loading_code || !loadForm.loading_name) { toast.error("적재함코드와 적재함명은 필수입니다."); return; }
if (!loadForm.loading_type) { toast.error("적재유형을 선택해주세요."); return; }
setSaving(true);
try {
if (loadModalMode === "create") {
const res = await createLoadingUnit(loadForm);
if (res.success) { toast.success("적재함 등록 완료"); setLoadModalOpen(false); fetchLoadingUnits(); }
} else {
const res = await updateLoadingUnit(loadForm.id, loadForm);
if (res.success) { toast.success("적재함 수정 완료"); setLoadModalOpen(false); fetchLoadingUnits(); setSelectedLoading(res.data); }
}
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
};
const handleDeleteLoading = async (lu: LoadingUnit) => {
const ok = await confirm(`"${lu.loading_name}" 적재함을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deleteLoadingUnit(lu.id);
toast.success("삭제 완료");
setSelectedLoading(null); setLoadingPkgs([]);
fetchLoadingUnits();
} catch { toast.error("삭제 실패"); }
};
// --- 품목 추가 모달 (포장재 매칭) ---
const openItemMatchModal = async () => {
setItemMatchKeyword(""); setItemMatchSelected(null); setItemMatchQty(1);
setItemMatchModalOpen(true);
try {
const res = await getGeneralItems();
if (res.success) setItemMatchResults(res.data);
} catch { setItemMatchResults([]); }
};
const searchItemsForMatch = async () => {
try {
const res = await getGeneralItems(itemMatchKeyword || undefined);
if (res.success) setItemMatchResults(res.data);
} catch { setItemMatchResults([]); }
};
const saveItemMatch = async () => {
if (!selectedPkg || !itemMatchSelected) { toast.error("품목을 선택해주세요."); return; }
if (itemMatchQty <= 0) { toast.error("포장수량을 입력해주세요."); return; }
setSaving(true);
try {
const res = await createPkgUnitItem({
pkg_code: selectedPkg.pkg_code,
item_number: itemMatchSelected.item_number,
pkg_qty: itemMatchQty,
});
if (res.success) { toast.success("품목 추가 완료"); setItemMatchModalOpen(false); selectPkg(selectedPkg); }
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
};
const handleDeletePkgItem = async (item: PkgUnitItem) => {
const ok = await confirm("매칭 품목을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deletePkgUnitItem(item.id);
toast.success("삭제 완료");
if (selectedPkg) selectPkg(selectedPkg);
} catch { toast.error("삭제 실패"); }
};
// --- 포장단위 추가 모달 (적재함 구성) ---
const openPkgMatchModal = () => {
setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod(""); setPkgMatchSearchKw("");
setPkgMatchModalOpen(true);
};
const savePkgMatch = async () => {
if (!selectedLoading || !pkgMatchSelected) { toast.error("포장단위를 선택해주세요."); return; }
if (pkgMatchQty <= 0) { toast.error("최대적재수량을 입력해주세요."); return; }
setSaving(true);
try {
const res = await createLoadingUnitPkg({
loading_code: selectedLoading.loading_code,
pkg_code: pkgMatchSelected.pkg_code,
max_load_qty: pkgMatchQty,
load_method: pkgMatchMethod || undefined,
});
if (res.success) { toast.success("포장단위 추가 완료"); setPkgMatchModalOpen(false); selectLoading(selectedLoading); }
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
};
const handleDeleteLoadPkg = async (lp: LoadingUnitPkg) => {
const ok = await confirm("적재 구성을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deleteLoadingUnitPkg(lp.id);
toast.success("삭제 완료");
if (selectedLoading) selectLoading(selectedLoading);
} catch { toast.error("삭제 실패"); }
};
return (
<div className="flex h-full flex-col gap-3 p-4">
{/* 검색 바 */}
<div className="flex items-center gap-2 rounded-lg border bg-card p-3">
<Input
placeholder="포장코드 / 포장명 / 적재함명 검색"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="h-9 w-[280px] text-xs"
/>
<Button size="sm" variant="outline" onClick={() => setSearchKeyword("")} className="h-9">
<RotateCcw className="mr-1 h-4 w-4" />
</Button>
</div>
{/* 탭 */}
<div className="flex gap-1 border-b">
{([["packing", "포장재 관리", filteredPkgUnits.length] as const, ["loading", "적재함 관리", filteredLoadingUnits.length] as const]).map(([tab, label, count]) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px",
activeTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab === "packing" ? <Package className="h-4 w-4" /> : <Box className="h-4 w-4" />}
{label}
<Badge variant="secondary" className="text-[10px] px-1.5">{count}</Badge>
</button>
))}
</div>
{/* 탭 콘텐츠 */}
<div className="flex-1 overflow-hidden">
{activeTab === "packing" ? (
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* 좌측: 포장재 목록 */}
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2.5">
<span className="text-sm font-semibold"> <span className="text-muted-foreground font-normal">({filteredPkgUnits.length})</span></span>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("create")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="text-[11px] bg-muted/50">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[90px]">(mm)</TableHead>
<TableHead className="p-2 w-[70px] text-right"></TableHead>
<TableHead className="p-2 w-[55px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pkgLoading ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
) : filteredPkgUnits.length === 0 ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs"> </TableCell></TableRow>
) : filteredPkgUnits.map((p) => (
<TableRow
key={p.id}
className={cn("cursor-pointer text-xs", selectedPkg?.id === p.id && "bg-primary/5")}
onClick={() => selectPkg(p)}
>
<TableCell className="p-2 font-medium truncate max-w-[100px]">{p.pkg_code}</TableCell>
<TableCell className="p-2 truncate max-w-[120px]">{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>
<TableCell className="p-2 text-center">
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(p.status))}>{STATUS_LABEL[p.status] || p.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 상세 */}
<ResizablePanel defaultSize={55} minSize={30}>
{!selectedPkg ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Package className="h-12 w-12 opacity-20 mb-2" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex h-full flex-col">
{/* 요약 헤더 */}
<div className="flex items-center justify-between border-b bg-blue-50 dark:bg-blue-950/20 px-4 py-3">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-blue-600" />
<div>
<div className="font-bold text-sm">{selectedPkg.pkg_name}</div>
<div className="text-[11px] text-muted-foreground">{selectedPkg.pkg_code} · {PKG_TYPE_LABEL[selectedPkg.pkg_type] || selectedPkg.pkg_type} · {fmtSize(selectedPkg.width_mm, selectedPkg.length_mm, selectedPkg.height_mm)}mm</div>
</div>
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("edit")}>
<Edit2 className="mr-1 h-3 w-3" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeletePkg(selectedPkg)}>
<Trash2 className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 매칭 품목 */}
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> ({pkgItems.length})</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openItemMatchModal}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-auto">
{pkgItemsLoading ? (
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : pkgItems.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs"> </div>
) : (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[50px]"></TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
<TableHead className="p-2 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{pkgItems.map((item) => (
<TableRow key={item.id} className="text-xs">
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
<X className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
) : (
/* 적재함 관리 탭 */
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2.5">
<span className="text-sm font-semibold"> <span className="text-muted-foreground font-normal">({filteredLoadingUnits.length})</span></span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("create")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="text-[11px] bg-muted/50">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[80px]"></TableHead>
<TableHead className="p-2 w-[90px]">(mm)</TableHead>
<TableHead className="p-2 w-[70px] text-right"></TableHead>
<TableHead className="p-2 w-[55px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingLoading ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
) : filteredLoadingUnits.length === 0 ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs"> </TableCell></TableRow>
) : filteredLoadingUnits.map((l) => (
<TableRow
key={l.id}
className={cn("cursor-pointer text-xs", selectedLoading?.id === l.id && "bg-primary/5")}
onClick={() => selectLoading(l)}
>
<TableCell className="p-2 font-medium truncate max-w-[100px]">{l.loading_code}</TableCell>
<TableCell className="p-2 truncate max-w-[120px]">{l.loading_name}</TableCell>
<TableCell className="p-2">{LOADING_TYPE_LABEL[l.loading_type] || l.loading_type || "-"}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(l.status))}>{STATUS_LABEL[l.status] || l.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={55} minSize={30}>
{!selectedLoading ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Box className="h-12 w-12 opacity-20 mb-2" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b bg-green-50 dark:bg-green-950/20 px-4 py-3">
<div className="flex items-center gap-3">
<Box className="h-5 w-5 text-green-600" />
<div>
<div className="font-bold text-sm">{selectedLoading.loading_name}</div>
<div className="text-[11px] text-muted-foreground">{selectedLoading.loading_code} · {LOADING_TYPE_LABEL[selectedLoading.loading_type] || selectedLoading.loading_type} · {fmtSize(selectedLoading.width_mm, selectedLoading.length_mm, selectedLoading.height_mm)}mm</div>
</div>
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("edit")}><Edit2 className="mr-1 h-3 w-3" /> </Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeleteLoading(selectedLoading)}><Trash2 className="mr-1 h-3 w-3" /> </Button>
</div>
</div>
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> ({loadingPkgs.length})</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openPkgMatchModal}><Plus className="mr-1 h-3 w-3" /> </Button>
</div>
<div className="flex-1 overflow-auto">
{loadingPkgsLoading ? (
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : loadingPkgs.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs"> </div>
) : (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{loadingPkgs.map((lp) => (
<TableRow key={lp.id} className="text-xs">
<TableCell className="p-2 font-medium">{lp.pkg_code}</TableCell>
<TableCell className="p-2">{lp.pkg_name || "-"}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[lp.pkg_type || ""] || lp.pkg_type || "-"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(lp.max_load_qty).toLocaleString()}</TableCell>
<TableCell className="p-2">{lp.load_method || "-"}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeleteLoadPkg(lp)}><X className="h-3 w-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
{/* 포장재 등록/수정 모달 */}
<FullscreenDialog open={pkgModalOpen} onOpenChange={setPkgModalOpen}
title={pkgModalMode === "create" ? "포장재 등록" : "포장재 수정"}
description="품목정보에서 포장재를 선택하면 코드와 이름이 자동 연동됩니다."
footer={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setPkgModalOpen(false)}></Button>
<Button onClick={savePkgUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} </Button>
</div>
}
>
<div className="space-y-4 p-6">
{/* 품목정보 연결 */}
{pkgModalMode === "create" && (
<div className="rounded-lg border bg-blue-50 dark:bg-blue-950/20 p-4">
<Label className="text-xs font-semibold mb-2 block"> (구분: 포장재)</Label>
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
{pkgForm.pkg_code
? `${pkgForm.pkg_name} (${pkgForm.pkg_code})`
: "품목정보에서 포장재를 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = pkgItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{pkgItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs"></Label><Input value={pkgForm.pkg_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div><Label className="text-xs"></Label><Input value={pkgForm.pkg_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div>
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select value={pkgForm.pkg_type || ""} onValueChange={(v) => setPkgForm((p) => ({ ...p, pkg_type: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>{Object.entries(PKG_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={pkgForm.status || "ACTIVE"} onValueChange={(v) => setPkgForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold"></Label>
<div className="grid grid-cols-3 gap-3 mt-2">
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.width_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.length_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.height_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={pkgForm.self_weight_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={pkgForm.max_load_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(L)</Label><Input type="number" value={pkgForm.volume_l || ""} onChange={(e) => setPkgForm((p) => ({ ...p, volume_l: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
</div>
</div>
<div><Label className="text-xs"></Label><Input value={pkgForm.remarks || ""} onChange={(e) => setPkgForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
</div>
</FullscreenDialog>
{/* 적재함 등록/수정 모달 */}
<FullscreenDialog open={loadModalOpen} onOpenChange={setLoadModalOpen}
title={loadModalMode === "create" ? "적재함 등록" : "적재함 수정"}
description="품목정보에서 적재함을 선택하면 코드와 이름이 자동 연동됩니다."
footer={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setLoadModalOpen(false)}></Button>
<Button onClick={saveLoadingUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} </Button>
</div>
}
>
<div className="space-y-4 p-6">
{loadModalMode === "create" && (
<div className="rounded-lg border bg-green-50 dark:bg-green-950/20 p-4">
<Label className="text-xs font-semibold mb-2 block"> (구분: 적재함)</Label>
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
{loadForm.loading_code
? `${loadForm.loading_name} (${loadForm.loading_code})`
: "품목정보에서 적재함을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = loadItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{loadItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs"></Label><Input value={loadForm.loading_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div><Label className="text-xs"></Label><Input value={loadForm.loading_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div>
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select value={loadForm.loading_type || ""} onValueChange={(v) => setLoadForm((p) => ({ ...p, loading_type: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>{Object.entries(LOADING_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={loadForm.status || "ACTIVE"} onValueChange={(v) => setLoadForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold"></Label>
<div className="grid grid-cols-3 gap-3 mt-2">
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.width_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.length_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.height_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={loadForm.self_weight_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={loadForm.max_load_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]"></Label><Input type="number" value={loadForm.max_stack || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_stack: e.target.value }))} className="h-8 text-xs" /></div>
</div>
</div>
<div><Label className="text-xs"></Label><Input value={loadForm.remarks || ""} onChange={(e) => setLoadForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
</div>
</FullscreenDialog>
{/* 품목 추가 모달 (포장재 매칭) */}
<Dialog open={itemMatchModalOpen} onOpenChange={setItemMatchModalOpen}>
<DialogContent className="max-w-[900px]">
<DialogHeader>
<DialogTitle> {selectedPkg?.pkg_name}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<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>
<TableRow className="text-[11px]">
<TableHead className="p-2 w-[30px]" />
<TableHead className="p-2 w-[130px]"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[100px]"></TableHead>
<TableHead className="p-2 w-[80px]"></TableHead>
<TableHead className="p-2 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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.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>
<TableCell className="p-2 font-medium truncate max-w-[130px]">{item.item_number}</TableCell>
<TableCell className="p-2 truncate max-w-[200px]">{item.item_name}</TableCell>
<TableCell className="p-2 truncate">{item.spec || "-"}</TableCell>
<TableCell className="p-2 truncate">{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-end gap-4">
<div className="flex-1">
<Label className="text-xs"> </Label>
<Input value={itemMatchSelected ? `${itemMatchSelected.item_name} (${itemMatchSelected.item_number})` : ""} readOnly className="h-9 bg-muted text-xs" />
</div>
<div className="w-[120px]">
<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>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setItemMatchModalOpen(false)}></Button>
<Button type="button" data-action-type="custom" onClick={saveItemMatch} disabled={saving || !itemMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 포장단위 추가 모달 (적재함 구성) */}
<Dialog open={pkgMatchModalOpen} onOpenChange={setPkgMatchModalOpen}>
<DialogContent className="max-w-[800px]">
<DialogHeader>
<DialogTitle> {selectedLoading?.loading_name}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<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 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>
{(() => {
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 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>
<Input value={pkgMatchMethod} onChange={(e) => setPkgMatchMethod(e.target.value)} placeholder="수직/수평/혼합" className="h-9 text-xs" />
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPkgMatchModalOpen(false)}></Button>
<Button type="button" data-action-type="custom" onClick={savePkgMatch} disabled={saving || !pkgMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{ConfirmDialogComponent}
</div>
);
}

View File

@ -18,14 +18,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
@ -693,14 +686,51 @@ export default function ReceivingPage() {
</div>
{/* 입고 등록 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex h-[90vh] max-w-[95vw] flex-col p-0 sm:max-w-[1600px]">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle className="text-lg"> </DialogTitle>
<DialogDescription className="text-xs">
, .
</DialogDescription>
</DialogHeader>
<FullscreenDialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
title="입고 등록"
description="입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가하세요."
defaultMaxWidth="sm:max-w-[1600px]"
defaultWidth="w-[95vw]"
className="h-[90vh] p-0"
footer={
<div className="flex w-full items-center justify-between px-6 py-3">
<div className="text-muted-foreground text-xs">
{selectedItems.length > 0 ? (
<>
{totalSummary.count} | :{" "}
{totalSummary.qty.toLocaleString()} | :{" "}
{totalSummary.amount.toLocaleString()}
</>
) : (
"품목을 추가해주세요"
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-9 text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={saving || selectedItems.length === 0}
className="h-9 text-sm"
>
{saving ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Save className="mr-1 h-4 w-4" />
)}
</Button>
</div>
</div>
}
>
{/* 입고유형 선택 */}
<div className="flex items-center gap-4 border-b px-6 py-3">
@ -974,43 +1004,7 @@ export default function ReceivingPage() {
</ResizablePanelGroup>
</div>
{/* 푸터 */}
<DialogFooter className="flex items-center justify-between border-t px-6 py-3">
<div className="text-muted-foreground text-xs">
{selectedItems.length > 0 ? (
<>
{totalSummary.count} | :{" "}
{totalSummary.qty.toLocaleString()} | :{" "}
{totalSummary.amount.toLocaleString()}
</>
) : (
"품목을 추가해주세요"
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-9 text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={saving || selectedItems.length === 0}
className="h-9 text-sm"
>
{saving ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Save className="mr-1 h-4 w-4" />
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</FullscreenDialog>
</div>
);
}

View File

@ -0,0 +1,474 @@
"use client";
/**
*
*
* 좌측: 부서 (dept_info)
* 우측: 선택한 (user_info)
*
* 모달: 부서 (dept_info), (user_info)
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} 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,
Building2, Users,
} 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 { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
const DEPT_TABLE = "dept_info";
const USER_TABLE = "user_info";
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
{ key: "dept_name", label: "부서명", minWidth: "min-w-[150px]" },
{ key: "parent_dept_code", label: "상위부서", width: "w-[100px]" },
{ key: "status", label: "상태", width: "w-[70px]" },
];
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "sabun", label: "사번", width: "w-[80px]" },
{ key: "user_name", label: "이름", width: "w-[90px]" },
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
{ key: "position_name", label: "직급", width: "w-[80px]" },
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]" },
{ key: "email", label: "이메일", minWidth: "min-w-[150px]" },
];
export default function DepartmentPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 부서
const [depts, setDepts] = useState<any[]>([]);
const [deptLoading, setDeptLoading] = useState(false);
const [deptCount, setDeptCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
// 우측: 사원
const [members, setMembers] = useState<any[]>([]);
const [memberLoading, setMemberLoading] = useState(false);
// 부서 모달
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [deptEditMode, setDeptEditMode] = useState(false);
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 사원 모달
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 fetchDepts = useCallback(async () => {
setDeptLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
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) {
console.error("부서 조회 실패:", err);
toast.error("부서 목록을 불러오는데 실패했습니다.");
} finally {
setDeptLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchDepts(); }, [fetchDepts]);
// 선택된 부서
const selectedDept = depts.find((d) => d.id === selectedDeptId);
const selectedDeptCode = selectedDept?.dept_code || null;
// 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서)
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({});
setDeptEditMode(false);
setDeptModalOpen(true);
};
const openDeptEdit = () => {
if (!selectedDept) return;
setDeptForm({ ...selectedDept });
setDeptEditMode(true);
setDeptModalOpen(true);
};
const handleDeptSave = async () => {
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
setSaving(true);
try {
if (deptEditMode && deptForm.dept_code) {
await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, {
originalData: { dept_code: deptForm.dept_code },
updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null },
});
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, {
dept_code: deptForm.dept_code || "",
dept_name: deptForm.dept_name,
parent_dept_code: deptForm.parent_dept_code || null,
});
toast.success("등록되었습니다.");
}
setDeptModalOpen(false);
fetchDepts();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 부서 삭제
const handleDeptDelete = async () => {
if (!selectedDeptCode) return;
const ok = await confirm("부서를 삭제하시겠습니까?", {
description: "해당 부서에 소속된 사원 정보는 유지됩니다.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, {
data: [{ dept_code: selectedDeptCode }],
});
toast.success("삭제되었습니다.");
setSelectedDeptId(null);
fetchDepts();
} catch { toast.error("삭제에 실패했습니다."); }
};
// 사원 추가
const openUserModal = (editData?: any) => {
if (editData) {
setUserEditMode(true);
setUserForm({ ...editData, user_password: "" });
} else {
setUserEditMode(false);
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
}
setFormErrors({});
setUserModalOpen(true);
};
const handleUserFormChange = (field: string, value: string) => {
const formatted = formatField(field, value);
setUserForm((prev) => ({ ...prev, [field]: formatted }));
const error = validateField(field, formatted);
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
};
const handleUserSave = async () => {
if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; }
if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; }
const errors = validateForm(userForm, ["cell_phone", "email"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
setSaving(true);
try {
// 비밀번호 미입력 시 기본값 (신규만)
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);
fetchMembers();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (depts.length === 0) return;
const data = depts.map((d) => ({
부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status,
}));
await exportToExcel(data, "부서관리.xlsx", "부서");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={DEPT_TABLE}
filterId="department"
onFilterChange={setSearchFilters}
dataCount={deptCount}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 부서 */}
<ResizablePanel defaultSize={40} minSize={25}>
<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">
<Building2 className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{deptCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={openDeptRegister}><Plus className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={handleDeptDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
</div>
</div>
<DataGrid
gridId="dept-left"
columns={LEFT_COLUMNS}
data={depts}
loading={deptLoading}
selectedId={selectedDeptId}
onSelect={(id) => {
setSelectedDeptId((prev) => (prev === id ? null : id));
}}
onRowDoubleClick={() => openDeptEdit()}
emptyMessage="등록된 부서가 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 사원 */}
<ResizablePanel defaultSize={60} minSize={30}>
<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" />
{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" onClick={() => openUserModal()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
<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>
</div>
{/* 부서 등록/수정 모달 */}
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
<DialogDescription>{deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={deptForm.dept_code || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_code: e.target.value }))}
placeholder="부서코드" className="h-9" disabled={deptEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={deptForm.dept_name || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
placeholder="부서명" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={deptForm.parent_dept_code || ""} onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="상위부서 선택 (선택사항)" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeptModalOpen(false)}></Button>
<Button onClick={handleDeptSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 사원 추가 모달 */}
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<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" disabled={userEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={userForm.user_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
placeholder="이름" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
placeholder="사번" className="h-9" />
</div>
<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={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
placeholder="직급" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
<SelectContent>
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={userForm.regdate || ""} onChange={(v) => setUserForm((p) => ({ ...p, regdate: v }))} placeholder="입사일" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={userForm.end_date || ""} onChange={(v) => setUserForm((p) => ({ ...p, end_date: v }))} placeholder="퇴사일" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserModalOpen(false)}></Button>
<Button onClick={handleUserSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DEPT_TABLE}
userId={user?.userId}
onSuccess={() => fetchDepts()}
/>
{ConfirmDialogComponent}
</div>
);
}

View File

@ -0,0 +1,517 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Plus, Trash2, RotateCcw, Save, Search, Loader2, FileSpreadsheet, Download,
Package, Pencil, Copy,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
// 테이블 컬럼 정의
const TABLE_COLUMNS = [
{ key: "item_number", label: "품목코드", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
{ key: "division", label: "관리품목", width: "w-[100px]" },
{ key: "type", label: "품목구분", width: "w-[100px]" },
{ key: "size", label: "규격", width: "w-[100px]" },
{ key: "unit", label: "단위", width: "w-[80px]" },
{ key: "material", label: "재질", width: "w-[100px]" },
{ key: "status", label: "상태", width: "w-[80px]" },
{ key: "selling_price", label: "판매가격", width: "w-[100px]" },
{ key: "standard_price", label: "기준단가", width: "w-[100px]" },
{ key: "weight", label: "중량", width: "w-[80px]" },
{ key: "inventory_unit", label: "재고단위", width: "w-[80px]" },
{ key: "user_type01", label: "대분류", width: "w-[100px]" },
{ key: "user_type02", label: "중분류", width: "w-[100px]" },
];
// 등록 모달 필드 정의
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "unit", label: "단위", type: "category" },
{ key: "material", label: "재질", type: "category" },
{ key: "status", label: "상태", type: "category" },
{ key: "weight", label: "중량", type: "text" },
{ key: "volum", label: "부피", type: "text" },
{ key: "specific_gravity", label: "비중", type: "text" },
{ key: "inventory_unit", label: "재고단위", type: "category" },
{ key: "selling_price", label: "판매가격", type: "text" },
{ key: "standard_price", label: "기준단가", type: "text" },
{ key: "currency_code", label: "통화", type: "category" },
{ key: "user_type01", label: "대분류", type: "category" },
{ key: "user_type02", label: "중분류", type: "category" },
{ key: "meno", label: "메모", type: "textarea" },
];
const TABLE_NAME = "item_info";
export default function ItemInfoPage() {
const { user } = useAuth();
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
const [searchDivision, setSearchDivision] = useState("all");
const [searchType, setSearchType] = useState("all");
const [searchStatus, setSearchStatus] = useState("all");
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<Record<string, any>>({});
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 카테고리 옵션 (API에서 로드)
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 카테고리 컬럼 목록
const CATEGORY_COLUMNS = ["division", "type", "unit", "material", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
// 카테고리 옵션 로드 (table_name + column_name 기반)
useEffect(() => {
const loadCategories = async () => {
try {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
await Promise.all(
CATEGORY_COLUMNS.map(async (colName) => {
try {
const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[colName] = flatten(res.data.data);
}
} catch { /* skip */ }
})
);
setCategoryOptions(optMap);
} catch (err) {
console.error("카테고리 로드 실패:", err);
}
};
loadCategories();
}, []);
// 데이터 조회
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const filters: any[] = [];
if (searchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
}
if (searchDivision !== "all") {
filters.push({ columnName: "division", operator: "equals", value: searchDivision });
}
if (searchType !== "all") {
filters.push({ columnName: "type", operator: "equals", value: searchType });
}
if (searchStatus !== "all") {
filters.push({ columnName: "status", operator: "equals", value: searchStatus });
}
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// 카테고리 코드→라벨 변환
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATEGORY_COLUMNS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setTotalCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchKeyword, searchDivision, searchType, searchStatus, categoryOptions]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
// 카테고리 코드 → 라벨 변환
const getCategoryLabel = (columnName: string, code: string) => {
if (!code) return "";
const opts = categoryOptions[columnName];
if (!opts) return code;
const found = opts.find((o) => o.code === code);
return found?.label || code;
};
// 등록 모달 열기
const openRegisterModal = () => {
setFormData({});
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
};
// 수정 모달 열기
const openEditModal = (item: any) => {
setFormData({ ...item });
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
};
// 복사 모달 열기
const openCopyModal = (item: any) => {
const { id, item_number, created_date, updated_date, writer, ...rest } = item;
setFormData(rest);
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
};
// 저장
const handleSave = async () => {
if (!formData.item_name) {
toast.error("품명은 필수 입력입니다.");
return;
}
setSaving(true);
try {
if (isEditMode && editId) {
// 수정
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
updatedData: updateFields,
});
toast.success("수정되었습니다.");
} else {
// 등록
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, insertFields);
toast.success("등록되었습니다.");
}
setIsModalOpen(false);
fetchItems();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 삭제
const handleDelete = async () => {
if (!selectedId) {
toast.error("삭제할 품목을 선택해주세요.");
return;
}
if (!confirm("선택한 품목을 삭제하시겠습니까?")) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: [{ id: selectedId }],
});
toast.success("삭제되었습니다.");
setSelectedId(null);
fetchItems();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) {
toast.error("다운로드할 데이터가 없습니다.");
return;
}
const exportData = items.map((item) => {
const row: Record<string, any> = {};
for (const col of TABLE_COLUMNS) {
row[col.label] = getCategoryLabel(col.key, item[col.key]) || item[col.key] || "";
}
return row;
});
await exportToExcel(exportData, "품목정보.xlsx", "품목정보");
toast.success("엑셀 다운로드 완료");
};
// 검색 초기화
const handleResetSearch = () => {
setSearchKeyword("");
setSearchDivision("all");
setSearchType("all");
setSearchStatus("all");
};
// 카테고리 셀렉트 렌더링
const renderCategorySelect = (field: typeof FORM_FIELDS[0]) => {
const options = categoryOptions[field.key] || [];
return (
<Select
value={formData[field.key] || ""}
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={`${field.label} 선택`} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.code} value={opt.code}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<Card className="shrink-0">
<CardContent className="p-4 flex flex-wrap items-end gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">/</Label>
<Input
placeholder="검색"
className="w-[180px] h-9"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchItems()}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchDivision} onValueChange={setSearchDivision}>
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchType} onValueChange={setSearchType}>
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["type"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["status"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
<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">
<Package className="w-5 h-5" />
<Badge variant="secondary" className="font-normal">{totalCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openCopyModal(item);
}}>
<Copy className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openEditModal(item);
}}>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground gap-2">
<Package className="w-8 h-8 opacity-50" />
<span> </span>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[40px] text-center">No</TableHead>
{TABLE_COLUMNS.map((col) => (
<TableHead key={col.key} className={col.width}>{col.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow
key={item.id}
className={cn("cursor-pointer", selectedId === item.id && "bg-primary/5")}
onClick={() => setSelectedId(item.id)}
onDoubleClick={() => openEditModal(item)}
>
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
{TABLE_COLUMNS.map((col) => (
<TableCell key={col.key} className="text-sm">
{["division", "type", "unit", "material", "status", "inventory_unit", "user_type01", "user_type02", "currency_code"].includes(col.key)
? getCategoryLabel(col.key, item[col.key])
: item[col.key] || ""}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-auto">
<DialogHeader>
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
<DialogDescription>
{isEditMode ? "품목 정보를 수정합니다." : "새로운 품목을 등록합니다."}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
{FORM_FIELDS.map((field) => (
<div key={field.key} className={cn("space-y-1.5", field.type === "textarea" && "col-span-2")}>
<Label className="text-sm">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "category" ? (
renderCategorySelect(field)
) : field.type === "textarea" ? (
<Textarea
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.label}
rows={3}
/>
) : (
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.disabled ? field.placeholder : field.label}
disabled={field.disabled && !isEditMode}
className="h-9"
/>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={TABLE_NAME}
userId={user?.userId}
onSuccess={() => {
fetchItems();
}}
/>
</div>
);
}

View File

@ -0,0 +1,510 @@
"use client";
/**
*
*
* 좌측: 품목 (subcontractor_item_mapping , item_info )
* 우측: 선택한 (subcontractor_item_mapping subcontractor_mng )
*
* ( subcontractor_item_mapping )
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} 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 { 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 { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "subcontractor_item_mapping";
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
// 좌측: 품목 컬럼
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[90px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// 우측: 외주업체 정보 컬럼
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "subcontractor_code", label: "외주업체코드", width: "w-[110px]" },
{ key: "subcontractor_name", label: "외주업체명", minWidth: "min-w-[120px]" },
{ key: "subcontractor_item_code", label: "외주품번", width: "w-[100px]" },
{ key: "subcontractor_item_name", label: "외주품명", width: "w-[100px]" },
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
];
export default function SubcontractorItemPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 품목
const [items, setItems] = useState<any[]>([]);
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 우측: 외주업체
const [subcontractorItems, setSubcontractorItems] = useState<any[]>([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(false);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 외주업체 추가 모달
const [subSelectOpen, setSubSelectOpen] = useState(false);
const [subSearchKeyword, setSubSearchKeyword] = useState("");
const [subSearchResults, setSubSearchResults] = useState<any[]>([]);
const [subSearchLoading, setSubSearchLoading] = useState(false);
const [subCheckedIds, setSubCheckedIds] = useState<Set<string>>(new Set());
// 품목 수정 모달
const [editItemOpen, setEditItemOpen] = useState(false);
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
try {
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
const outsourcingDivisionCode = categoryOptions["division"]?.find(
(o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주")
)?.code;
const fetchItems = useCallback(async () => {
setItemLoading(true);
try {
const filters: any[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
// division = 외주관리 필터 추가
if (outsourcingDivisionCode) {
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setItemCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setItemLoading(false);
}
}, [searchFilters, categoryOptions, outsourcingDivisionCode]);
useEffect(() => { fetchItems(); }, [fetchItems]);
// 선택된 품목
const selectedItem = items.find((i) => i.id === selectedItemId);
// 우측: 외주업체 목록 조회
useEffect(() => {
if (!selectedItem?.item_number) { setSubcontractorItems([]); return; }
const itemKey = selectedItem.item_number;
const fetchSubcontractorItems = async () => {
setSubcontractorLoading(true);
try {
// subcontractor_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
// subcontractor_id → subcontractor_mng 조인 (외주업체명)
const subIds = [...new Set(mappings.map((m: any) => m.subcontractor_id).filter(Boolean))];
let subMap: Record<string, any> = {};
if (subIds.length > 0) {
try {
const subRes = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: subIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "subcontractor_code", operator: "in", value: subIds }] },
autoFilter: true,
});
for (const s of (subRes.data?.data?.data || subRes.data?.data?.rows || [])) {
subMap[s.subcontractor_code] = s;
}
} catch { /* skip */ }
}
setSubcontractorItems(mappings.map((m: any) => ({
...m,
subcontractor_code: m.subcontractor_id,
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
})));
} catch (err) {
console.error("외주업체 조회 실패:", err);
} finally {
setSubcontractorLoading(false);
}
};
fetchSubcontractorItems();
}, [selectedItem?.item_number]);
// 외주업체 검색
const searchSubcontractors = async () => {
setSubSearchLoading(true);
try {
const filters: any[] = [];
if (subSearchKeyword) filters.push({ columnName: "subcontractor_name", operator: "contains", value: subSearchKeyword });
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const all = res.data?.data?.data || res.data?.data?.rows || [];
// 이미 등록된 외주업체 제외
const existing = new Set(subcontractorItems.map((s: any) => s.subcontractor_id || s.subcontractor_code));
setSubSearchResults(all.filter((s: any) => !existing.has(s.subcontractor_code)));
} catch { /* skip */ } finally { setSubSearchLoading(false); }
};
// 외주업체 추가 저장
const addSelectedSubcontractors = async () => {
const selected = subSearchResults.filter((s) => subCheckedIds.has(s.id));
if (selected.length === 0 || !selectedItem) return;
try {
for (const sub of selected) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
subcontractor_id: sub.subcontractor_code,
item_id: selectedItem.item_number,
});
}
toast.success(`${selected.length}개 외주업체가 추가되었습니다.`);
setSubCheckedIds(new Set());
setSubSelectOpen(false);
// 우측 새로고침
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "외주업체 추가에 실패했습니다.");
}
};
// 품목 수정
const openEditItem = () => {
if (!selectedItem) return;
setEditItemForm({ ...selectedItem });
setEditItemOpen(true);
};
const handleEditSave = async () => {
if (!editItemForm.id) return;
setSaving(true);
try {
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editItemForm.id },
updatedData: {
selling_price: editItemForm.selling_price || null,
standard_price: editItemForm.standard_price || null,
currency_code: editItemForm.currency_code || null,
},
});
toast.success("수정되었습니다.");
setEditItemOpen(false);
fetchItems();
} catch (err: any) {
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) return;
const data = items.map((i) => ({
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
}));
await exportToExcel(data, "외주품목정보.xlsx", "외주품목");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={ITEM_TABLE}
filterId="subcontractor-item"
onFilterChange={setSearchFilters}
dataCount={itemCount}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 외주품목 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<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">
<Package className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{itemCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<DataGrid
gridId="subcontractor-item-left"
columns={LEFT_COLUMNS}
data={items}
loading={itemLoading}
selectedId={selectedItemId}
onSelect={setSelectedItemId}
onRowDoubleClick={() => openEditItem()}
emptyMessage="등록된 외주품목이 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 외주업체 정보 */}
<ResizablePanel defaultSize={45} minSize={25}>
<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" />
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setSubCheckedIds(new Set()); setSubSelectOpen(true); searchSubcontractors(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedItemId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<DataGrid
gridId="subcontractor-item-right"
columns={RIGHT_COLUMNS}
data={subcontractorItems}
loading={subcontractorLoading}
showRowNumber={false}
emptyMessage="등록된 외주업체가 없습니다"
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 품목 수정 모달 */}
<FullscreenDialog
open={editItemOpen}
onOpenChange={setEditItemOpen}
title="외주품목 수정"
description={`${editItemForm.item_number || ""}${editItemForm.item_name || ""}`}
defaultMaxWidth="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setEditItemOpen(false)}></Button>
<Button onClick={handleEditSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="grid grid-cols-2 gap-4 py-4">
{[
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
].map((f) => (
<div key={f.key} className="space-y-1.5">
<Label className="text-sm text-muted-foreground">{f.label}</Label>
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
</div>
))}
<div className="col-span-2 border-t my-2" />
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
placeholder="판매가격" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
placeholder="기준단가" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</FullscreenDialog>
{/* 외주업체 추가 모달 */}
<Dialog open={subSelectOpen} onOpenChange={setSubSelectOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="외주업체명 검색" value={subSearchKeyword}
onChange={(e) => setSubSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchSubcontractors()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchSubcontractors} disabled={subSearchLoading} className="h-9">
{subSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={subSearchResults.length > 0 && subCheckedIds.size === subSearchResults.length}
onChange={(e) => {
if (e.target.checked) setSubCheckedIds(new Set(subSearchResults.map((s) => s.id)));
else setSubCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : subSearchResults.map((s) => (
<TableRow key={s.id} className={cn("cursor-pointer", subCheckedIds.has(s.id) && "bg-primary/5")}
onClick={() => setSubCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(s.id)) next.delete(s.id); else next.add(s.id);
return next;
})}>
<TableCell className="text-center"><input type="checkbox" checked={subCheckedIds.has(s.id)} readOnly /></TableCell>
<TableCell className="text-xs">{s.subcontractor_code}</TableCell>
<TableCell className="text-sm">{s.subcontractor_name}</TableCell>
<TableCell className="text-xs">{s.division}</TableCell>
<TableCell className="text-xs">{s.contact_person}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{subCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setSubSelectOpen(false)}></Button>
<Button onClick={addSelectedSubcontractors} disabled={subCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {subCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={ITEM_TABLE}
userId={user?.userId}
onSuccess={() => fetchItems()}
/>
{ConfirmDialogComponent}
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -733,15 +733,17 @@ export default function WorkInstructionPage() {
<div className="max-h-[280px] overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow><TableHead className="w-[60px]"></TableHead><TableHead className="w-[120px]"></TableHead><TableHead className="w-[100px] text-right"></TableHead><TableHead></TableHead><TableHead className="w-[60px]" /></TableRow>
<TableRow><TableHead className="w-[60px]"></TableHead><TableHead className="w-[120px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[100px] text-right"></TableHead><TableHead></TableHead><TableHead className="w-[60px]" /></TableRow>
</TableHeader>
<TableBody>
{editItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm"> </TableCell></TableRow>
<TableRow><TableCell colSpan={7} className="text-center py-8 text-muted-foreground text-sm"> </TableCell></TableRow>
) : editItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
<TableCell className="text-xs max-w-[180px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-xs max-w-[100px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>

View File

@ -62,9 +62,11 @@ import {
Check,
ChevronsUpDown,
Loader2,
FileSpreadsheet,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
// --- Types ---
type ClaimType = "불량" | "교환" | "반품" | "배송지연" | "기타";
@ -94,57 +96,7 @@ interface SalesOrderOption {
status: string;
}
// --- Sample Data ---
const initialData: Claim[] = [
{
claimNo: "CLM-2025-004",
claimDate: "2025-11-09",
claimType: "불량",
claimStatus: "접수",
customerCode: "CUST-0001",
customerName: "주식회사 코아스포트",
managerName: "김철수",
orderNo: "SO-2025-0102",
claimContent: "제품 표면에 스크래치가 발견되었습니다.",
processContent: "",
},
{
claimNo: "CLM-2025-001",
claimDate: "2025-01-05",
claimType: "불량",
claimStatus: "접수",
customerCode: "CUST-0002",
customerName: "(주)현상산업",
managerName: "김철수",
orderNo: "SO-2025-0102",
claimContent: "제품 불량",
processContent: "",
},
{
claimNo: "CLM-2025-002",
claimDate: "2025-01-04",
claimType: "교환",
claimStatus: "처리중",
customerCode: "CUST-0003",
customerName: "대한전섬",
managerName: "이영희",
orderNo: "SO-2025-0095",
claimContent: "규격 불일치",
processContent: "교환 진행 중",
},
{
claimNo: "CLM-2025-003",
claimDate: "2025-01-03",
claimType: "반품",
claimStatus: "완료",
customerCode: "CUST-0004",
customerName: "삼성전자",
managerName: "박민수",
orderNo: "SO-2024-1285",
claimContent: "수량 초과 납품",
processContent: "반품 완료",
},
];
const initialData: Claim[] = [];
const getClaimTypeStyle = (type: ClaimType) => {
switch (type) {
@ -193,6 +145,9 @@ export default function ClaimManagementPage() {
const [searchCustomer, setSearchCustomer] = useState("");
const [searchClaimNo, setSearchClaimNo] = useState("");
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
@ -563,9 +518,14 @@ export default function ClaimManagementPage() {
{filteredData.length}
</Badge>
</div>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
@ -1122,6 +1082,16 @@ export default function ClaimManagementPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName="claim_mng"
onSuccess={() => {
// TODO: 클레임 테이블 API 연동 후 데이터 새로고침
}}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,900 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
// Card, CardContent 제거 — DynamicSearchFilter가 대체
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
const DETAIL_TABLE = "sales_order_detail";
// 천단위 구분자 표시용 (입력 중에는 콤마 포함 표시, 저장 시 숫자만)
const formatNumber = (val: string) => {
const num = val.replace(/[^\d.-]/g, "");
if (!num) return "";
const parts = num.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
};
const parseNumber = (val: string) => val.replace(/,/g, "");
const MASTER_TABLE = "sales_order_mng";
// 메인 목록 테이블 컬럼 (sales_order_detail 기준)
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "order_no", label: "수주번호", width: "w-[120px]" },
{ key: "part_code", label: "품번", width: "w-[120px]", editable: true },
{ key: "part_name", label: "품명", minWidth: "min-w-[150px]", editable: true },
{ key: "spec", label: "규격", width: "w-[120px]", editable: true },
{ key: "unit", label: "단위", width: "w-[70px]", editable: true },
{ key: "qty", label: "수량", width: "w-[90px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
{ key: "ship_qty", label: "출하수량", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "balance_qty", label: "잔량", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "unit_price", label: "단가", width: "w-[100px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
{ key: "amount", label: "금액", width: "w-[110px]", formatNumber: true, align: "right" },
{ key: "due_date", label: "납기일", width: "w-[110px]" },
{ key: "memo", label: "메모", width: "w-[100px]", editable: true },
];
// 조건부 레이어 설정 (input_mode, sell_mode에 따라 표시 필드가 달라짐)
// Zone 10: input_mode → 공급업체우선(CAT_MLZWPH5R_983R) / 품목우선(CAT_MLZWPUQC_PB8Z)
// Zone 17: sell_mode → 해외판매(CAT_MLZWFF2Z_BQCV)
export default function SalesOrderPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
// 검색 필터 (DynamicSearchFilter에서 관리)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
// isModalFullscreen 제거됨 — FullscreenDialog 사용
const [isEditMode, setIsEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
const [detailRows, setDetailRows] = useState<any[]>([]);
// 품목 선택 모달 (리피터에서 품목 추가용)
const [itemSelectOpen, setItemSelectOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
const [itemSearchLoading, setItemSearchLoading] = useState(false);
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 출하계획 모달
const [shippingPlanOpen, setShippingPlanOpen] = useState(false);
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 체크된 행 (다중선택)
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 카테고리 로드
useEffect(() => {
const loadCategories = async () => {
const catColumns = ["sell_mode", "input_mode", "price_mode", "incoterms", "payment_term"];
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// 라벨 치환 + 중복 제거 (같은 label이면 첫 번째만 유지)
const LABEL_REPLACE: Record<string, string> = {
"공급업체 우선": "거래처 우선",
"공급업체우선": "거래처 우선",
};
const dedup = (items: { code: string; label: string }[]) => {
const seen = new Set<string>();
return items
.map((item) => ({ ...item, label: LABEL_REPLACE[item.label] || item.label }))
.filter((item) => {
const key = item.label.replace(/\s/g, "");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
await Promise.all(
catColumns.map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[col] = dedup(flatten(res.data.data));
}
} catch { /* skip */ }
})
);
// 거래처 목록도 로드
try {
const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, {
page: 1, size: 500, autoFilter: true,
});
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
} catch { /* skip */ }
// 사용자 목록 로드 (담당자 선택용)
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager_id"] = users.map((u: any) => ({
code: u.user_id || u.id,
label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`,
}));
} catch { /* skip */ }
// item_info 카테고리도 로드 (unit, material 등 코드→라벨 변환용)
for (const col of ["unit", "material", "division", "type"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[`item_${col}`] = flatten(res.data.data);
}
} catch { /* skip */ }
}
setCategoryOptions(optMap);
};
loadCategories();
}, []);
// 데이터 조회
const fetchOrders = useCallback(async () => {
setLoading(true);
try {
const filters: any[] = searchFilters.map((f) => ({
columnName: f.columnName,
operator: f.operator,
value: f.value,
}));
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "order_no", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// part_code → item_info 조인 (품명/규격이 비어있는 경우 보강)
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
let itemMap: Record<string, any> = {};
if (partCodes.length > 0) {
try {
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: partCodes.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: partCodes }] },
autoFilter: true,
});
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
for (const item of items) {
itemMap[item.item_number] = item;
}
} catch { /* skip */ }
}
// 조인 적용 + 카테고리 코드→라벨 변환
const resolveLabel = (key: string, code: string) => {
if (!code) return "";
const opts = categoryOptions[key];
if (!opts) return code;
return opts.find((o) => o.code === code)?.label || code;
};
const data = rows.map((row: any) => {
const item = itemMap[row.part_code];
const rawUnit = row.unit || item?.unit || "";
return {
...row,
part_name: row.part_name || item?.item_name || "",
spec: row.spec || item?.size || "",
unit: resolveLabel("item_unit", rawUnit) || rawUnit,
};
});
setOrders(data);
setTotalCount(res.data?.data?.total || data.length);
} catch (err) {
console.error("수주 조회 실패:", err);
toast.error("수주 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
const found = categoryOptions[col]?.find((o) => o.code === code);
return found?.label || code;
};
// 등록 모달 열기
// 납품처 목록 (거래처 선택 시 조회)
const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]);
const loadDeliveryOptions = async (customerCode: string) => {
if (!customerCode) { setDeliveryOptions([]); return; }
try {
const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] },
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setDeliveryOptions(rows.map((r: any) => ({
code: r.destination_code || r.id,
label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`,
})));
} catch { setDeliveryOptions([]); }
};
const openRegisterModal = () => {
// 기본값: 각 카테고리의 첫 번째 옵션
const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || "";
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || "";
setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, manager_id: user?.userId || "" });
setDetailRows([]);
setDeliveryOptions([]);
setIsEditMode(false);
setIsModalOpen(true);
};
// 수정 모달 열기 (order_no로 마스터 + 디테일 조회)
const openEditModal = async (orderNo: string) => {
try {
// 마스터 조회
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masterData = (masterRes.data?.data?.data || masterRes.data?.data?.rows || [])[0];
// 디테일 조회
const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
setMasterForm(masterData || {});
setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` })));
setIsEditMode(true);
setIsModalOpen(true);
} catch (err) {
console.error("수주 상세 조회 실패:", err);
toast.error("수주 정보를 불러오는데 실패했습니다.");
}
};
// 삭제 (다중 선택)
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
const ok = await confirm(`${checkedIds.length}건의 수주 데이터를 삭제하시겠습니까?`, {
description: "삭제된 데이터는 복구할 수 없습니다.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
// 선택된 디테일 행 삭제
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: checkedIds.map((id) => ({ id })),
});
// 해당 수주번호의 남은 디테일이 없으면 마스터도 삭제
for (const orderNo of orderNos) {
const remaining = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const rows = remaining.data?.data?.data || remaining.data?.data?.rows || [];
if (rows.length === 0) {
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
});
}
}
}
toast.success("삭제되었습니다.");
setCheckedIds([]);
fetchOrders();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
// 저장 (마스터 + 디테일)
const handleSave = async () => {
if (!masterForm.order_no && !isEditMode) {
toast.error("수주번호는 필수입니다.");
return;
}
if (detailRows.length === 0) {
toast.error("품목을 1개 이상 추가해주세요.");
return;
}
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm;
if (isEditMode && id) {
// 마스터 수정
await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, {
originalData: { id },
updatedData: masterFields,
});
// 기존 디테일 삭제 후 재삽입
const existingDetails = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: masterForm.order_no }] },
autoFilter: true,
});
const existings = existingDetails.data?.data?.data || existingDetails.data?.data?.rows || [];
if (existings.length > 0) {
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: existings.map((d: any) => ({ id: d.id })),
});
}
} else {
// 마스터 등록
await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields);
}
// 디테일 등록
for (const row of detailRows) {
const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
...detailFields,
order_no: masterForm.order_no,
});
}
toast.success(isEditMode ? "수정되었습니다." : "등록되었습니다.");
setIsModalOpen(false);
fetchOrders();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 품목 검색 (리피터에서 추가)
const searchItems = async () => {
setItemSearchLoading(true);
try {
const filters: any[] = [];
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
};
const addSelectedItemsToDetail = async () => {
const selected = itemSearchResults.filter((item) => itemCheckedIds.has(item.id));
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
// 단가방식에 따라 단가 조회
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
const today = new Date().toISOString().split("T")[0];
// 거래처별 단가 조회 (선택된 품목들에 대해)
let customerPriceMap: Record<string, string> = {};
if (isCustomerPrice && partnerId) {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
dataFilter: {
enabled: true,
filters: [
{ columnName: "customer_id", operator: "equals", value: partnerId },
{ columnName: "item_id", operator: "in", value: itemIds },
],
},
autoFilter: true,
});
const mappings = res.data?.data?.data || res.data?.data?.rows || [];
for (const m of mappings) {
// calculated_price 우선, 없으면 current_unit_price
const price = m.calculated_price || m.current_unit_price || "";
if (price) customerPriceMap[m.item_id] = String(price);
}
} catch (err) {
console.error("거래처별 단가 조회 실패:", err);
}
}
const newRows = selected.map((item) => {
const itemCode = item.item_number || item.id;
let unitPrice = "";
if (isStandardPrice) {
// 기준단가: item_info의 standard_price 또는 selling_price
unitPrice = item.standard_price || item.selling_price || "";
} else if (isCustomerPrice && partnerId) {
// 거래처별 단가
unitPrice = customerPriceMap[itemCode] || "";
}
return {
_id: `new_${Date.now()}_${Math.random()}`,
part_code: itemCode,
part_name: item.item_name,
spec: item.size || "",
material: getCategoryLabel("item_material", item.material) || item.material || "",
unit: getCategoryLabel("item_unit", item.unit) || item.unit || "",
qty: "",
unit_price: unitPrice,
amount: "",
due_date: "",
};
});
setDetailRows((prev) => [...prev, ...newRows]);
toast.success(`${selected.length}개 품목이 추가되었습니다.`);
setItemCheckedIds(new Set());
setItemSelectOpen(false);
};
const updateDetailRow = (idx: number, field: string, value: string) => {
setDetailRows((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: value };
// 수량 × 단가 = 금액 자동 계산
if (field === "qty" || field === "unit_price") {
const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0;
const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0;
next[idx].amount = (qty * price).toString();
}
return next;
});
};
const removeDetailRow = (idx: number) => {
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
};
// input_mode 값으로 레이어 판단
// 거래처 우선 (구: 공급업체 우선) - 두 코드 모두 지원
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
const isItemFirst = masterForm.input_mode === "CAT_MLZWPUQC_PB8Z" || masterForm.input_mode === "CAT_MLKG5FZO_HS1B";
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
const handleExcelDownload = async () => {
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
const data = orders.map((o) => {
const row: Record<string, any> = {};
for (const col of GRID_COLUMNS) row[col.label] = o[col.key] || "";
return row;
});
await exportToExcel(data, "수주관리.xlsx", "수주목록");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 필터 (사용자 설정 가능) */}
<DynamicSearchFilter
tableName={DETAIL_TABLE}
filterId="sales-order"
onFilterChange={setSearchFilters}
dataCount={totalCount}
/>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
<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">
<ClipboardList className="w-5 h-5" />
<Badge variant="secondary" className="font-normal">{totalCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={checkedIds.length !== 1} onClick={() => {
const item = orders.find((o) => o.id === checkedIds[0]);
if (item) openEditModal(item.order_no);
}}>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
<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>
</div>
</div>
<DataGrid
gridId="sales-order"
columns={GRID_COLUMNS}
data={orders}
loading={loading}
showCheckbox
showRowNumber={false}
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEditModal(row.order_no)}
tableName={DETAIL_TABLE}
emptyMessage="등록된 수주가 없습니다"
onCellEdit={() => fetchOrders()}
/>
</div>
{/* 수주 등록/수정 모달 */}
<FullscreenDialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={isEditMode ? "수주 수정" : "수주 등록"}
description={isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."}
footer={
<>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="space-y-4 py-2">
{/* 기본 레이어 (항상 표시) */}
<div className="grid grid-cols-4 gap-4">
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={masterForm.order_no || ""} onChange={(e) => setMasterForm((p) => ({ ...p, order_no: e.target.value }))}
placeholder="수주번호" className="h-9" disabled={isEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={masterForm.order_date || ""} onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} placeholder="수주일" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={masterForm.sell_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, sell_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["sell_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["input_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.price_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, price_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["price_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{/* 레이어 2: 거래처 우선 (거래처, 담당자, 납품처, 납품장소) */}
{isSupplierFirst && (
<div className="grid grid-cols-4 gap-4 border-t pt-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.partner_id || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }}>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.manager_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["manager_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{deliveryOptions.length > 0 ? (
<Select value={masterForm.delivery_partner_id || ""} onValueChange={(v) => {
setMasterForm((p) => ({ ...p, delivery_partner_id: v }));
// 선택한 납품처의 주소를 자동 입력
const found = deliveryOptions.find((o) => o.code === v);
if (found) {
const addr = found.label.match(/\((.+)\)$/)?.[1] || "";
if (addr) setMasterForm((p) => ({ ...p, delivery_partner_id: v, delivery_address: addr }));
}
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="납품처 선택" /></SelectTrigger>
<SelectContent>
{deliveryOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input value={masterForm.delivery_partner_id || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))}
placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} />
)}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.delivery_address || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))}
placeholder="납품장소" className="h-9" />
</div>
</div>
)}
{/* 레이어 4: 해외판매 (인코텀즈, 결제조건, 통화, 선적항, 도착항, HS코드) */}
{isOverseas && (
<div className="grid grid-cols-3 gap-4 border-t pt-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.incoterms || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, incoterms: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["incoterms"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.payment_term || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, payment_term: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["payment_term"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.currency || ""} onChange={(e) => setMasterForm((p) => ({ ...p, currency: e.target.value }))}
placeholder="KRW" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.port_of_loading || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_loading: e.target.value }))}
className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.port_of_discharge || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_discharge: e.target.value }))}
className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm">HS Code</Label>
<Input value={masterForm.hs_code || ""} onChange={(e) => setMasterForm((p) => ({ ...p, hs_code: e.target.value }))}
className="h-9" />
</div>
</div>
)}
{/* 리피터 그리드 (품목 목록) — 레이어 2,3 공통 */}
<div className="border rounded-lg">
<div className="flex items-center justify-between p-3 border-b bg-muted/10">
<span className="text-sm font-semibold"> </span>
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="overflow-auto max-h-[300px]">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_code}>{row.part_code}</span></TableCell>
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
<TableCell className="text-xs">{row.spec}</TableCell>
<TableCell className="text-xs">{row.unit}</TableCell>
<TableCell>
<Input value={formatNumber(row.qty || "")} onChange={(e) => updateDetailRow(idx, "qty", parseNumber(e.target.value))}
className="h-8 text-sm text-right" />
</TableCell>
<TableCell>
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
className="h-8 text-sm text-right" />
</TableCell>
<TableCell className="text-sm text-right font-medium">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell>
<FormDatePicker value={row.due_date || ""} onChange={(v) => updateDetailRow(idx, "due_date", v)} placeholder="납기일" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* 메모 */}
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.memo || ""} onChange={(e) => setMasterForm((p) => ({ ...p, memo: e.target.value }))}
placeholder="메모" className="h-9" />
</div>
</div>
{/* 품목 선택 모달 (등록 모달 내부에 중첩) */}
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
<DialogContent className="max-w-3xl max-h-[70vh]" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="품명/품목코드 검색" value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItems()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={itemSearchResults.length > 0 && itemCheckedIds.size === itemSearchResults.length}
onChange={(e) => {
if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id)));
else setItemCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[130px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : itemSearchResults.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", itemCheckedIds.has(item.id) && "bg-primary/5")}
onClick={() => setItemCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
})}>
<TableCell className="text-center">
<input type="checkbox" checked={itemCheckedIds.has(item.id)} readOnly />
</TableCell>
<TableCell className="text-xs max-w-[130px]"><span className="block truncate" title={item.item_number}>{item.item_number}</span></TableCell>
<TableCell className="text-sm max-w-[150px]"><span className="block truncate" title={item.item_name}>{item.item_name}</span></TableCell>
<TableCell className="text-xs">{item.size}</TableCell>
<TableCell className="text-xs">{item.material}</TableCell>
<TableCell className="text-xs">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{itemCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(false); }}></Button>
<Button onClick={addSelectedItemsToDetail} disabled={itemCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {itemCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</FullscreenDialog>
{/* 출하계획 동시 등록 모달 */}
<ShippingPlanBatchModal
open={shippingPlanOpen}
onOpenChange={setShippingPlanOpen}
selectedDetailIds={checkedIds}
onSuccess={fetchOrders}
/>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DETAIL_TABLE}
userId={user?.userId}
onSuccess={() => fetchOrders()}
/>
{/* 공통 확인 다이얼로그 */}
{ConfirmDialogComponent}
</div>
);
}

View File

@ -0,0 +1,889 @@
"use client";
/**
*
*
* 좌측: 판매품목 (item_info, )
* 우측: 선택한 (customer_item_mapping customer_mng )
*
* ( customer_item_mapping )
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} 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 { 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 { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "customer_item_mapping";
const CUSTOMER_TABLE = "customer_mng";
// 좌측: 판매품목 컬럼
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[90px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// 우측: 거래처 정보 컬럼
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "customer_code", label: "거래처코드", width: "w-[110px]" },
{ key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" },
{ key: "customer_item_code", label: "거래처품번", width: "w-[100px]" },
{ key: "customer_item_name", label: "거래처품명", width: "w-[100px]" },
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
];
export default function SalesItemPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 품목
const [items, setItems] = useState<any[]>([]);
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 우측: 거래처
const [customerItems, setCustomerItems] = useState<any[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 거래처 추가 모달
const [custSelectOpen, setCustSelectOpen] = useState(false);
const [custSearchKeyword, setCustSearchKeyword] = useState("");
const [custSearchResults, setCustSearchResults] = useState<any[]>([]);
const [custSearchLoading, setCustSearchLoading] = useState(false);
const [custCheckedIds, setCustCheckedIds] = useState<Set<string>>(new Set());
// 품목 수정 모달
const [editItemOpen, setEditItemOpen] = useState(false);
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 거래처 상세 입력 모달 (거래처 품번/품명 + 단가)
const [custDetailOpen, setCustDetailOpen] = useState(false);
const [selectedCustsForDetail, setSelectedCustsForDetail] = useState<any[]>([]);
const [custMappings, setCustMappings] = useState<Record<string, Array<{ _id: string; customer_item_code: string; customer_item_name: string }>>>({});
const [custPrices, setCustPrices] = useState<Record<string, Array<{
_id: string; start_date: string; end_date: string; currency_code: string;
base_price_type: string; base_price: string; discount_type: string;
discount_value: string; rounding_type: string; rounding_unit_value: string;
calculated_price: string;
}>>>({});
const [priceCategoryOptions, setPriceCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [editCustData, setEditCustData] = useState<any>(null);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
try {
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
// 단가 카테고리
const priceOpts: Record<string, { code: string; label: string }[]> = {};
for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) {
try {
const res = await apiClient.get(`/table-categories/customer_item_prices/${col}/values`);
if (res.data?.success) priceOpts[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setPriceCategoryOptions(priceOpts);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 좌측: 품목 조회
const fetchItems = useCallback(async () => {
setItemLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setItemCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setItemLoading(false);
}
}, [searchFilters, categoryOptions]);
useEffect(() => { fetchItems(); }, [fetchItems]);
// 선택된 품목
const selectedItem = items.find((i) => i.id === selectedItemId);
// 우측: 거래처 목록 조회
useEffect(() => {
if (!selectedItem?.item_number) { setCustomerItems([]); return; }
const itemKey = selectedItem.item_number;
const fetchCustomerItems = async () => {
setCustomerLoading(true);
try {
// customer_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
// customer_id → customer_mng 조인 (거래처명)
const custIds = [...new Set(mappings.map((m: any) => m.customer_id).filter(Boolean))];
let custMap: Record<string, any> = {};
if (custIds.length > 0) {
try {
const custRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
page: 1, size: custIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "in", value: custIds }] },
autoFilter: true,
});
for (const c of (custRes.data?.data?.data || custRes.data?.data?.rows || [])) {
custMap[c.customer_code] = c;
}
} catch { /* skip */ }
}
setCustomerItems(mappings.map((m: any) => ({
...m,
customer_code: m.customer_id,
customer_name: custMap[m.customer_id]?.customer_name || "",
})));
} catch (err) {
console.error("거래처 조회 실패:", err);
} finally {
setCustomerLoading(false);
}
};
fetchCustomerItems();
}, [selectedItem?.item_number]);
// 거래처 검색
const searchCustomers = async () => {
setCustSearchLoading(true);
try {
const filters: any[] = [];
if (custSearchKeyword) filters.push({ columnName: "customer_name", operator: "contains", value: custSearchKeyword });
const res = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const all = res.data?.data?.data || res.data?.data?.rows || [];
// 이미 등록된 거래처 제외
const existing = new Set(customerItems.map((c: any) => c.customer_id || c.customer_code));
setCustSearchResults(all.filter((c: any) => !existing.has(c.customer_code)));
} catch { /* skip */ } finally { setCustSearchLoading(false); }
};
// 거래처 선택 → 상세 모달로 이동
const goToCustDetail = () => {
const selected = custSearchResults.filter((c) => custCheckedIds.has(c.id));
if (selected.length === 0) { toast.error("거래처를 선택해주세요."); return; }
setSelectedCustsForDetail(selected);
const mappings: typeof custMappings = {};
const prices: typeof custPrices = {};
for (const cust of selected) {
const key = cust.customer_code || cust.id;
mappings[key] = [];
prices[key] = [{
_id: `p_${Date.now()}_${Math.random()}`,
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
calculated_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
}];
}
setCustMappings(mappings);
setCustPrices(prices);
setCustSelectOpen(false);
setCustDetailOpen(true);
};
const addMappingRow = (custKey: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: [...(prev[custKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, customer_item_code: "", customer_item_name: "" }],
}));
};
const removeMappingRow = (custKey: string, rowId: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
}));
};
const updateMappingRow = (custKey: string, rowId: string, field: string, value: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r),
}));
};
const addPriceRow = (custKey: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: [...(prev[custKey] || []), {
_id: `p_${Date.now()}_${Math.random()}`,
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "",
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
calculated_price: "",
}],
}));
};
const removePriceRow = (custKey: string, rowId: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
}));
};
const updatePriceRow = (custKey: string, rowId: string, field: string, value: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).map((r) => {
if (r._id !== rowId) return r;
const updated = { ...r, [field]: value };
if (["base_price", "discount_type", "discount_value"].includes(field)) {
const bp = Number(updated.base_price) || 0;
const dv = Number(updated.discount_value) || 0;
const dt = updated.discount_type;
let calc = bp;
if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100);
else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv;
updated.calculated_price = String(Math.round(calc));
}
return updated;
}),
}));
};
const openEditCust = async (row: any) => {
const custKey = row.customer_code || row.customer_id;
// customer_mng에서 거래처 정보 조회
let custInfo: any = { customer_code: custKey, customer_name: row.customer_name || "" };
try {
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: custKey }] },
autoFilter: true,
});
const found = (res.data?.data?.data || res.data?.data?.rows || [])[0];
if (found) custInfo = found;
} catch { /* skip */ }
const mappingRows = [{
_id: `m_existing_${row.id}`,
customer_item_code: row.customer_item_code || "",
customer_item_name: row.customer_item_name || "",
}].filter((m) => m.customer_item_code || m.customer_item_name);
const priceRows = [{
_id: `p_existing_${row.id}`,
start_date: row.start_date || "",
end_date: row.end_date || "",
currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI",
base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW",
base_price: row.base_price ? String(row.base_price) : "",
discount_type: row.discount_type || "",
discount_value: row.discount_value ? String(row.discount_value) : "",
rounding_type: row.rounding_type || "",
rounding_unit_value: row.rounding_unit_value || "",
calculated_price: row.calculated_price ? String(row.calculated_price) : "",
}].filter((p) => p.base_price || p.start_date);
if (priceRows.length === 0) {
priceRows.push({
_id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "",
rounding_type: "", rounding_unit_value: "", calculated_price: "",
});
}
setSelectedCustsForDetail([custInfo]);
setCustMappings({ [custKey]: mappingRows });
setCustPrices({ [custKey]: priceRows });
setEditCustData(row);
setCustDetailOpen(true);
};
const handleCustDetailSave = async () => {
if (!selectedItem) return;
const isEditingExisting = !!editCustData;
setSaving(true);
try {
for (const cust of selectedCustsForDetail) {
const custKey = cust.customer_code || cust.id;
const mappingRows = custMappings[custKey] || [];
if (isEditingExisting && editCustData?.id) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: editCustData.id },
updatedData: {
customer_item_code: mappingRows[0]?.customer_item_code || "",
customer_item_name: mappingRows[0]?.customer_item_name || "",
},
});
// 기존 prices 삭제 후 재등록
try {
const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "mapping_id", operator: "equals", value: editCustData.id },
]}, autoFilter: true,
});
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
data: existing.map((p: any) => ({ id: p.id })),
});
}
} catch { /* skip */ }
const priceRows = (custPrices[custKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
mapping_id: editCustData.id,
customer_id: custKey,
item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
} else {
// 신규 등록
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: mappingRows[0]?.customer_item_code || "",
customer_item_name: mappingRows[0]?.customer_item_name || "",
});
const mappingId = mappingRes.data?.data?.id || null;
for (let mi = 1; mi < mappingRows.length; mi++) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: mappingRows[mi].customer_item_name || "",
});
}
const priceRows = (custPrices[custKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
mapping_id: mappingId || "", customer_id: custKey, item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
}
}
toast.success(isEditingExisting ? "수정되었습니다." : `${selectedCustsForDetail.length}개 거래처가 추가되었습니다.`);
setCustDetailOpen(false);
setEditCustData(null);
setCustCheckedIds(new Set());
// 우측 새로고침
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 품목 수정
const openEditItem = () => {
if (!selectedItem) return;
setEditItemForm({ ...selectedItem });
setEditItemOpen(true);
};
const handleEditSave = async () => {
if (!editItemForm.id) return;
setSaving(true);
try {
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editItemForm.id },
updatedData: {
selling_price: editItemForm.selling_price || null,
standard_price: editItemForm.standard_price || null,
currency_code: editItemForm.currency_code || null,
},
});
toast.success("수정되었습니다.");
setEditItemOpen(false);
fetchItems();
} catch (err: any) {
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) return;
const data = items.map((i) => ({
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
}));
await exportToExcel(data, "판매품목정보.xlsx", "판매품목");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={ITEM_TABLE}
filterId="sales-item"
onFilterChange={setSearchFilters}
dataCount={itemCount}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 판매품목 */}
<ResizablePanel defaultSize={55} minSize={30}>
<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">
<Package className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{itemCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<DataGrid
gridId="sales-item-left"
columns={LEFT_COLUMNS}
data={items}
loading={itemLoading}
selectedId={selectedItemId}
onSelect={setSelectedItemId}
onRowDoubleClick={() => openEditItem()}
emptyMessage="등록된 판매품목이 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 거래처 정보 */}
<ResizablePanel defaultSize={45} minSize={25}>
<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" />
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setCustCheckedIds(new Set()); setCustSelectOpen(true); searchCustomers(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedItemId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<DataGrid
gridId="sales-item-right"
columns={RIGHT_COLUMNS}
data={customerItems}
loading={customerLoading}
showRowNumber={false}
emptyMessage="등록된 거래처가 없습니다"
onRowDoubleClick={(row) => openEditCust(row)}
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 품목 수정 모달 */}
<FullscreenDialog
open={editItemOpen}
onOpenChange={setEditItemOpen}
title="판매품목 수정"
description={`${editItemForm.item_number || ""}${editItemForm.item_name || ""}`}
defaultMaxWidth="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setEditItemOpen(false)}></Button>
<Button onClick={handleEditSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="grid grid-cols-2 gap-4 py-4">
{/* 품목 기본정보 (읽기 전용) */}
{[
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
].map((f) => (
<div key={f.key} className="space-y-1.5">
<Label className="text-sm text-muted-foreground">{f.label}</Label>
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
</div>
))}
<div className="col-span-2 border-t my-2" />
{/* 판매 설정 (수정 가능) */}
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
placeholder="판매가격" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
placeholder="기준단가" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</FullscreenDialog>
{/* 거래처 추가 모달 */}
<Dialog open={custSelectOpen} onOpenChange={setCustSelectOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="거래처명 검색" value={custSearchKeyword}
onChange={(e) => setCustSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchCustomers()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchCustomers} disabled={custSearchLoading} className="h-9">
{custSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={custSearchResults.length > 0 && custCheckedIds.size === custSearchResults.length}
onChange={(e) => {
if (e.target.checked) setCustCheckedIds(new Set(custSearchResults.map((c) => c.id)));
else setCustCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{custSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : custSearchResults.map((c) => (
<TableRow key={c.id} className={cn("cursor-pointer", custCheckedIds.has(c.id) && "bg-primary/5")}
onClick={() => setCustCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(c.id)) next.delete(c.id); else next.add(c.id);
return next;
})}>
<TableCell className="text-center"><input type="checkbox" checked={custCheckedIds.has(c.id)} readOnly /></TableCell>
<TableCell className="text-xs">{c.customer_code}</TableCell>
<TableCell className="text-sm">{c.customer_name}</TableCell>
<TableCell className="text-xs">{c.division}</TableCell>
<TableCell className="text-xs">{c.contact_person}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{custCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCustSelectOpen(false)}></Button>
<Button onClick={goToCustDetail} disabled={custCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {custCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 거래처 상세 입력/수정 모달 */}
<FullscreenDialog
open={custDetailOpen}
onOpenChange={setCustDetailOpen}
title={`📋 거래처 상세정보 ${editCustData ? "수정" : "입력"}${selectedItem?.item_name || ""}`}
description={editCustData ? "거래처 품번/품명과 기간별 단가를 수정합니다." : "선택한 거래처의 품번/품명과 기간별 단가를 설정합니다."}
defaultMaxWidth="max-w-[1100px]"
footer={
<>
<Button variant="outline" onClick={() => {
setCustDetailOpen(false);
if (!editCustData) setCustSelectOpen(true);
setEditCustData(null);
}}>{editCustData ? "취소" : "← 이전"}</Button>
<Button onClick={handleCustDetailSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="space-y-6 py-2">
{selectedCustsForDetail.map((cust, idx) => {
const custKey = cust.customer_code || cust.id;
const mappingRows = custMappings[custKey] || [];
const prices = custPrices[custKey] || [];
return (
<div key={custKey} className="border rounded-xl overflow-hidden bg-card">
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-bold">{idx + 1}. {cust.customer_name || custKey}</div>
<div className="text-xs text-muted-foreground">{custKey}</div>
</div>
<div className="flex gap-4 p-4">
{/* 좌: 거래처 품번/품명 */}
<div className="flex-1 border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/10">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold"> / </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(custKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div>
) : mappingRows.map((mRow, mIdx) => (
<div key={mRow._id} className="flex gap-2 items-center">
<span className="text-xs text-muted-foreground w-4 shrink-0">{mIdx + 1}</span>
<Input value={mRow.customer_item_code}
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_code", e.target.value)}
placeholder="거래처 품번" className="h-8 text-sm flex-1" />
<Input value={mRow.customer_item_name}
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_name", e.target.value)}
placeholder="거래처 품명" className="h-8 text-sm flex-1" />
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive shrink-0"
onClick={() => removeMappingRow(custKey, mRow._id)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
{/* 우: 기간별 단가 */}
<div className="flex-1 border rounded-lg p-4 bg-amber-50/30 dark:bg-amber-950/10">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold"> </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addPriceRow(custKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-3">
{prices.map((price, pIdx) => (
<div key={price._id} className="border rounded-lg p-3 bg-background space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> {pIdx + 1}</span>
{prices.length > 1 && (
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive"
onClick={() => removePriceRow(custKey, price._id)}>
<X className="h-3 w-3" />
</Button>
)}
</div>
<div className="flex gap-2 items-center">
<div className="flex-1">
<FormDatePicker value={price.start_date}
onChange={(v) => updatePriceRow(custKey, price._id, "start_date", v)} placeholder="시작일" />
</div>
<span className="text-xs text-muted-foreground">~</span>
<div className="flex-1">
<FormDatePicker value={price.end_date}
onChange={(v) => updatePriceRow(custKey, price._id, "end_date", v)} placeholder="종료일" />
</div>
<div className="w-[80px]">
<Select value={price.currency_code} onValueChange={(v) => updatePriceRow(custKey, price._id, "currency_code", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-2 items-center">
<div className="w-[90px]">
<Select value={price.base_price_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "base_price_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="기준" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["base_price_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input value={price.base_price}
onChange={(e) => updatePriceRow(custKey, price._id, "base_price", e.target.value)}
className="h-8 text-xs text-right flex-1" placeholder="기준가" />
<div className="w-[90px]">
<Select value={price.discount_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "discount_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="할인" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{(priceCategoryOptions["discount_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input value={price.discount_value}
onChange={(e) => updatePriceRow(custKey, price._id, "discount_value", e.target.value)}
className="h-8 text-xs text-right w-[60px]" placeholder="0" />
<div className="w-[90px]">
<Select value={price.rounding_unit_value} onValueChange={(v) => updatePriceRow(custKey, price._id, "rounding_unit_value", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="반올림" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["rounding_unit_value"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1 border-t">
<span className="text-xs text-muted-foreground"> :</span>
<span className="font-bold text-sm">{price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
})}
</div>
</FullscreenDialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={ITEM_TABLE}
userId={user?.userId}
onSuccess={() => fetchItems()}
/>
{ConfirmDialogComponent}
</div>
);
}

View File

@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2 } from "lucide-react";
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react";
import { cn } from "@/lib/utils";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import {
@ -24,6 +24,8 @@ import {
getSalesOrderSource,
getItemSource,
} from "@/lib/api/shipping";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo";
@ -84,6 +86,9 @@ export default function ShippingOrderPage() {
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
@ -467,6 +472,9 @@ export default function ShippingOrderPage() {
<Badge variant="secondary" className="font-normal">{orders.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={() => openModal()}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
@ -577,14 +585,22 @@ export default function ShippingOrderPage() {
</div>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[90vw] max-h-[90vh] w-[1400px] p-0 flex flex-col overflow-hidden">
<DialogHeader className="p-5 pb-4 border-b bg-primary text-primary-foreground shrink-0">
<DialogTitle className="text-lg">{isEditMode ? "출하지시 수정" : "출하지시 등록"}</DialogTitle>
<DialogDescription className="text-primary-foreground/70">
{isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
</DialogDescription>
</DialogHeader>
<FullscreenDialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={isEditMode ? "출하지시 수정" : "출하지시 등록"}
description={isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
defaultMaxWidth="max-w-[90vw]"
defaultWidth="w-[1400px]"
footer={
<>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal">
@ -813,14 +829,17 @@ export default function ShippingOrderPage() {
</ResizablePanelGroup>
</div>
<DialogFooter className="p-4 border-t bg-muted/30 shrink-0">
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</FullscreenDialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName="shipment_instruction"
onSuccess={() => {
fetchOrders();
}}
/>
</div>
);
}

View File

@ -26,6 +26,9 @@ import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/servi
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
import { FileSpreadsheet, Loader2 as ExcelLoader } from "lucide-react";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
export interface ScreenViewPageProps {
screenIdProp?: number;
@ -96,6 +99,11 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
// 데이터 전달에 의해 강제 활성화된 레이어 ID 목록
const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState<string[]>([]);
// 엑셀 업로드 모달 상태
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
// 편집 모달 상태
const [editModalOpen, setEditModalOpen] = useState(false);
const [editModalConfig, setEditModalConfig] = useState<{
@ -650,8 +658,46 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
<TableOptionsProvider>
<div
ref={containerRef}
className={`bg-background h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}
className={`bg-background relative h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}
>
{/* 엑셀 업로드 버튼 (테이블이 있는 화면에서만 표시) */}
{!isPreviewMode && screen?.tableName && (
<div className="absolute top-2 right-3 z-10">
<Button
variant="outline"
size="sm"
className="gap-1.5 text-xs"
disabled={excelDetecting}
onClick={async () => {
if (!screen?.tableName) return;
setExcelDetecting(true);
try {
const result = await autoDetectMultiTableConfig(screen.tableName, screenId);
if (result.success && result.data) {
setExcelChainConfig(result.data);
setExcelUploadOpen(true);
} else {
const { toast } = await import("sonner");
toast.error(result.message || "테이블 구조를 분석할 수 없습니다.");
}
} catch {
const { toast } = await import("sonner");
toast.error("테이블 구조 분석 중 오류가 발생했습니다.");
} finally {
setExcelDetecting(false);
}
}}
>
{excelDetecting ? (
<ExcelLoader className="h-3.5 w-3.5 animate-spin" />
) : (
<FileSpreadsheet className="h-3.5 w-3.5" />
)}
</Button>
</div>
)}
{/* 레이아웃 준비 중 로딩 표시 */}
{!layoutReady && (
<div className="bg-muted/30 flex h-full w-full items-center justify-center">
@ -801,6 +847,22 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
}}
/>
{/* 엑셀 업로드 모달 (멀티테이블 자동감지) */}
{excelChainConfig && (
<MultiTableExcelUploadModal
open={excelUploadOpen}
onOpenChange={(open) => {
setExcelUploadOpen(open);
if (!open) setExcelChainConfig(null);
}}
config={excelChainConfig}
onSuccess={() => {
window.dispatchEvent(new CustomEvent("refreshTable"));
setTableRefreshKey((prev) => prev + 1);
}}
/>
)}
{/* 스케줄 생성 확인 다이얼로그 */}
<ScheduleConfirmDialog
open={showConfirmDialog}

View File

@ -0,0 +1,118 @@
"use client";
/**
* ConfirmDialog /
*
* :
* const { confirm, ConfirmDialogComponent } = useConfirmDialog();
*
* // JSX에 넣기
* <ConfirmDialogComponent />
*
* // 호출
* const ok = await confirm("삭제하시겠습니까?", { description: "이 작업은 되돌릴 수 없습니다." });
* if (ok) { ... }
*/
import React, { useState, useCallback, useRef } from "react";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/components/ui/alert-dialog";
import { AlertTriangle, Info, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
type ConfirmVariant = "default" | "destructive" | "info";
interface ConfirmOptions {
description?: string;
confirmText?: string;
cancelText?: string;
variant?: ConfirmVariant;
}
const VARIANT_CONFIG = {
default: {
icon: Info,
iconClass: "text-primary",
buttonClass: "",
},
destructive: {
icon: Trash2,
iconClass: "text-destructive",
buttonClass: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
},
info: {
icon: Info,
iconClass: "text-blue-500",
buttonClass: "",
},
};
export function useConfirmDialog() {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [options, setOptions] = useState<ConfirmOptions>({});
const resolveRef = useRef<((value: boolean) => void) | null>(null);
const confirm = useCallback((msg: string, opts?: ConfirmOptions): Promise<boolean> => {
setTitle(msg);
setOptions(opts || {});
setOpen(true);
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve;
});
}, []);
const handleConfirm = () => {
setOpen(false);
resolveRef.current?.(true);
};
const handleCancel = () => {
setOpen(false);
resolveRef.current?.(false);
};
const variant = options.variant || "default";
const config = VARIANT_CONFIG[variant];
const Icon = config.icon;
const ConfirmDialogComponent = (
<AlertDialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
<AlertDialogContent className="max-w-[420px]">
<AlertDialogHeader>
<div className="flex items-start gap-3">
<div className={cn("mt-0.5 shrink-0", config.iconClass)}>
<Icon className="h-5 w-5" />
</div>
<div>
<AlertDialogTitle className="text-base">{title}</AlertDialogTitle>
{options.description && (
<AlertDialogDescription className="mt-1.5 text-sm">
{options.description}
</AlertDialogDescription>
)}
</div>
</div>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>
{options.cancelText || "취소"}
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm} className={cn(config.buttonClass)}>
{options.confirmText || "확인"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
return { confirm, ConfirmDialogComponent };
}

View File

@ -0,0 +1,560 @@
"use client";
/**
* DataGrid
*
* :
* - (@dnd-kit)
* - (asc/desc)
* - 필터: 필터 Popover
* - (text/number/date/select)
* - (number )
* - truncate + tooltip
*/
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import {
DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent,
} from "@dnd-kit/core";
import { SortableContext, horizontalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Filter, Check, Search, ImageIcon, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
// --- 타입 ---
export interface DataGridColumn {
key: string;
label: string;
width?: string;
minWidth?: string;
editable?: boolean;
inputType?: "text" | "number" | "date" | "select";
sortable?: boolean;
filterable?: boolean;
truncate?: boolean;
align?: "left" | "center" | "right";
formatNumber?: boolean;
/** 이미지 타입 — 값을 /api/files/preview/{objid} 이미지로 렌더링 */
renderType?: "image";
selectOptions?: { value: string; label: string }[];
}
export interface DataGridProps {
columns: DataGridColumn[];
data: any[];
onRowClick?: (row: any, index: number) => void;
onRowDoubleClick?: (row: any, index: number) => void;
selectedId?: string | null;
onSelect?: (id: string | null) => void;
onCellEdit?: (rowId: string, columnKey: string, newValue: any, row: any) => void;
tableName?: string;
showRowNumber?: boolean;
/** 체크박스 다중선택 모드 */
showCheckbox?: boolean;
/** 체크된 행 ID 배열 */
checkedIds?: string[];
/** 체크 변경 콜백 */
onCheckedChange?: (ids: string[]) => void;
emptyMessage?: string;
loading?: boolean;
onColumnOrderChange?: (columns: DataGridColumn[]) => void;
gridId?: string;
}
const fmtNum = (val: any) => {
if (val == null || val === "") return "";
const n = Number(String(val).replace(/,/g, ""));
if (isNaN(n)) return String(val);
return n.toLocaleString();
};
// --- Sortable Header Cell ---
function SortableHeaderCell({
col, sortKey, sortDir, onSort,
headerFilterValues, uniqueValues, onToggleFilter, onClearFilter,
}: {
col: DataGridColumn;
sortKey: string | null;
sortDir: "asc" | "desc";
onSort: (key: string) => void;
headerFilterValues: Set<string>;
uniqueValues: string[];
onToggleFilter: (colKey: string, value: string) => void;
onClearFilter: (colKey: string) => void;
}) {
const [filterSearch, setFilterSearch] = useState("");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: "grab",
};
const isSorted = sortKey === col.key;
const hasFilter = headerFilterValues.size > 0;
return (
<TableHead
ref={setNodeRef}
style={style}
className={cn(col.width, col.minWidth, "select-none relative")}
>
<div className="flex items-center gap-0.5">
<div
{...attributes}
{...listeners}
className="flex items-center gap-0.5 cursor-pointer flex-1 min-w-0"
onClick={(e) => {
e.stopPropagation();
if (col.sortable !== false) onSort(col.key);
}}
>
<span className="text-xs font-medium truncate">{col.label}</span>
{isSorted && (
<span className="text-primary text-xs shrink-0">{sortDir === "asc" ? "↑" : "↓"}</span>
)}
</div>
{/* 컬럼 필터 아이콘 → Popover */}
{col.filterable !== false && uniqueValues.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className={cn(
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
hasFilter && "text-primary bg-primary/10",
)}
title="필터"
>
<Filter className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-2" align="start" onClick={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-xs font-medium">: {col.label}</span>
{hasFilter && (
<button onClick={() => onClearFilter(col.key)} className="text-destructive text-xs hover:underline">
</button>
)}
</div>
{/* 검색 */}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
placeholder="검색..."
className="h-7 text-xs pl-7"
/>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{uniqueValues
.filter((v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase()))
.slice(0, 50).map((val) => {
const isSelected = headerFilterValues.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => onToggleFilter(col.key, val)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
isSelected ? "bg-primary border-primary" : "border-input",
)}>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{uniqueValues.filter((v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())).length > 50 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {uniqueValues.filter((v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())).length - 50}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</TableHead>
);
}
// --- DataGrid ---
export function DataGrid({
columns: initialColumns,
data,
onRowClick,
onRowDoubleClick,
selectedId,
onSelect,
onCellEdit,
tableName,
showRowNumber = true,
showCheckbox = false,
checkedIds = [],
onCheckedChange,
emptyMessage = "데이터가 없습니다",
loading = false,
onColumnOrderChange,
gridId,
}: DataGridProps) {
const [columns, setColumns] = useState(initialColumns);
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
// 정렬
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
// 헤더 필터 (컬럼별 선택된 값 Set)
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
// 인라인 편집
const [editingCell, setEditingCell] = useState<{ rowIdx: number; colKey: string } | null>(null);
// 이미지 확대 모달
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const editRef = useRef<HTMLInputElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
);
// localStorage에서 컬럼 순서 복원
useEffect(() => {
if (!gridId) return;
const saved = localStorage.getItem(`datagrid_col_order_${gridId}`);
if (saved) {
try {
const order = JSON.parse(saved) as string[];
const reordered = order
.map((key) => initialColumns.find((c) => c.key === key))
.filter(Boolean) as DataGridColumn[];
const remaining = initialColumns.filter((c) => !order.includes(c.key));
setColumns([...reordered, ...remaining]);
} catch { /* skip */ }
}
}, [gridId]); // eslint-disable-line react-hooks/exhaustive-deps
// 컬럼별 고유값 계산 (필터 팝오버용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of columns) {
const values = new Set<string>();
data.forEach((row) => {
const val = row[col.key];
if (val !== null && val !== undefined && val !== "") {
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data, columns]);
// 드래그 완료
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setColumns((prev) => {
const oldIndex = prev.findIndex((c) => c.key === active.id);
const newIndex = prev.findIndex((c) => c.key === over.id);
const next = arrayMove(prev, oldIndex, newIndex);
if (gridId) localStorage.setItem(`datagrid_col_order_${gridId}`, JSON.stringify(next.map((c) => c.key)));
onColumnOrderChange?.(next);
return next;
});
};
// 정렬
const handleSort = (key: string) => {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
};
// 헤더 필터 토글
const toggleHeaderFilter = (colKey: string, value: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
const set = new Set(next[colKey] || []);
if (set.has(value)) set.delete(value); else set.add(value);
if (set.size === 0) delete next[colKey]; else next[colKey] = set;
return next;
});
};
const clearHeaderFilter = (colKey: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
delete next[colKey];
return next;
});
};
// 필터 + 정렬 적용
const processedData = useMemo(() => {
let result = [...data];
// 헤더 필터 적용
if (Object.keys(headerFilters).length > 0) {
result = result.filter((row) => {
return Object.entries(headerFilters).every(([colKey, values]) => {
if (values.size === 0) return true;
const cellVal = row[colKey] != null ? String(row[colKey]) : "";
return values.has(cellVal);
});
});
}
// 정렬
if (sortKey) {
result.sort((a, b) => {
const av = a[sortKey] ?? "";
const bv = b[sortKey] ?? "";
const na = Number(av);
const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return sortDir === "asc" ? na - nb : nb - na;
return sortDir === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return result;
}, [data, headerFilters, sortKey, sortDir]);
// 인라인 편집
const startEdit = (rowIdx: number, colKey: string, currentVal: any) => {
const col = columns.find((c) => c.key === colKey);
if (!col?.editable) return;
setEditingCell({ rowIdx, colKey });
setEditValue(currentVal != null ? String(currentVal) : "");
};
const saveEdit = useCallback(async () => {
if (!editingCell) return;
const { rowIdx, colKey } = editingCell;
const row = processedData[rowIdx];
if (!row) { setEditingCell(null); return; }
const originalVal = String(row[colKey] ?? "");
if (originalVal === editValue) { setEditingCell(null); return; }
if (tableName && row.id) {
try {
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
originalData: { id: row.id },
updatedData: { [colKey]: editValue || null },
});
row[colKey] = editValue;
toast.success("저장됨");
} catch (err) {
console.error("셀 저장 실패:", err);
toast.error("저장에 실패했습니다.");
setEditingCell(null);
return;
}
}
onCellEdit?.(row.id, colKey, editValue, row);
setEditingCell(null);
}, [editingCell, editValue, processedData, tableName, onCellEdit]);
const cancelEdit = () => setEditingCell(null);
const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") { e.preventDefault(); saveEdit(); }
else if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }
else if (e.key === "Tab") { e.preventDefault(); saveEdit(); }
};
useEffect(() => {
if (editingCell && editRef.current) {
editRef.current.focus();
editRef.current.select();
}
}, [editingCell]);
// 셀 렌더링
const renderCell = (row: any, col: DataGridColumn, rowIdx: number) => {
const isEditing = editingCell?.rowIdx === rowIdx && editingCell?.colKey === col.key;
const val = row[col.key];
if (isEditing) {
if (col.inputType === "select" && col.selectOptions) {
return (
<select ref={editRef as any} value={editValue} onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown} onBlur={saveEdit}
className="h-7 w-full rounded border border-primary bg-background px-1.5 text-xs focus:ring-1">
<option value=""></option>
{col.selectOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
);
}
return (
<input ref={editRef} type={col.inputType === "date" ? "date" : "text"}
value={editValue} onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown} onBlur={saveEdit}
className={cn("h-7 w-full rounded border border-primary bg-background px-1.5 text-xs focus:ring-1",
col.align === "right" && "text-right")} />
);
}
// 이미지 타입
if (col.renderType === "image" && val) {
const src = (val.startsWith("http") || val.startsWith("/")) ? val : `/api/files/preview/${val}`;
return (
<img src={src} alt="" className="h-10 w-10 rounded object-cover cursor-pointer hover:ring-2 hover:ring-primary transition-all"
onClick={(e) => { e.stopPropagation(); setPreviewImage(src); }} />
);
}
if (col.renderType === "image" && !val) {
return <div className="h-10 w-10 rounded bg-muted flex items-center justify-center"><ImageIcon className="h-4 w-4 text-muted-foreground/30" /></div>;
}
let display = val ?? "";
if (col.formatNumber || col.inputType === "number") display = fmtNum(val);
const truncateClass = col.truncate !== false ? "block truncate" : "";
return (
<span className={cn("text-xs", truncateClass, col.align === "right" && "text-right w-full inline-block")}
title={String(val ?? "")}>
{display}
</span>
);
};
return (
<div className="flex flex-col h-full overflow-auto">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow>
{showCheckbox && (
<TableHead className="w-[40px] text-center">
<Checkbox
checked={processedData.length > 0 && checkedIds.length === processedData.length}
onCheckedChange={(checked) => {
onCheckedChange?.(checked ? processedData.map((r) => r.id) : []);
}}
/>
</TableHead>
)}
{showRowNumber && !showCheckbox && <TableHead className="w-[40px] text-center text-xs">No</TableHead>}
{columns.map((col) => (
<SortableHeaderCell
key={col.key}
col={col}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
headerFilterValues={headerFilters[col.key] || new Set()}
uniqueValues={columnUniqueValues[col.key] || []}
onToggleFilter={toggleHeaderFilter}
onClearFilter={clearHeaderFilter}
/>
))}
</TableRow>
</SortableContext>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
...
</TableCell>
</TableRow>
) : processedData.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
{emptyMessage}
</TableCell>
</TableRow>
) : processedData.map((row, rowIdx) => (
<TableRow
key={row.id || rowIdx}
className={cn("cursor-pointer",
selectedId === row.id && "bg-primary/5",
showCheckbox && checkedIds.includes(row.id) && "bg-primary/5",
)}
onClick={() => {
onSelect?.(row.id);
onRowClick?.(row, rowIdx);
// 체크박스 모드에서는 행 클릭으로 체크 토글
if (showCheckbox && onCheckedChange) {
const next = checkedIds.includes(row.id)
? checkedIds.filter((id) => id !== row.id)
: [...checkedIds, row.id];
onCheckedChange(next);
}
}}
onDoubleClick={() => onRowDoubleClick?.(row, rowIdx)}
>
{showCheckbox && (
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={checkedIds.includes(row.id)}
onCheckedChange={(checked) => {
const next = checked
? [...checkedIds, row.id]
: checkedIds.filter((id) => id !== row.id);
onCheckedChange?.(next);
}}
/>
</TableCell>
)}
{showRowNumber && !showCheckbox && <TableCell className="text-center text-[10px] text-muted-foreground">{rowIdx + 1}</TableCell>}
{columns.map((col) => (
<TableCell
key={col.key}
className={cn(col.width, col.minWidth, "py-2.5", col.editable && "cursor-text")}
onDoubleClick={(e) => {
if (col.editable) {
e.stopPropagation();
startEdit(rowIdx, col.key, row[col.key]);
}
}}
>
{renderCell(row, col, rowIdx)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</DndContext>
{/* 이미지 확대 모달 */}
{previewImage && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70"
onClick={() => setPreviewImage(null)}>
<div className="relative max-w-[90vw] max-h-[90vh]">
<img src={previewImage} alt="" className="max-w-full max-h-[85vh] rounded-lg object-contain" />
<button className="absolute -top-3 -right-3 bg-background rounded-full p-1 shadow-lg hover:bg-muted"
onClick={() => setPreviewImage(null)}>
<X className="h-5 w-5" />
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,460 @@
"use client";
/**
* DynamicSearchFilter
*
* :
* - / ( )
* - //
* - (%)
* - placeholder로만 ( )
* - select ()
* - (FormDatePicker)
* - + localStorage에 ( )
* - ( )
* - table_type_columns의 column_label
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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 { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
// --- 타입 ---
export type FilterType = "text" | "select" | "date";
export interface FilterColumn {
columnName: string;
columnLabel: string;
/** 원본 inputType (DB에서 가져온 값, 타입 변경 시 기본값 복원용) */
originalType: FilterType;
/** 사용자가 선택한 필터 타입 */
filterType: FilterType;
enabled: boolean;
/** 필터 너비 (%, 10~100, 기본 25) */
width: number;
}
export interface FilterValue {
columnName: string;
operator: "contains" | "equals" | "in" | "between";
value: string;
}
export interface DynamicSearchFilterProps {
/** 테이블명 (컬럼 목록 + 카테고리 옵션 로드에 사용) */
tableName: string;
/** 고유 ID (localStorage 키 분리용, 예: "item-info", "sales-order") */
filterId: string;
/** 필터 변경 시 콜백 — API 필터 배열 형태로 전달 */
onFilterChange: (filters: FilterValue[]) => void;
/** 데이터 건수 표시 (optional) */
dataCount?: number;
/** 추가 액션 버튼 영역 */
extraActions?: React.ReactNode;
}
const FILTER_TYPE_OPTIONS: { value: FilterType; label: string }[] = [
{ value: "text", label: "텍스트" },
{ value: "select", label: "선택" },
{ value: "date", label: "날짜" },
];
const WIDTH_OPTIONS = [
{ value: 15, label: "15%" },
{ value: 20, label: "20%" },
{ value: 25, label: "25%" },
{ value: 30, label: "30%" },
{ value: 40, label: "40%" },
{ value: 50, label: "50%" },
];
// --- 컴포넌트 ---
export function DynamicSearchFilter({
tableName,
filterId,
onFilterChange,
dataCount,
extraActions,
}: 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 [tempColumns, setTempColumns] = useState<FilterColumn[]>([]);
const STORAGE_KEY_FILTERS = `dynamic_filter_config_${filterId}`;
const STORAGE_KEY_VALUES = `dynamic_filter_values_${filterId}`;
// 컬럼 정보 로드 (회사별 table_type_columns의 column_label 사용)
useEffect(() => {
const loadColumns = async () => {
try {
const res = await apiClient.get(`/table-management/tables/${tableName}/web-types`);
const types = res.data?.data || [];
const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"];
const cols: FilterColumn[] = types
.filter((t: any) => !AUTO_COLS.includes(t.columnName))
.map((t: any) => {
let filterType: FilterType = "text";
if (t.inputType === "category" || t.inputType === "select") filterType = "select";
else if (t.inputType === "date" || t.inputType === "datetime") filterType = "date";
return {
columnName: t.columnName,
columnLabel: t.displayName || t.columnLabel || t.columnName,
originalType: filterType,
filterType,
enabled: false,
width: 25,
};
});
// localStorage에서 저장된 설정 복원 (enabled, filterType, width)
const saved = localStorage.getItem(STORAGE_KEY_FILTERS);
let merged = cols;
if (saved) {
try {
const parsed = JSON.parse(saved) as FilterColumn[];
merged = cols.map((col) => {
const s = parsed.find((p) => p.columnName === col.columnName);
return s ? { ...col, enabled: s.enabled, filterType: s.filterType, width: s.width || 25 } : col;
});
} catch { /* skip */ }
}
setAllColumns(merged);
setActiveFilters(merged.filter((c) => c.enabled));
// 저장된 필터 값 복원
const savedValues = localStorage.getItem(STORAGE_KEY_VALUES);
if (savedValues) {
try { setFilterValues(JSON.parse(savedValues)); } catch { /* skip */ }
}
} catch (err) {
console.error("필터 컬럼 로드 실패:", err);
}
};
loadColumns();
}, [tableName, STORAGE_KEY_FILTERS, STORAGE_KEY_VALUES]);
// select 타입 필터의 옵션 로드
useEffect(() => {
const loadOptions = async () => {
const selectCols = activeFilters.filter((f) => f.filterType === "select");
if (selectCols.length === 0) return;
const opts: Record<string, { label: string; value: string }[]> = {};
const flatten = (vals: any[]): { label: string; value: string }[] => {
const result: { label: string; value: string }[] = [];
for (const v of vals) {
result.push({ value: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
await Promise.all(
selectCols.map(async (col) => {
if (selectOptions[col.columnName]?.length) return; // 이미 로드됨
try {
const res = await apiClient.get(`/table-categories/${tableName}/${col.columnName}/values`);
if (res.data?.success && res.data.data?.length > 0) {
opts[col.columnName] = flatten(res.data.data);
}
} catch { /* skip */ }
})
);
if (Object.keys(opts).length > 0) {
setSelectOptions((prev) => ({ ...prev, ...opts }));
}
};
loadOptions();
}, [activeFilters, tableName]); // eslint-disable-line react-hooks/exhaustive-deps
// 필터 값 → API 필터 형태로 변환
const emitFilters = useCallback((values: Record<string, any>) => {
const filters: FilterValue[] = [];
for (const f of activeFilters) {
const val = values[f.columnName];
if (!val || (typeof val === "string" && val === "")) continue;
if (f.filterType === "date" && typeof val === "object" && (val.from || val.to)) {
const from = val.from || "";
const to = val.to || "";
if (from || to) filters.push({ columnName: f.columnName, operator: "between", value: `${from}|${to}` });
} else if (Array.isArray(val)) {
if (val.length > 0) filters.push({ columnName: f.columnName, operator: "in", value: val.join("|") });
} else {
filters.push({
columnName: f.columnName,
operator: f.filterType === "select" ? "equals" : "contains",
value: String(val),
});
}
}
onFilterChange(filters);
}, [activeFilters, onFilterChange]);
const handleValueChange = (columnName: string, value: any) => {
const newValues = { ...filterValues, [columnName]: value };
setFilterValues(newValues);
localStorage.setItem(STORAGE_KEY_VALUES, JSON.stringify(newValues));
emitFilters(newValues);
};
// 초기 로드 시 필터 적용
useEffect(() => {
if (activeFilters.length > 0 && Object.keys(filterValues).length > 0) {
emitFilters(filterValues);
}
}, [activeFilters.length]); // eslint-disable-line react-hooks/exhaustive-deps
const handleReset = () => {
setFilterValues({});
localStorage.removeItem(STORAGE_KEY_VALUES);
onFilterChange([]);
};
// 설정 모달
const openSettings = () => {
setTempColumns(allColumns.map((c) => ({ ...c })));
setSettingsOpen(true);
};
const saveSettings = () => {
setAllColumns(tempColumns);
const active = tempColumns.filter((c) => c.enabled);
setActiveFilters(active);
localStorage.setItem(STORAGE_KEY_FILTERS, JSON.stringify(tempColumns));
// 비활성화된 필터 값 + 타입 변경된 필터 값 제거
const activeNames = new Set(active.map((a) => a.columnName));
const cleaned = { ...filterValues };
for (const key of Object.keys(cleaned)) {
if (!activeNames.has(key)) delete cleaned[key];
}
// 타입이 변경된 필터의 값도 초기화
for (const col of active) {
const prev = allColumns.find((c) => c.columnName === col.columnName);
if (prev && prev.filterType !== col.filterType) {
delete cleaned[col.columnName];
}
}
setFilterValues(cleaned);
localStorage.setItem(STORAGE_KEY_VALUES, JSON.stringify(cleaned));
setSettingsOpen(false);
emitFilters(cleaned);
};
// --- 필터 렌더링 (라벨은 placeholder로만) ---
const renderFilterInput = (filter: FilterColumn) => {
const value = filterValues[filter.columnName] || "";
const widthStyle = { flex: `0 0 ${filter.width}%`, minWidth: "120px" };
switch (filter.filterType) {
case "date":
return (
<div style={widthStyle} className="flex items-center gap-1">
<div className="flex-1">
<FormDatePicker
value={typeof value === "object" ? value.from || "" : ""}
onChange={(v) => handleValueChange(filter.columnName, { ...((typeof value === "object" && value) || {}), from: v })}
placeholder={`${filter.columnLabel} 시작`}
/>
</div>
<span className="text-muted-foreground text-xs shrink-0">~</span>
<div className="flex-1">
<FormDatePicker
value={typeof value === "object" ? value.to || "" : ""}
onChange={(v) => handleValueChange(filter.columnName, { ...((typeof value === "object" && value) || {}), to: v })}
placeholder={`${filter.columnLabel} 종료`}
/>
</div>
</div>
);
case "select": {
const options = selectOptions[filter.columnName] || [];
const selectedValues: string[] = Array.isArray(value) ? value : value ? [value] : [];
const getDisplayText = () => {
if (selectedValues.length === 0) return filter.columnLabel;
if (selectedValues.length === 1) {
const opt = options.find((o) => o.value === selectedValues[0]);
return opt?.label || selectedValues[0];
}
return `${filter.columnLabel} (${selectedValues.length})`;
};
const toggleOption = (optValue: string, checked: boolean) => {
const next = checked ? [...selectedValues, optValue] : selectedValues.filter((v) => v !== optValue);
handleValueChange(filter.columnName, next.length > 0 ? next : "");
};
return (
<div style={widthStyle}>
<Popover>
<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")}>
<span className="truncate">{getDisplayText()}</span>
<ChevronsUpDown className="ml-1 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<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) => (
<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))}>
<Checkbox checked={selectedValues.includes(opt.value)} />
<span className="truncate text-sm">{opt.label}</span>
</div>
))}
</div>
{selectedValues.length > 0 && (
<div className="border-t p-1">
<Button variant="ghost" size="sm" className="h-7 w-full text-xs"
onClick={() => handleValueChange(filter.columnName, "")}> </Button>
</div>
)}
</PopoverContent>
</Popover>
</div>
);
}
default: // text
return (
<div style={widthStyle}>
<Input type="text" value={value}
onChange={(e) => handleValueChange(filter.columnName, e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") emitFilters(filterValues); }}
className="h-9 w-full text-sm" placeholder={filter.columnLabel} />
</div>
);
}
};
return (
<div className="bg-card flex w-full flex-wrap items-center gap-2 rounded-lg border p-3 shadow-sm">
{/* 활성 필터들 — 라벨 없이 placeholder만 */}
{activeFilters.length > 0 ? (
<div className="flex flex-1 flex-wrap items-center gap-2">
{activeFilters.map((filter) => renderFilterInput(filter))}
<Button variant="outline" size="sm" onClick={handleReset} className="h-9 shrink-0">
<RotateCcw className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="flex-1 text-sm text-muted-foreground">
</div>
)}
{/* 우측 */}
<div className="flex items-center gap-2 shrink-0">
{dataCount !== undefined && (
<div className="bg-muted text-muted-foreground rounded-md px-3 py-1.5 text-sm font-medium">
{dataCount.toLocaleString()}
</div>
)}
{extraActions}
<Button variant="outline" size="sm" onClick={openSettings} className="h-9">
<Settings className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{/* 필터 설정 모달 */}
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<DialogContent className="max-w-2xl max-h-[70vh] overflow-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="py-2">
{/* 헤더 */}
<div className="flex items-center gap-3 pb-2 border-b mb-1 text-xs font-medium text-muted-foreground">
<div className="w-8 text-center"></div>
<div className="flex-1"></div>
<div className="w-[120px]"> </div>
<div className="w-[100px]"></div>
</div>
{tempColumns.map((col, idx) => (
<div key={col.columnName} className="flex items-center gap-3 py-1.5 px-1 hover:bg-muted/50 rounded">
<div className="w-8 text-center">
<Checkbox
checked={col.enabled}
onCheckedChange={(checked) => {
const next = [...tempColumns];
next[idx] = { ...next[idx], enabled: !!checked };
setTempColumns(next);
}}
/>
</div>
<div className="flex-1 text-sm">{col.columnLabel}</div>
<div className="w-[120px]">
<Select
value={col.filterType}
onValueChange={(v) => {
const next = [...tempColumns];
next[idx] = { ...next[idx], filterType: v as FilterType };
setTempColumns(next);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-[100px]">
<Select
value={String(col.width)}
onValueChange={(v) => {
const next = [...tempColumns];
next[idx] = { ...next[idx], width: Number(v) };
setTempColumns(next);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WIDTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSettingsOpen(false)}></Button>
<Button onClick={saveSettings}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -822,6 +822,37 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
return true;
};
// 템플릿 다운로드: 테이블 스키마 기반으로 빈 엑셀 파일 생성
const handleDownloadTemplate = async () => {
try {
const { exportToExcel } = await import("@/lib/utils/excelExport");
const response = await getTableSchema(tableName);
if (!response.success || !response.data) {
toast.error("테이블 정보를 가져올 수 없습니다.");
return;
}
const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"];
const columns = response.data.columns.filter(
(col) => !AUTO_COLS.includes(col.name.toLowerCase())
);
// 필수 컬럼에 * 표시
const headerRow: Record<string, any> = {};
for (const col of columns) {
const label = col.label || col.name;
const isRequired = !col.nullable;
headerRow[isRequired ? `${label} *` : label] = "";
}
await exportToExcel([headerRow], `${tableName}_템플릿.xlsx`, "Sheet1");
toast.success("템플릿 파일이 다운로드되었습니다.");
} catch (error) {
console.error("템플릿 다운로드 실패:", error);
toast.error("템플릿 다운로드에 실패했습니다.");
}
};
// 다음 단계
const handleNext = async () => {
if (currentStep === 1 && !file) {
@ -1607,11 +1638,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</div>
)}
{/* 파일 선택 영역 */}
{/* 템플릿 다운로드 + 파일 선택 영역 */}
<div>
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
*
</Label>
<div className="flex items-center justify-between">
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
*
</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={handleDownloadTemplate}
>
<ArrowRight className="h-3 w-3 rotate-90" />
릿
</Button>
</div>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}

View File

@ -0,0 +1,88 @@
"use client";
/**
* FullscreenDialog Dialog
*
* :
* <FullscreenDialog open={open} onOpenChange={setOpen} title="제목" description="설명">
* {children}
* </FullscreenDialog>
*
* footer prop으로
*/
import React, { useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Maximize2, Minimize2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface FullscreenDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
description?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
/** 기본 모달 최대 너비 (기본: "max-w-5xl") */
defaultMaxWidth?: string;
/** 기본 모달 너비 (기본: "w-[95vw]") */
defaultWidth?: string;
className?: string;
}
export function FullscreenDialog({
open, onOpenChange, title, description, children, footer,
defaultMaxWidth = "max-w-5xl",
defaultWidth = "w-[95vw]",
className,
}: FullscreenDialogProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
const handleOpenChange = (v: boolean) => {
if (!v) setIsFullscreen(false);
onOpenChange(v);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className={cn(
"overflow-auto flex flex-col transition-all duration-200",
isFullscreen
? "max-w-screen max-h-screen w-screen h-screen rounded-none"
: `${defaultMaxWidth} ${defaultWidth} max-h-[90vh]`,
className,
)}>
<DialogHeader className="shrink-0">
<div className="flex items-center justify-between pr-8">
<div>
{typeof title === "string" ? <DialogTitle>{title}</DialogTitle> : title}
{description && (
typeof description === "string"
? <DialogDescription>{description}</DialogDescription>
: description
)}
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
onClick={() => setIsFullscreen((p) => !p)}
title={isFullscreen ? "기본 크기" : "전체 화면"}>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</Button>
</div>
</DialogHeader>
<div className="flex-1 overflow-auto">
{children}
</div>
{footer && (
<DialogFooter className="shrink-0">
{footer}
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,164 @@
"use client";
/**
* ImageUpload
*
* :
* - ( or )
* -
* - /api/files/upload API
* - objid
* - (objid URL)
*
* :
* <ImageUpload
* value={form.image_path} // 기존 파일 objid 또는 URL
* onChange={(objid) => setForm(...)} // 업로드 완료 시 objid 반환
* tableName="equipment_mng" // 연결 테이블
* recordId="xxx" // 연결 레코드 ID
* columnName="image_path" // 연결 컬럼명
* />
*/
import React, { useState, useRef, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Upload, X, Loader2, ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
interface ImageUploadProps {
/** 현재 이미지 값 (파일 objid, URL, 또는 빈 문자열) */
value?: string;
/** 업로드 완료 시 콜백 (파일 objid) */
onChange?: (value: string) => void;
/** 연결할 테이블명 */
tableName?: string;
/** 연결할 레코드 ID */
recordId?: string;
/** 연결할 컬럼명 */
columnName?: string;
/** 높이 (기본 160px) */
height?: string;
/** 비활성화 */
disabled?: boolean;
className?: string;
}
export function ImageUpload({
value, onChange, tableName, recordId, columnName,
height = "h-40", disabled = false, className,
}: ImageUploadProps) {
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
// 이미지 URL 결정
const imageUrl = value
? (value.startsWith("http") || value.startsWith("/"))
? value
: `/api/files/preview/${value}`
: null;
const handleUpload = useCallback(async (file: File) => {
if (!file.type.startsWith("image/")) {
toast.error("이미지 파일만 업로드 가능합니다.");
return;
}
if (file.size > 10 * 1024 * 1024) {
toast.error("파일 크기는 10MB 이하만 가능합니다.");
return;
}
setUploading(true);
try {
const formData = new FormData();
formData.append("files", file);
formData.append("docType", "IMAGE");
formData.append("docTypeName", "이미지");
if (tableName) formData.append("linkedTable", tableName);
if (recordId) formData.append("recordId", recordId);
if (columnName) formData.append("columnName", columnName);
if (tableName && recordId) {
formData.append("autoLink", "true");
if (columnName) formData.append("isVirtualFileColumn", "true");
}
const res = await apiClient.post("/files/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
if (res.data?.success && (res.data.files?.length > 0 || res.data.data?.length > 0)) {
const file = res.data.files?.[0] || res.data.data?.[0];
const objid = file.objid;
onChange?.(objid);
toast.success("이미지가 업로드되었습니다.");
} else {
toast.error(res.data?.message || "업로드에 실패했습니다.");
}
} catch (err: any) {
console.error("이미지 업로드 실패:", err);
toast.error(err.response?.data?.message || "업로드에 실패했습니다.");
} finally {
setUploading(false);
}
}, [tableName, recordId, columnName, onChange]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
e.target.value = "";
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) handleUpload(file);
};
const handleRemove = () => {
onChange?.("");
};
return (
<div className={cn("relative", className)}>
<div
className={cn(
"border-2 border-dashed rounded-lg flex flex-col items-center justify-center cursor-pointer transition-colors overflow-hidden",
height,
dragOver ? "border-primary bg-primary/5" : imageUrl ? "border-transparent" : "border-muted-foreground/25 hover:border-primary hover:bg-muted/50",
disabled && "opacity-50 cursor-not-allowed",
)}
onClick={() => !disabled && !uploading && fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={!disabled ? handleDrop : undefined}
>
{uploading ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground"> ...</span>
</div>
) : imageUrl ? (
<img src={imageUrl} alt="이미지" className="w-full h-full object-contain" />
) : (
<div className="flex flex-col items-center gap-2">
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
<span className="text-xs text-muted-foreground"> </span>
</div>
)}
</div>
{/* 삭제 버튼 */}
{imageUrl && !disabled && (
<Button variant="destructive" size="sm" className="absolute top-1 right-1 h-6 w-6 p-0 rounded-full"
onClick={(e) => { e.stopPropagation(); handleRemove(); }}>
<X className="h-3 w-3" />
</Button>
)}
<input ref={fileRef} type="file" accept="image/*" onChange={handleFileChange} className="hidden" />
</div>
);
}

View File

@ -0,0 +1,382 @@
"use client";
/**
* ShippingPlanBatchModal
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Plus, X, Loader2, Package, Truck, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import {
getShippingPlanAggregate,
batchSaveShippingPlans,
type AggregateResponse,
type BatchSavePlan,
} from "@/lib/api/shipping";
// --- 시간 선택 컴포넌트 (오전/오후 + 시 + 분) ---
function TimePicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [open, setOpen] = useState(false);
// value: "HH:MM" or ""
const parsed = value ? value.split(":") : ["", ""];
const hour24 = parsed[0] ? parseInt(parsed[0]) : -1;
const minute = parsed[1] ? parseInt(parsed[1]) : -1;
const isAM = hour24 >= 0 && hour24 < 12;
const [period, setPeriod] = useState<"오전" | "오후">(isAM ? "오전" : "오후");
const hours = Array.from({ length: 12 }, (_, i) => i); // 0-11
const minutes = Array.from({ length: 12 }, (_, i) => i * 5); // 0,5,10...55
const displayHour = hour24 >= 0 ? (hour24 % 12 || 12) : null;
const displayMinute = minute >= 0 ? minute : null;
const select = (p: "오전" | "오후", h: number, m: number) => {
const h24 = p === "오전" ? (h === 12 ? 0 : h) : (h === 12 ? 12 : h + 12);
onChange(`${String(h24).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="h-7 w-full justify-start text-xs font-normal gap-1 px-2">
<Clock className="h-3 w-3 shrink-0 opacity-50" />
{value || "--:--"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="flex">
{/* 오전/오후 */}
<div className="border-r p-1 flex flex-col gap-0.5">
{(["오전", "오후"] as const).map((p) => (
<button key={p} onClick={() => setPeriod(p)}
className={cn("px-3 py-1.5 text-xs rounded", period === p ? "bg-primary text-primary-foreground" : "hover:bg-muted")}>
{p}
</button>
))}
</div>
{/* 시 */}
<div className="border-r p-1 max-h-48 overflow-auto flex flex-col gap-0.5">
{[12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((h) => (
<button key={h} onClick={() => select(period, h, displayMinute ?? 0)}
className={cn("px-3 py-1 text-xs rounded min-w-[32px]",
displayHour === h ? "bg-primary text-primary-foreground" : "hover:bg-muted")}>
{String(h).padStart(2, "0")}
</button>
))}
</div>
{/* 분 */}
<div className="p-1 max-h-48 overflow-auto flex flex-col gap-0.5">
{minutes.map((m) => (
<button key={m} onClick={() => select(period, displayHour ?? 12, m)}
className={cn("px-3 py-1 text-xs rounded min-w-[32px]",
displayMinute === m ? "bg-primary text-primary-foreground" : "hover:bg-muted")}>
{String(m).padStart(2, "0")}
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
);
}
// --- 타입 ---
interface NewPlanRow {
_id: string;
sourceId: string;
partCode: string;
planQty: string;
planDate: string;
planTime: string;
shipInfo: string;
}
interface ShippingPlanBatchModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedDetailIds: string[];
onSuccess?: () => void;
}
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className={cn("rounded-lg px-3 py-2.5 flex-1 min-w-0", color)}>
<div className="text-[10px] opacity-80 whitespace-nowrap">{label}</div>
<div className="text-lg font-bold">{value.toLocaleString()}</div>
</div>
);
}
// --- 메인 ---
export function ShippingPlanBatchModal({
open, onOpenChange, selectedDetailIds, onSuccess,
}: ShippingPlanBatchModalProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [aggregate, setAggregate] = useState<AggregateResponse>({});
const [newPlans, setNewPlans] = useState<Record<string, NewPlanRow[]>>({});
useEffect(() => {
if (!open || selectedDetailIds.length === 0) return;
const load = async () => {
setLoading(true);
try {
const result = await getShippingPlanAggregate(selectedDetailIds);
if (result.success && result.data) {
setAggregate(result.data);
const plans: Record<string, NewPlanRow[]> = {};
for (const partCode of Object.keys(result.data)) {
plans[partCode] = [makeNewRow(partCode, result.data[partCode].orders[0]?.sourceId || "")];
}
setNewPlans(plans);
}
} catch (err) {
console.error("출하계획 집계 조회 실패:", err);
toast.error("출하계획 정보를 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
load();
}, [open, selectedDetailIds]);
const makeNewRow = (partCode: string, sourceId: string): NewPlanRow => ({
_id: `new_${Date.now()}_${Math.random()}`,
sourceId, partCode, planQty: "", planDate: "", planTime: "", shipInfo: "",
});
const addRow = (partCode: string) => {
const sourceId = aggregate[partCode]?.orders[0]?.sourceId || "";
setNewPlans((prev) => ({ ...prev, [partCode]: [...(prev[partCode] || []), makeNewRow(partCode, sourceId)] }));
};
const removeRow = (partCode: string, rowId: string) => {
setNewPlans((prev) => ({ ...prev, [partCode]: (prev[partCode] || []).filter((r) => r._id !== rowId) }));
};
const updateRow = (partCode: string, rowId: string, field: keyof NewPlanRow, value: string) => {
// planQty 변경 시 총수주잔량 초과 검증
if (field === "planQty" && value) {
const agg = aggregate[partCode];
if (agg) {
const maxQty = agg.totalBalance - agg.totalPlanQty; // 기존 계획 제외한 잔여 가능량
const otherSum = (newPlans[partCode] || [])
.filter((r) => r._id !== rowId)
.reduce((sum, r) => sum + (Number(r.planQty) || 0), 0);
const remaining = maxQty - otherSum;
if (Number(value) > remaining) {
toast.error(`출하계획량이 잔여 가능량(${remaining.toLocaleString()})을 초과할 수 없습니다.`);
value = String(Math.max(0, remaining));
}
}
}
setNewPlans((prev) => ({
...prev,
[partCode]: (prev[partCode] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r),
}));
};
const totalNewPlans = Object.values(newPlans).flat().filter((r) => r.planQty && Number(r.planQty) > 0).length;
const handleSave = async () => {
const plans: BatchSavePlan[] = [];
for (const rows of Object.values(newPlans)) {
for (const row of rows) {
const qty = Number(row.planQty);
if (qty <= 0) continue;
plans.push({ sourceId: row.sourceId, planQty: qty, planDate: row.planDate || undefined });
}
}
if (plans.length === 0) { toast.error("출하계획 수량을 입력해주세요."); return; }
setSaving(true);
try {
const result = await batchSaveShippingPlans(plans, "detail");
if (result.success) {
toast.success(`출하계획 ${plans.length}건이 등록되었습니다.`);
onSuccess?.();
onOpenChange(false);
} else {
toast.error(result.message || "등록 실패");
}
} catch { toast.error("등록 실패"); } finally { setSaving(false); }
};
const partCodes = Object.keys(aggregate);
return (
<FullscreenDialog
open={open}
onOpenChange={onOpenChange}
title={<><Truck className="h-5 w-5 inline mr-2" /> </>}
description={<> : <strong>{totalNewPlans}</strong></>}
defaultMaxWidth="max-w-[1200px]"
footer={
<div className="flex items-center justify-between w-full">
<span className="text-sm text-muted-foreground">💡 </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button onClick={handleSave} disabled={saving || totalNewPlans === 0}>
{saving && <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />}
</Button>
</div>
</div>
}
>
<div className="flex-1 overflow-auto space-y-6 py-2 px-1">
{loading ? (
<div className="flex items-center justify-center h-40"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
) : partCodes.length === 0 ? (
<div className="text-center text-muted-foreground py-10"> .</div>
) : partCodes.map((partCode) => {
const agg = aggregate[partCode];
const orders = agg.orders || [];
const existingPlans = agg.existingPlans || [];
const rows = newPlans[partCode] || [];
const firstOrder = orders[0];
return (
<div key={partCode} className="border rounded-xl overflow-hidden bg-card">
{/* 품목 헤더 */}
<div className="flex items-center justify-between px-5 py-3 bg-muted/30 border-b">
<div className="flex items-center gap-3">
<Package className="h-7 w-7 text-muted-foreground shrink-0" />
<div>
<div className="text-[10px] text-muted-foreground"></div>
<div className="font-bold">{partCode}</div>
</div>
</div>
<div className="text-right">
<div className="text-[10px] text-muted-foreground"></div>
<div className="font-bold">{firstOrder?.partName || "-"}</div>
</div>
</div>
{/* 통계 카드 (신규 입력량 반영) */}
{(() => {
const newQtySum = (newPlans[partCode] || []).reduce((sum, r) => sum + (Number(r.planQty) || 0), 0);
const totalPlanQty = agg.totalPlanQty + newQtySum;
const availableStock = agg.currentStock - totalPlanQty;
return (
<div className="flex gap-2 px-4 py-2.5">
<StatCard label="총수주잔량" value={agg.totalBalance} color="bg-violet-100 text-violet-900 dark:bg-violet-900/30 dark:text-violet-200" />
<StatCard label="총출하계획량" value={totalPlanQty} color="bg-blue-100 text-blue-900 dark:bg-blue-900/30 dark:text-blue-200" />
<StatCard label="현재고" value={agg.currentStock} color="bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200" />
<StatCard label="가용재고" value={availableStock} color="bg-emerald-100 text-emerald-900 dark:bg-emerald-900/30 dark:text-emerald-200" />
<StatCard label="생산중수량" value={agg.inProductionQty} color="bg-cyan-100 text-cyan-900 dark:bg-cyan-900/30 dark:text-cyan-200" />
</div>
);
})()}
{/* 테이블 — overflow-x-auto로 겹침 방지 */}
<div className="px-4 pb-3">
<table className="w-full text-sm table-fixed">
<colgroup>
<col style={{ width: 50 }} />
<col style={{ width: "16%" }} />
<col style={{ width: "9%" }} />
<col style={{ width: "7%" }} />
<col style={{ width: "6%" }} />
<col style={{ width: "7%" }} />
<col style={{ width: "17%" }} />
<col style={{ width: "10%" }} />
<col style={{ width: "14%" }} />
<col style={{ width: 42 }} />
</colgroup>
<thead>
<tr className="border-b text-xs text-muted-foreground">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-center"></th>
</tr>
</thead>
<tbody>
{/* 기존 계획 */}
{existingPlans.map((plan) => {
const order = orders.find((o) => o.sourceId === plan.sourceId);
return (
<tr key={`ex-${plan.id}`} className="border-b border-dashed">
<td className="py-2"><Badge variant="secondary" className="text-[10px]"></Badge></td>
<td className="py-2 text-xs truncate max-w-[150px]">{order?.orderNo || "-"}</td>
<td className="py-2 text-xs">{order?.partnerName || "-"}</td>
<td className="py-2 text-xs">{order?.dueDate?.split("T")[0] || "-"}</td>
<td className="py-2 text-right text-xs">{order?.balanceQty?.toLocaleString() || "-"}</td>
<td className="py-2 text-center text-xs">{plan.planQty.toLocaleString()}</td>
<td className="py-2 text-center text-xs">{plan.planDate?.split("T")[0] || "-"}</td>
<td className="py-2 text-center text-xs">-</td>
<td className="py-2 text-xs">{plan.shipmentPlanNo || "-"}</td>
<td></td>
</tr>
);
})}
{/* 신규 입력 행 */}
{rows.map((row, rowIdx) => {
const order = orders.find((o) => o.sourceId === row.sourceId) || orders[0];
return (
<tr key={row._id} className="border-b">
<td className="py-2.5"><Badge className="text-[10px] bg-primary"></Badge></td>
<td className="py-2.5 text-xs truncate">{order?.orderNo || "-"}</td>
<td className="py-2.5 text-xs">{order?.partnerName || "-"}</td>
<td className="py-2.5 text-xs">{order?.dueDate?.split("T")[0] || "-"}</td>
<td className="py-2.5 text-right text-xs">{order?.balanceQty?.toLocaleString() || "-"}</td>
<td className="py-2 px-1">
<Input value={row.planQty}
onChange={(e) => {
const raw = e.target.value.replace(/[^0-9]/g, "");
updateRow(partCode, row._id, "planQty", raw ? String(Number(raw)) : "");
}}
className="h-8 text-xs text-center" placeholder="0" />
</td>
<td className="py-2 px-1">
<FormDatePicker value={row.planDate}
onChange={(v) => updateRow(partCode, row._id, "planDate", v)} placeholder="계획일" />
</td>
<td className="py-2 px-1">
<TimePicker value={row.planTime}
onChange={(v) => updateRow(partCode, row._id, "planTime", v)} />
</td>
<td className="py-2 px-1">
<Input value={row.shipInfo}
onChange={(e) => updateRow(partCode, row._id, "shipInfo", e.target.value)}
className="h-8 text-xs" placeholder="출하정보 입력" />
</td>
<td className="py-2 text-center">
{rowIdx === rows.length - 1 ? (
<Button variant="outline" size="sm" className="h-7 w-7 p-0 rounded-full"
onClick={() => addRow(partCode)}><Plus className="h-3.5 w-3.5" /></Button>
) : (
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive"
onClick={() => removeRow(partCode, row._id)}><X className="h-3.5 w-3.5" /></Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})}
</div>
</FullscreenDialog>
);
}

View File

@ -0,0 +1,575 @@
"use client";
/**
* ShippingPlanModal
*
* shipping-plan Dialog .
* orderNo prop이 .
*/
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Search, X, Save, RotateCcw, Loader2, Maximize2, Minimize2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
getShipmentPlanList,
updateShipmentPlan,
type ShipmentPlanListItem,
} from "@/lib/api/shipping";
interface ShippingPlanModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
orderNo?: string;
onUpdated?: () => void;
}
const STATUS_OPTIONS = [
{ value: "all", label: "전체" },
{ value: "READY", label: "준비" },
{ value: "CONFIRMED", label: "확정" },
{ value: "SHIPPING", label: "출하중" },
{ value: "COMPLETED", label: "완료" },
{ value: "CANCEL_REQUEST", label: "취소요청" },
{ value: "CANCELLED", label: "취소완료" },
];
const getStatusLabel = (status: string) => {
const found = STATUS_OPTIONS.find((o) => o.value === status);
return found?.label || status;
};
const getStatusColor = (status: string) => {
switch (status) {
case "READY": return "bg-blue-100 text-blue-800 border-blue-200";
case "CONFIRMED": return "bg-indigo-100 text-indigo-800 border-indigo-200";
case "SHIPPING": return "bg-amber-100 text-amber-800 border-amber-200";
case "COMPLETED": return "bg-emerald-100 text-emerald-800 border-emerald-200";
case "CANCEL_REQUEST": return "bg-rose-100 text-rose-800 border-rose-200";
case "CANCELLED": return "bg-slate-100 text-slate-800 border-slate-200";
default: return "bg-gray-100 text-gray-800 border-gray-200";
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
};
const formatNumber = (val: string | number) => {
const num = Number(val);
return isNaN(num) ? "0" : num.toLocaleString();
};
export function ShippingPlanModal({ open, onOpenChange, orderNo, onUpdated }: ShippingPlanModalProps) {
const [data, setData] = useState<ShipmentPlanListItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [checkedIds, setCheckedIds] = useState<number[]>([]);
const [isFullscreen, setIsFullscreen] = useState(false);
// 검색
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
const [searchStatus, setSearchStatus] = useState("all");
const [searchCustomer, setSearchCustomer] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
// 상세 패널 편집
const [editPlanQty, setEditPlanQty] = useState("");
const [editPlanDate, setEditPlanDate] = useState("");
const [editMemo, setEditMemo] = useState("");
const [isDetailChanged, setIsDetailChanged] = useState(false);
const [saving, setSaving] = useState(false);
// 모달 열릴 때 초기화
useEffect(() => {
if (!open) return;
const today = new Date();
const threeMonthsAgo = new Date(today);
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const oneMonthLater = new Date(today);
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
setSearchDateTo(oneMonthLater.toISOString().split("T")[0]);
setSearchStatus("all");
setSearchCustomer("");
setSearchKeyword(orderNo || "");
setSelectedId(null);
setCheckedIds([]);
setIsDetailChanged(false);
setIsFullscreen(false);
}, [open, orderNo]);
// 데이터 조회
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: any = {};
if (searchDateFrom) params.dateFrom = searchDateFrom;
if (searchDateTo) params.dateTo = searchDateTo;
if (searchStatus !== "all") params.status = searchStatus;
if (searchCustomer.trim()) params.customer = searchCustomer.trim();
if (searchKeyword.trim()) params.keyword = searchKeyword.trim();
const result = await getShipmentPlanList(params);
if (result.success) {
setData(result.data || []);
}
} catch (err) {
console.error("출하계획 조회 실패:", err);
} finally {
setLoading(false);
}
}, [searchDateFrom, searchDateTo, searchStatus, searchCustomer, searchKeyword]);
// 모달 열리고 날짜 세팅 완료 후 자동 조회
useEffect(() => {
if (open && searchDateFrom && searchDateTo) {
fetchData();
}
}, [open, searchDateFrom, searchDateTo]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSearch = () => fetchData();
const handleResetSearch = () => {
setSearchStatus("all");
setSearchCustomer("");
setSearchKeyword(orderNo || "");
const today = new Date();
const threeMonthsAgo = new Date(today);
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const oneMonthLater = new Date(today);
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
setSearchDateTo(oneMonthLater.toISOString().split("T")[0]);
};
const selectedPlan = useMemo(() => data.find((p) => p.id === selectedId), [data, selectedId]);
const groupedData = useMemo(() => {
const orderMap = new Map<string, ShipmentPlanListItem[]>();
const orderKeys: string[] = [];
data.forEach((plan) => {
const key = plan.order_no || `_no_order_${plan.id}`;
if (!orderMap.has(key)) {
orderMap.set(key, []);
orderKeys.push(key);
}
orderMap.get(key)!.push(plan);
});
return orderKeys.map((key) => ({
orderNo: key,
plans: orderMap.get(key)!,
}));
}, [data]);
const handleRowClick = (plan: ShipmentPlanListItem) => {
if (isDetailChanged && selectedId !== plan.id) {
if (!confirm("변경사항이 있습니다. 저장하지 않고 이동하시겠습니까?")) return;
}
setSelectedId(plan.id);
setEditPlanQty(String(Number(plan.plan_qty)));
setEditPlanDate(plan.plan_date ? plan.plan_date.split("T")[0] : "");
setEditMemo(plan.memo || "");
setIsDetailChanged(false);
};
const handleCheckAll = (checked: boolean) => {
if (checked) {
setCheckedIds(data.filter((p) => p.status !== "CANCELLED").map((p) => p.id));
} else {
setCheckedIds([]);
}
};
const handleCheck = (id: number, checked: boolean) => {
if (checked) {
setCheckedIds((prev) => [...prev, id]);
} else {
setCheckedIds((prev) => prev.filter((i) => i !== id));
}
};
const handleSaveDetail = async () => {
if (!selectedId || !selectedPlan) return;
const qty = Number(editPlanQty);
if (qty <= 0) {
alert("계획수량은 0보다 커야 합니다.");
return;
}
if (!editPlanDate) {
alert("출하계획일을 입력해주세요.");
return;
}
setSaving(true);
try {
const result = await updateShipmentPlan(selectedId, {
planQty: qty,
planDate: editPlanDate,
memo: editMemo,
});
if (result.success) {
setIsDetailChanged(false);
alert("저장되었습니다.");
fetchData();
onUpdated?.();
} else {
alert(result.message || "저장 실패");
}
} catch (err: any) {
alert(err.message || "저장 중 오류 발생");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"overflow-hidden flex flex-col transition-all duration-200 p-0",
isFullscreen
? "max-w-[100vw] max-h-[100vh] w-[100vw] h-[100vh] rounded-none"
: "max-w-6xl h-[85vh]"
)}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader className="px-4 pt-4 pb-2 shrink-0">
<div className="flex items-center justify-between pr-8">
<div>
<DialogTitle> </DialogTitle>
<DialogDescription>
{orderNo ? `수주번호 ${orderNo}의 출하계획` : "전체 출하계획을 관리합니다."}
</DialogDescription>
</div>
<Button
variant="ghost" size="sm" className="h-8 w-8 p-0"
onClick={() => setIsFullscreen((prev) => !prev)}
title={isFullscreen ? "기본 크기" : "전체 화면"}
>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</Button>
</div>
</DialogHeader>
{/* 검색 영역 */}
<div className="px-4 pb-2 shrink-0 flex flex-wrap items-end gap-3 border-b">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground"></Label>
<div className="flex items-center gap-1.5">
<Input type="date" className="w-[130px] h-8 text-xs" value={searchDateFrom}
onChange={(e) => setSearchDateFrom(e.target.value)} />
<span className="text-muted-foreground text-xs">~</span>
<Input type="date" className="w-[130px] h-8 text-xs" value={searchDateTo}
onChange={(e) => setSearchDateTo(e.target.value)} />
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="w-[100px] h-8 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground"></Label>
<Input placeholder="거래처" className="w-[120px] h-8 text-xs" value={searchCustomer}
onChange={(e) => setSearchCustomer(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">/</Label>
<Input placeholder="수주번호/품목" className="w-[160px] h-8 text-xs" value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
</div>
<div className="flex items-center gap-1.5 pb-1">
<Button size="sm" className="h-8 text-xs" onClick={handleSearch} disabled={loading}>
{loading ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Search className="w-3.5 h-3.5 mr-1" />}
</Button>
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={handleResetSearch}>
<RotateCcw className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
{/* 목록 + 상세 패널 */}
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={selectedId ? 60 : 100} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Badge variant="secondary" className="font-normal">{data.length}</Badge>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[36px] text-center">
<Checkbox
checked={data.length > 0 && checkedIds.length === data.filter((p) => p.status !== "CANCELLED").length}
onCheckedChange={handleCheckAll}
/>
</TableHead>
<TableHead className="w-[130px]"></TableHead>
<TableHead className="w-[90px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[90px] text-center"></TableHead>
<TableHead className="w-[70px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupedData.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="h-32 text-center text-muted-foreground">
.
</TableCell>
</TableRow>
) : (
groupedData.map((group) =>
group.plans.map((plan, planIdx) => (
<TableRow
key={plan.id}
className={cn(
"cursor-pointer hover:bg-muted/50 transition-colors",
selectedId === plan.id && "bg-primary/5",
plan.status === "CANCELLED" && "opacity-60 bg-slate-50",
planIdx === 0 && "border-t-2 border-t-border"
)}
onClick={() => handleRowClick(plan)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{planIdx === 0 && (
<Checkbox
checked={group.plans.every((p) => checkedIds.includes(p.id))}
onCheckedChange={(c) => {
if (c) {
setCheckedIds((prev) => [...new Set([...prev, ...group.plans.filter((p) => p.status !== "CANCELLED").map((p) => p.id)])]);
} else {
setCheckedIds((prev) => prev.filter((id) => !group.plans.some((p) => p.id === id)));
}
}}
/>
)}
</TableCell>
<TableCell className="font-medium text-xs">
{planIdx === 0 ? (plan.order_no || "-") : ""}
</TableCell>
<TableCell className="text-center text-xs">
{planIdx === 0 ? formatDate(plan.due_date) : ""}
</TableCell>
<TableCell className="text-xs">
{planIdx === 0 ? (plan.customer_name || "-") : ""}
</TableCell>
<TableCell className="text-muted-foreground text-xs">{plan.part_code || "-"}</TableCell>
<TableCell className="text-xs font-medium">{plan.part_name || "-"}</TableCell>
<TableCell className="text-right text-xs">{formatNumber(plan.order_qty)}</TableCell>
<TableCell className="text-right text-xs font-semibold text-primary">{formatNumber(plan.plan_qty)}</TableCell>
<TableCell className="text-center text-xs">{formatDate(plan.plan_date)}</TableCell>
<TableCell className="text-center">
<span className={cn("px-1.5 py-0.5 rounded-full text-[10px] font-medium border", getStatusColor(plan.status))}>
{getStatusLabel(plan.status)}
</span>
</TableCell>
</TableRow>
))
)
)}
</TableBody>
</Table>
)}
</div>
</div>
</ResizablePanel>
{/* 상세 패널 */}
{selectedId && selectedPlan && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full bg-card">
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0">
<span className="font-semibold text-sm">
{selectedPlan.shipment_plan_no || `#${selectedPlan.id}`}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleSaveDetail}
disabled={!isDetailChanged || saving}
className={cn("h-8", isDetailChanged ? "bg-primary" : "bg-muted text-muted-foreground")}
>
{saving ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Save className="w-3.5 h-3.5 mr-1" />}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setSelectedId(null)}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto p-4 space-y-5">
{/* 기본 정보 */}
<section>
<h3 className="text-sm font-semibold mb-2"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5 text-sm">
<div>
<span className="text-muted-foreground text-xs block mb-0.5"></span>
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border inline-block", getStatusColor(selectedPlan.status))}>
{getStatusLabel(selectedPlan.status)}
</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-0.5"></span>
<span>{selectedPlan.order_no || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-0.5"></span>
<span>{selectedPlan.customer_name || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-0.5"></span>
<span>{formatDate(selectedPlan.due_date)}</span>
</div>
</div>
</section>
{/* 품목 정보 */}
<section>
<h3 className="text-sm font-semibold mb-2"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5 text-sm bg-muted/30 p-3 rounded-md border border-border/50">
<div>
<span className="text-muted-foreground text-xs block mb-0.5"></span>
<span>{selectedPlan.part_code || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-0.5"></span>
<span className="font-medium">{selectedPlan.part_name || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-0.5"></span>
<span>{selectedPlan.spec || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-0.5"></span>
<span>{selectedPlan.material || "-"}</span>
</div>
</div>
</section>
{/* 수량 정보 */}
<section>
<h3 className="text-sm font-semibold mb-2"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<span className="text-muted-foreground text-xs block mb-0.5"></span>
<span>{formatNumber(selectedPlan.order_qty)}</span>
</div>
<div>
<Label className="text-muted-foreground text-xs block mb-0.5"></Label>
<Input
type="number" className="h-8 text-sm"
value={editPlanQty}
onChange={(e) => { setEditPlanQty(e.target.value); setIsDetailChanged(true); }}
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
/>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-0.5"></span>
<span>{formatNumber(selectedPlan.shipped_qty)}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-0.5"></span>
<span className={cn("font-semibold",
(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty)) > 0
? "text-destructive"
: "text-emerald-600"
)}>
{formatNumber(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty))}
</span>
</div>
</div>
</section>
{/* 출하 정보 */}
<section>
<h3 className="text-sm font-semibold mb-2"> </h3>
<div className="grid grid-cols-1 gap-y-3 text-sm">
<div>
<Label className="text-muted-foreground text-xs block mb-0.5"></Label>
<Input
type="date" className="h-8 text-sm"
value={editPlanDate}
onChange={(e) => { setEditPlanDate(e.target.value); setIsDetailChanged(true); }}
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
/>
</div>
<div>
<Label className="text-muted-foreground text-xs block mb-0.5"></Label>
<Textarea
className="min-h-[70px] resize-y text-sm"
value={editMemo}
onChange={(e) => { setEditMemo(e.target.value); setIsDetailChanged(true); }}
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
placeholder="비고 입력"
/>
</div>
</div>
</section>
{/* 등록 정보 */}
<section>
<h3 className="text-sm font-semibold mb-2"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5 text-sm text-muted-foreground">
<div>
<span className="text-xs block mb-0.5"></span>
<span className="text-foreground">{selectedPlan.created_by || "-"}</span>
</div>
<div>
<span className="text-xs block mb-0.5"></span>
<span className="text-foreground">{selectedPlan.created_date ? new Date(selectedPlan.created_date).toLocaleString("ko-KR") : "-"}</span>
</div>
</div>
</section>
</div>
</div>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,825 @@
"use client";
/**
* TimelineScheduler
*
* :
* - (/) Y축, X축
* - (//)
* - (//)
* -
* - (/ )
* -
* -
* - ()
* - +
* - ( )
*/
import React, { useState, useRef, useCallback, useMemo, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
ChevronLeft,
ChevronRight,
CalendarDays,
Loader2,
Diamond,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ─── 타입 정의 ───
export interface TimelineResource {
id: string;
label: string;
subLabel?: string;
}
export interface TimelineEvent {
id: string | number;
resourceId: string;
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
label?: string;
status?: string;
progress?: number; // 0~100
isMilestone?: boolean;
data?: any;
}
export type ZoomLevel = "day" | "week" | "month";
export interface StatusColor {
key: string;
label: string;
bgClass: string; // tailwind gradient class e.g. "from-blue-500 to-blue-600"
}
export interface TimelineSchedulerProps {
resources: TimelineResource[];
events: TimelineEvent[];
/** 타임라인 시작 기준일 (기본: 오늘) */
startDate?: Date;
/** 줌 레벨 (기본: week) */
zoomLevel?: ZoomLevel;
onZoomChange?: (zoom: ZoomLevel) => void;
/** 이벤트 바 클릭 */
onEventClick?: (event: TimelineEvent) => void;
/** 드래그 이동 완료 */
onEventMove?: (eventId: string | number, newStartDate: string, newEndDate: string) => void;
/** 리사이즈 완료 */
onEventResize?: (eventId: string | number, newStartDate: string, newEndDate: string) => void;
/** 상태별 색상 배열 */
statusColors?: StatusColor[];
/** 진행률 바 표시 여부 */
showProgress?: boolean;
/** 마일스톤 표시 여부 */
showMilestones?: boolean;
/** 오늘 세로선 표시 */
showTodayLine?: boolean;
/** 범례 표시 */
showLegend?: boolean;
/** 충돌 감지 */
conflictDetection?: boolean;
/** 로딩 상태 */
loading?: boolean;
/** 데이터 없을 때 메시지 */
emptyMessage?: string;
/** 데이터 없을 때 아이콘 */
emptyIcon?: React.ReactNode;
/** 리소스 열 너비 (px) */
resourceWidth?: number;
/** 행 높이 (px) */
rowHeight?: number;
}
// ─── 기본값 ───
const DEFAULT_STATUS_COLORS: StatusColor[] = [
{ key: "planned", label: "계획", bgClass: "from-blue-500 to-blue-600" },
{ key: "work-order", label: "지시", bgClass: "from-amber-500 to-amber-600" },
{ key: "in-progress", label: "진행", bgClass: "from-emerald-500 to-emerald-600" },
{ key: "completed", label: "완료", bgClass: "from-gray-400 to-gray-500" },
];
const ZOOM_CONFIG: Record<ZoomLevel, { cellWidth: number; spanDays: number; navStep: number }> = {
day: { cellWidth: 60, spanDays: 28, navStep: 7 },
week: { cellWidth: 36, spanDays: 56, navStep: 14 },
month: { cellWidth: 16, spanDays: 90, navStep: 30 },
};
// ─── 유틸리티 함수 ───
/** YYYY-MM-DD 문자열로 변환 */
function toDateStr(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** 날짜 문자열을 Date로 (시간 0시) */
function parseDate(s: string): Date {
const [y, m, d] = s.split("T")[0].split("-").map(Number);
return new Date(y, m - 1, d);
}
/** 두 날짜 사이의 일 수 차이 */
function diffDays(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
}
/** 날짜에 일 수 더하기 */
function addDays(d: Date, n: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
function isWeekend(d: Date): boolean {
return d.getDay() === 0 || d.getDay() === 6;
}
function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
const DAY_NAMES = ["일", "월", "화", "수", "목", "금", "토"];
const MONTH_NAMES = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"];
// ─── 충돌 감지 ───
function detectConflicts(events: TimelineEvent[]): Set<string | number> {
const conflictIds = new Set<string | number>();
const byResource = new Map<string, TimelineEvent[]>();
for (const ev of events) {
if (ev.isMilestone) continue;
if (!byResource.has(ev.resourceId)) byResource.set(ev.resourceId, []);
byResource.get(ev.resourceId)!.push(ev);
}
for (const [, resEvents] of byResource) {
for (let i = 0; i < resEvents.length; i++) {
for (let j = i + 1; j < resEvents.length; j++) {
const a = resEvents[i];
const b = resEvents[j];
const aStart = parseDate(a.startDate).getTime();
const aEnd = parseDate(a.endDate).getTime();
const bStart = parseDate(b.startDate).getTime();
const bEnd = parseDate(b.endDate).getTime();
if (aStart <= bEnd && bStart <= aEnd) {
conflictIds.add(a.id);
conflictIds.add(b.id);
}
}
}
}
return conflictIds;
}
// ─── 메인 컴포넌트 ───
export default function TimelineScheduler({
resources,
events,
startDate: propStartDate,
zoomLevel: propZoom,
onZoomChange,
onEventClick,
onEventMove,
onEventResize,
statusColors = DEFAULT_STATUS_COLORS,
showProgress = true,
showMilestones = true,
showTodayLine = true,
showLegend = true,
conflictDetection = true,
loading = false,
emptyMessage = "데이터가 없습니다",
emptyIcon,
resourceWidth = 160,
rowHeight = 48,
}: TimelineSchedulerProps) {
// ── 상태 ──
const [zoom, setZoom] = useState<ZoomLevel>(propZoom || "week");
const [baseDate, setBaseDate] = useState<Date>(() => {
const d = propStartDate || new Date();
d.setHours(0, 0, 0, 0);
return d;
});
// 드래그/리사이즈 상태
const [dragState, setDragState] = useState<{
eventId: string | number;
mode: "move" | "resize-left" | "resize-right";
origStartDate: string;
origEndDate: string;
startX: number;
currentOffsetDays: number;
} | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// 줌 레벨 동기화
useEffect(() => {
if (propZoom && propZoom !== zoom) setZoom(propZoom);
}, [propZoom]);
const config = ZOOM_CONFIG[zoom];
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
// 날짜 배열 생성
const dates = useMemo(() => {
const arr: Date[] = [];
for (let i = 0; i < config.spanDays; i++) {
arr.push(addDays(baseDate, i));
}
return arr;
}, [baseDate, config.spanDays]);
const totalWidth = config.cellWidth * config.spanDays;
// 충돌 ID 집합
const conflictIds = useMemo(() => {
return conflictDetection ? detectConflicts(events) : new Set<string | number>();
}, [events, conflictDetection]);
// 리소스별 이벤트 그룹
const eventsByResource = useMemo(() => {
const map = new Map<string, TimelineEvent[]>();
for (const r of resources) map.set(r.id, []);
for (const ev of events) {
if (!map.has(ev.resourceId)) map.set(ev.resourceId, []);
map.get(ev.resourceId)!.push(ev);
}
return map;
}, [resources, events]);
// 같은 리소스 내 겹치는 이벤트들의 행(lane) 계산
const eventLanes = useMemo(() => {
const laneMap = new Map<string | number, number>();
for (const [, resEvents] of eventsByResource) {
// 시작일 기준 정렬
const sorted = [...resEvents].sort(
(a, b) => parseDate(a.startDate).getTime() - parseDate(b.startDate).getTime()
);
const lanes: { endTime: number }[] = [];
for (const ev of sorted) {
if (ev.isMilestone) {
laneMap.set(ev.id, 0);
continue;
}
const evStart = parseDate(ev.startDate).getTime();
const evEnd = parseDate(ev.endDate).getTime();
let placed = false;
for (let l = 0; l < lanes.length; l++) {
if (evStart > lanes[l].endTime) {
lanes[l].endTime = evEnd;
laneMap.set(ev.id, l);
placed = true;
break;
}
}
if (!placed) {
laneMap.set(ev.id, lanes.length);
lanes.push({ endTime: evEnd });
}
}
}
return laneMap;
}, [eventsByResource]);
// 리소스별 최대 lane 수 -> 행 높이 결정
const resourceLaneCounts = useMemo(() => {
const map = new Map<string, number>();
for (const [resId, resEvents] of eventsByResource) {
let maxLane = 0;
for (const ev of resEvents) {
const lane = eventLanes.get(ev.id) || 0;
maxLane = Math.max(maxLane, lane);
}
map.set(resId, resEvents.length > 0 ? maxLane + 1 : 1);
}
return map;
}, [eventsByResource, eventLanes]);
// ── 줌/네비게이션 핸들러 ──
const handleZoom = useCallback(
(z: ZoomLevel) => {
setZoom(z);
onZoomChange?.(z);
},
[onZoomChange]
);
const handleNavPrev = useCallback(() => {
setBaseDate((prev) => addDays(prev, -config.navStep));
}, [config.navStep]);
const handleNavNext = useCallback(() => {
setBaseDate((prev) => addDays(prev, config.navStep));
}, [config.navStep]);
const handleNavToday = useCallback(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
setBaseDate(d);
}, []);
// ── 이벤트 바 위치 계산 ──
const getBarStyle = useCallback(
(startDateStr: string, endDateStr: string) => {
const evStart = parseDate(startDateStr);
const evEnd = parseDate(endDateStr);
const firstDate = dates[0];
const lastDate = dates[dates.length - 1];
// 완전히 범위 밖이면 표시하지 않음
if (evEnd < firstDate || evStart > lastDate) return null;
const startIdx = Math.max(0, diffDays(firstDate, evStart));
const endIdx = Math.min(config.spanDays - 1, diffDays(firstDate, evEnd));
const left = startIdx * config.cellWidth;
const width = (endIdx - startIdx + 1) * config.cellWidth;
return { left, width };
},
[dates, config.cellWidth, config.spanDays]
);
// ── 드래그/리사이즈 핸들러 ──
const handleMouseDown = useCallback(
(
e: React.MouseEvent,
eventId: string | number,
mode: "move" | "resize-left" | "resize-right",
startDate: string,
endDate: string
) => {
e.preventDefault();
e.stopPropagation();
setDragState({
eventId,
mode,
origStartDate: startDate,
origEndDate: endDate,
startX: e.clientX,
currentOffsetDays: 0,
});
},
[]
);
// mousemove / mouseup (document-level)
useEffect(() => {
if (!dragState) return;
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - dragState.startX;
const dayOffset = Math.round(dx / config.cellWidth);
setDragState((prev) => (prev ? { ...prev, currentOffsetDays: dayOffset } : null));
};
const handleMouseUp = (e: MouseEvent) => {
if (!dragState) return;
const dx = e.clientX - dragState.startX;
const dayOffset = Math.round(dx / config.cellWidth);
if (dayOffset !== 0) {
const origStart = parseDate(dragState.origStartDate);
const origEnd = parseDate(dragState.origEndDate);
if (dragState.mode === "move") {
const newStart = toDateStr(addDays(origStart, dayOffset));
const newEnd = toDateStr(addDays(origEnd, dayOffset));
onEventMove?.(dragState.eventId, newStart, newEnd);
} else if (dragState.mode === "resize-left") {
const newStart = toDateStr(addDays(origStart, dayOffset));
const newEnd = dragState.origEndDate.split("T")[0];
// 시작이 종료를 넘지 않도록
if (parseDate(newStart) <= parseDate(newEnd)) {
onEventResize?.(dragState.eventId, newStart, newEnd);
}
} else if (dragState.mode === "resize-right") {
const newStart = dragState.origStartDate.split("T")[0];
const newEnd = toDateStr(addDays(origEnd, dayOffset));
if (parseDate(newStart) <= parseDate(newEnd)) {
onEventResize?.(dragState.eventId, newStart, newEnd);
}
}
}
setDragState(null);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [dragState, config.cellWidth, onEventMove, onEventResize]);
// 드래그 중인 이벤트의 현재 표시 위치 계산
const getDraggedBarStyle = useCallback(
(event: TimelineEvent) => {
if (!dragState || dragState.eventId !== event.id) return null;
const origStart = parseDate(dragState.origStartDate);
const origEnd = parseDate(dragState.origEndDate);
const offset = dragState.currentOffsetDays;
let newStart: Date, newEnd: Date;
if (dragState.mode === "move") {
newStart = addDays(origStart, offset);
newEnd = addDays(origEnd, offset);
} else if (dragState.mode === "resize-left") {
newStart = addDays(origStart, offset);
newEnd = origEnd;
if (newStart > newEnd) newStart = newEnd;
} else {
newStart = origStart;
newEnd = addDays(origEnd, offset);
if (newEnd < newStart) newEnd = newStart;
}
return getBarStyle(toDateStr(newStart), toDateStr(newEnd));
},
[dragState, getBarStyle]
);
// ── 오늘 라인 위치 ──
const todayLineLeft = useMemo(() => {
if (!showTodayLine || dates.length === 0) return null;
const firstDate = dates[0];
const lastDate = dates[dates.length - 1];
if (today < firstDate || today > lastDate) return null;
const idx = diffDays(firstDate, today);
return idx * config.cellWidth + config.cellWidth / 2;
}, [dates, today, config.cellWidth, showTodayLine]);
// ── 상태 색상 매핑 ──
const getStatusColor = useCallback(
(status?: string) => {
if (!status) return statusColors[0]?.bgClass || "from-blue-500 to-blue-600";
const found = statusColors.find((c) => c.key === status);
return found?.bgClass || statusColors[0]?.bgClass || "from-blue-500 to-blue-600";
},
[statusColors]
);
// ── 날짜 헤더 그룹 ──
const dateGroups = useMemo(() => {
if (zoom === "day") {
return null; // day 뷰에서는 상위 그룹 없이 바로 날짜 표시
}
// week / month 뷰: 월 단위로 그룹
const groups: { label: string; span: number; startIdx: number }[] = [];
let currentMonth = -1;
let currentYear = -1;
for (let i = 0; i < dates.length; i++) {
const d = dates[i];
if (d.getMonth() !== currentMonth || d.getFullYear() !== currentYear) {
groups.push({
label: `${d.getFullYear()}${MONTH_NAMES[d.getMonth()]}`,
span: 1,
startIdx: i,
});
currentMonth = d.getMonth();
currentYear = d.getFullYear();
} else {
groups[groups.length - 1].span++;
}
}
return groups;
}, [dates, zoom]);
// ── 렌더링 ──
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (resources.length === 0 || events.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
{emptyIcon}
<p className="text-base font-medium mb-2 mt-3">{emptyMessage}</p>
</div>
);
}
const barHeight = 24;
const barGap = 2;
return (
<div className="flex flex-col gap-3">
{/* 컨트롤 바 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={handleNavPrev}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={handleNavToday}>
<CalendarDays className="mr-1 h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={handleNavNext}>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground ml-2">
{toDateStr(dates[0])} ~ {toDateStr(dates[dates.length - 1])}
</span>
</div>
<div className="flex items-center gap-1">
{(["day", "week", "month"] as ZoomLevel[]).map((z) => (
<Button
key={z}
size="sm"
variant={zoom === z ? "default" : "outline"}
className="h-7 text-xs px-3"
onClick={() => handleZoom(z)}
>
{z === "day" ? "일" : z === "week" ? "주" : "월"}
</Button>
))}
</div>
</div>
{/* 범례 */}
{showLegend && (
<div className="flex items-center gap-4 flex-wrap text-xs">
<span className="font-semibold text-muted-foreground">:</span>
{statusColors.map((sc) => (
<div key={sc.key} className="flex items-center gap-1.5">
<div className={cn("h-3.5 w-5 rounded bg-gradient-to-br", sc.bgClass)} />
<span>{sc.label}</span>
</div>
))}
{showMilestones && (
<div className="flex items-center gap-1.5">
<Diamond className="h-3.5 w-3.5 text-purple-500 fill-purple-500" />
<span></span>
</div>
)}
{conflictDetection && (
<div className="flex items-center gap-1.5">
<div className="h-3.5 w-5 rounded border-2 border-red-500 bg-red-500/20" />
<span></span>
</div>
)}
</div>
)}
{/* 타임라인 본체 */}
<div className="rounded-lg border bg-background overflow-hidden">
<div
ref={scrollRef}
className="overflow-x-auto overflow-y-auto"
style={{ maxHeight: "calc(100vh - 350px)" }}
>
<div className="flex" style={{ minWidth: resourceWidth + totalWidth }}>
{/* 좌측: 리소스 라벨 */}
<div
className="shrink-0 border-r bg-muted/30 z-20 sticky left-0"
style={{ width: resourceWidth }}
>
{/* 헤더 공간 */}
<div
className="border-b bg-muted/50 flex items-center justify-center text-xs font-semibold text-muted-foreground"
style={{ height: dateGroups ? 60 : 36 }}
>
</div>
{/* 리소스 행 */}
{resources.map((res) => {
const laneCount = resourceLaneCounts.get(res.id) || 1;
const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12);
return (
<div
key={res.id}
className="border-b px-3 flex flex-col justify-center"
style={{ height: h }}
>
<span className="text-xs font-semibold text-foreground truncate">
{res.label}
</span>
{res.subLabel && (
<span className="text-[10px] text-muted-foreground truncate">
{res.subLabel}
</span>
)}
</div>
);
})}
</div>
{/* 우측: 타임라인 그리드 */}
<div className="flex-1 relative" ref={gridRef} style={{ width: totalWidth }}>
{/* 날짜 헤더 */}
<div className="sticky top-0 z-10 bg-background border-b">
{/* 상위 그룹 (월) */}
{dateGroups && (
<div className="flex border-b">
{dateGroups.map((g, idx) => (
<div
key={idx}
className="text-center text-[11px] font-semibold text-muted-foreground border-r py-1"
style={{ width: g.span * config.cellWidth }}
>
{g.label}
</div>
))}
</div>
)}
{/* 하위 날짜 셀 */}
<div className="flex">
{dates.map((date, idx) => {
const isT = isSameDay(date, today);
const isW = isWeekend(date);
return (
<div
key={idx}
className={cn(
"text-center border-r select-none",
isW && "text-red-400",
isT && "bg-primary/10 font-bold text-primary"
)}
style={{
width: config.cellWidth,
minWidth: config.cellWidth,
fontSize: zoom === "month" ? 9 : 11,
padding: zoom === "month" ? "2px 0" : "3px 0",
}}
>
{zoom === "month" ? (
<div>{date.getDate()}</div>
) : (
<>
<div className="font-semibold">{date.getDate()}</div>
<div>{DAY_NAMES[date.getDay()]}</div>
</>
)}
</div>
);
})}
</div>
</div>
{/* 리소스별 이벤트 행 */}
{resources.map((res) => {
const resEvents = eventsByResource.get(res.id) || [];
const laneCount = resourceLaneCounts.get(res.id) || 1;
const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12);
return (
<div key={res.id} className="relative border-b" style={{ height: h }}>
{/* 배경 그리드 */}
<div className="absolute inset-0 flex pointer-events-none">
{dates.map((date, idx) => (
<div
key={idx}
className={cn(
"border-r border-border/20",
isWeekend(date) && "bg-red-500/[0.03]"
)}
style={{ width: config.cellWidth, minWidth: config.cellWidth }}
/>
))}
</div>
{/* 오늘 라인 */}
{todayLineLeft != null && (
<div
className="absolute top-0 bottom-0 w-[2px] bg-red-500 z-[5] pointer-events-none"
style={{ left: todayLineLeft }}
/>
)}
{/* 이벤트 바 */}
{resEvents.map((ev) => {
if (ev.isMilestone && showMilestones) {
// 마일스톤: 다이아몬드 아이콘
const pos = getBarStyle(ev.startDate, ev.startDate);
if (!pos) return null;
return (
<div
key={ev.id}
className="absolute z-10 flex items-center justify-center cursor-pointer"
style={{
left: pos.left + pos.width / 2 - 8,
top: "50%",
transform: "translateY(-50%)",
}}
title={ev.label || "마일스톤"}
onClick={() => onEventClick?.(ev)}
>
<Diamond className="h-4 w-4 text-purple-500 fill-purple-500" />
</div>
);
}
// 일반 이벤트 바
const isDragging = dragState?.eventId === ev.id;
const barStyle = isDragging
? getDraggedBarStyle(ev)
: getBarStyle(ev.startDate, ev.endDate);
if (!barStyle) return null;
const lane = eventLanes.get(ev.id) || 0;
const colorClass = getStatusColor(ev.status);
const isConflict = conflictIds.has(ev.id);
const progress = ev.progress ?? 0;
return (
<div
key={ev.id}
className={cn(
"absolute rounded shadow-sm z-10 group select-none",
`bg-gradient-to-br ${colorClass}`,
isDragging && "opacity-80 shadow-lg z-20",
isConflict && "ring-2 ring-red-500 ring-offset-1",
"cursor-grab active:cursor-grabbing"
)}
style={{
left: barStyle.left,
width: Math.max(barStyle.width, config.cellWidth * 0.5),
height: barHeight,
top: 6 + lane * (barHeight + barGap),
}}
title={`${ev.label || ""} | ${ev.startDate.split("T")[0]} ~ ${ev.endDate.split("T")[0]}${progress > 0 ? ` | ${progress}%` : ""}`}
onClick={(e) => {
if (!isDragging) {
e.stopPropagation();
onEventClick?.(ev);
}
}}
onMouseDown={(e) => handleMouseDown(e, ev.id, "move", ev.startDate, ev.endDate)}
>
{/* 진행률 바 */}
{showProgress && progress > 0 && (
<div
className="absolute inset-y-0 left-0 rounded-l bg-white/25"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
)}
{/* 라벨 */}
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-white drop-shadow-sm truncate px-1">
{ev.label || ""}
{showProgress && progress > 0 && (
<span className="ml-1 opacity-75">({progress}%)</span>
)}
</span>
{/* 좌측 리사이즈 핸들 */}
<div
className="absolute left-0 top-0 bottom-0 w-[5px] cursor-col-resize opacity-0 group-hover:opacity-100 bg-white/30 rounded-l"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, ev.id, "resize-left", ev.startDate, ev.endDate);
}}
/>
{/* 우측 리사이즈 핸들 */}
<div
className="absolute right-0 top-0 bottom-0 w-[5px] cursor-col-resize opacity-0 group-hover:opacity-100 bg-white/30 rounded-r"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, ev.id, "resize-right", ev.startDate, ev.endDate);
}}
/>
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -648,24 +648,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
);
if (response.data.success && response.data.data) {
// valueCode 및 valueId -> {label, color} 매핑 생성
// valueCode 및 valueId -> {label, color} 매핑 생성 (트리 재귀 평탄화)
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
// valueCode로 매핑
if (item.valueCode) {
mapping[item.valueCode] = {
label: item.valueLabel,
color: item.color,
};
}
// valueId로도 매핑 (숫자 ID 저장 시 라벨 표시용)
if (item.valueId !== undefined && item.valueId !== null) {
mapping[String(item.valueId)] = {
label: item.valueLabel,
color: item.color,
};
}
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const displayLabel = parentLabel
? `${parentLabel} / ${item.valueLabel}`
: item.valueLabel;
if (item.valueCode) {
mapping[item.valueCode] = {
label: displayLabel,
color: item.color,
};
}
if (item.valueId !== undefined && item.valueId !== null) {
mapping[String(item.valueId)] = {
label: displayLabel,
color: item.color,
};
}
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, item.valueLabel);
}
});
};
flattenCategoryTree(response.data.data);
mappings[col.columnName] = mapping;
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid });
}

View File

@ -31,7 +31,7 @@ const AlertDialog: React.FC<React.ComponentProps<typeof AlertDialogPrimitive.Roo
const isTabActiveRef = React.useRef(isTabActive);
isTabActiveRef.current = isTabActive;
const effectiveOpen = open != null ? open && isTabActive : undefined;
const effectiveOpen = open != null ? open : undefined;
const guardedOnOpenChange = React.useCallback(
(newOpen: boolean) => {
@ -94,6 +94,11 @@ const AlertDialogContent = React.forwardRef<
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
const scoped = React.useContext(ScopedAlertCtx);
// 탭 비활성 시 content를 언마운트하지 않고 CSS로 숨김 (자식 컴포넌트 상태 보존)
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const isTabActive = !tabId || tabId === activeTabId;
const adjustedStyle = scoped && style
? { ...style, maxHeight: undefined, maxWidth: undefined }
: style;
@ -117,7 +122,7 @@ const AlertDialogContent = React.forwardRef<
<DialogPrimitive.Portal container={container ?? undefined}>
<div
className="absolute inset-0 z-1050 flex items-center justify-center overflow-hidden p-4"
style={hiddenProp ? { display: "none" } : undefined}
style={(hiddenProp || !isTabActive) ? { display: "none" } : undefined}
>
<div className="absolute inset-0 bg-black/80" />
<DialogPrimitive.Content

View File

@ -28,7 +28,7 @@ const Dialog: React.FC<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
isTabActiveRef.current = isTabActive;
const effectiveModal = modal !== undefined ? modal : !scoped ? undefined : false;
const effectiveOpen = open != null ? open && isTabActive : undefined;
const effectiveOpen = open != null ? open : undefined;
// 비활성 탭에서 발생하는 onOpenChange(false) 차단
// (탭 전환 시 content unmount → focus 이동 → Radix가 onOpenChange(false)를 호출하는 것을 방지)
@ -83,6 +83,11 @@ const DialogContent = React.forwardRef<
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
const scoped = !!container;
// 탭 비활성 시 content를 언마운트하지 않고 CSS로 숨김 (자식 컴포넌트 상태 보존)
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const isTabActive = !tabId || tabId === activeTabId;
// state 기반 ref: DialogPrimitive.Content 마운트/언마운트 시 useEffect 재실행 보장
const [contentNode, setContentNode] = React.useState<HTMLDivElement | null>(null);
const mergedRef = React.useCallback(
@ -130,7 +135,7 @@ const DialogContent = React.forwardRef<
<DialogPortal container={container ?? undefined}>
<div
className={scoped ? "absolute inset-0 z-999 flex items-center justify-center overflow-hidden p-4" : undefined}
style={hiddenProp ? { display: "none" } : undefined}
style={(hiddenProp || (scoped && !isTabActive)) ? { display: "none" } : undefined}
>
{scoped ? (
<div className="absolute inset-0 bg-black/60" />

View File

@ -92,7 +92,14 @@ const DropdownSelect = forwardRef<
className={cn("w-full", allowClear && hasValue ? "pr-8" : "", className)}
style={style}
>
<SelectValue placeholder={placeholder} />
<span data-slot="select-value">
{(() => {
const val = typeof value === "string" ? value : (value?.[0] ?? "");
const opt = options.find(o => o.value === val);
if (!opt || !val) return placeholder;
return opt.displayLabel || opt.label;
})()}
</span>
</SelectTrigger>
<SelectContent>
{options
@ -139,7 +146,7 @@ const DropdownSelect = forwardRef<
const selectedLabels = useMemo(() => {
return safeOptions
.filter((o) => selectedValues.includes(o.value))
.map((o) => o.label)
.map((o) => o.displayLabel || o.label)
.filter(Boolean) as string[];
}, [selectedValues, safeOptions]);
@ -896,18 +903,23 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
// 트리 구조를 평탄화하여 옵션으로 변환
// 🔧 value로 valueCode를 사용 (커스텀 테이블 저장/조회 호환)
const flattenTree = (
items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[],
items: { valueId: number; valueCode: string; valueLabel: string; path?: string; children?: any[] }[],
depth: number = 0,
parentLabel: string = "",
): SelectOption[] => {
const result: SelectOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
const displayLabel = parentLabel
? `${parentLabel} / ${item.valueLabel}`
: item.valueLabel;
result.push({
value: item.valueCode, // 🔧 valueCode를 value로 사용
label: prefix + item.valueLabel,
displayLabel,
});
if (item.children && item.children.length > 0) {
result.push(...flattenTree(item.children, depth + 1));
result.push(...flattenTree(item.children, depth + 1, item.valueLabel));
}
}
return result;

View File

@ -0,0 +1,188 @@
import { apiClient } from "./client";
// --- 타입 정의 ---
export interface OutboundItem {
id: string;
company_code: string;
outbound_number: string;
outbound_type: string;
outbound_date: string;
reference_number: string | null;
customer_code: string | null;
customer_name: string | null;
item_code: string | null;
item_name: string | null;
specification: string | null;
material: string | null;
unit: string | null;
outbound_qty: number;
unit_price: number;
total_amount: number;
lot_number: string | null;
warehouse_code: string | null;
warehouse_name?: string | null;
location_code: string | null;
outbound_status: string;
manager_id: string | null;
memo: string | null;
source_type: string | null;
sales_order_id: string | null;
shipment_plan_id: string | null;
item_info_id: string | null;
destination_code: string | null;
delivery_destination: string | null;
delivery_address: string | null;
created_date: string;
created_by: string | null;
}
export interface ShipmentInstructionSource {
detail_id: number;
instruction_id: number;
instruction_no: string;
instruction_date: string;
partner_id: string;
instruction_status: string;
item_code: string;
item_name: string;
spec: string | null;
material: string | null;
plan_qty: number;
ship_qty: number;
order_qty: number;
remain_qty: number;
source_type: string | null;
}
export interface PurchaseOrderSource {
id: string;
purchase_no: string;
order_date: string;
supplier_code: string;
supplier_name: string;
item_code: string;
item_name: string;
spec: string | null;
material: string | null;
order_qty: number;
received_qty: number;
unit_price: number;
status: string;
due_date: string | null;
}
export interface ItemSource {
id: string;
item_number: string;
item_name: string;
spec: string | null;
material: string | null;
unit: string | null;
standard_price: number;
}
export interface WarehouseOption {
warehouse_code: string;
warehouse_name: string;
warehouse_type: string;
}
export interface CreateOutboundPayload {
outbound_number: string;
outbound_date: string;
warehouse_code?: string;
location_code?: string;
manager_id?: string;
memo?: string;
items: Array<{
outbound_type: string;
reference_number?: string;
customer_code?: string;
customer_name?: string;
item_code?: string;
item_number?: string;
item_name?: string;
spec?: string;
specification?: string;
material?: string;
unit?: string;
outbound_qty: number;
unit_price?: number;
total_amount?: number;
lot_number?: string;
warehouse_code?: string;
location_code?: string;
outbound_status?: string;
manager_id?: string;
memo?: string;
source_type?: string;
source_id?: string;
sales_order_id?: string;
shipment_plan_id?: string;
item_info_id?: string;
destination_code?: string;
delivery_destination?: string;
delivery_address?: string;
}>;
}
// --- API 호출 ---
export async function getOutboundList(params?: {
outbound_type?: string;
outbound_status?: string;
search_keyword?: string;
date_from?: string;
date_to?: string;
}) {
const res = await apiClient.get("/outbound/list", { params });
return res.data as { success: boolean; data: OutboundItem[] };
}
export async function createOutbound(payload: CreateOutboundPayload) {
const res = await apiClient.post("/outbound", payload);
return res.data as { success: boolean; data: OutboundItem[]; message?: string };
}
export async function updateOutbound(id: string, payload: Partial<OutboundItem>) {
const res = await apiClient.put(`/outbound/${id}`, payload);
return res.data as { success: boolean; data: OutboundItem };
}
export async function deleteOutbound(id: string) {
const res = await apiClient.delete(`/outbound/${id}`);
return res.data as { success: boolean; message?: string };
}
export async function generateOutboundNumber() {
const res = await apiClient.get("/outbound/generate-number");
return res.data as { success: boolean; data: string };
}
export async function getOutboundWarehouses() {
const res = await apiClient.get("/outbound/warehouses");
return res.data as { success: boolean; data: WarehouseOption[] };
}
// 소스 데이터 조회
export async function getShipmentInstructionSources(keyword?: string) {
const res = await apiClient.get("/outbound/source/shipment-instructions", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ShipmentInstructionSource[] };
}
export async function getPurchaseOrderSources(keyword?: string) {
const res = await apiClient.get("/outbound/source/purchase-orders", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: PurchaseOrderSource[] };
}
export async function getItemSources(keyword?: string) {
const res = await apiClient.get("/outbound/source/items", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ItemSource[] };
}

View File

@ -0,0 +1,168 @@
import { apiClient } from "./client";
// --- 타입 정의 ---
export interface PkgUnit {
id: string;
company_code: string;
pkg_code: string;
pkg_name: string;
pkg_type: string;
status: string;
width_mm: number | null;
length_mm: number | null;
height_mm: number | null;
self_weight_kg: number | null;
max_load_kg: number | null;
volume_l: number | null;
remarks: string | null;
created_date: string;
writer: string | null;
}
export interface PkgUnitItem {
id: string;
company_code: string;
pkg_code: string;
item_number: string;
pkg_qty: number;
// JOIN된 필드
item_name?: string;
spec?: string;
unit?: string;
}
export interface LoadingUnit {
id: string;
company_code: string;
loading_code: string;
loading_name: string;
loading_type: string;
status: string;
width_mm: number | null;
length_mm: number | null;
height_mm: number | null;
self_weight_kg: number | null;
max_load_kg: number | null;
max_stack: number | null;
remarks: string | null;
created_date: string;
writer: string | null;
}
export interface LoadingUnitPkg {
id: string;
company_code: string;
loading_code: string;
pkg_code: string;
max_load_qty: number;
load_method: string | null;
// JOIN된 필드
pkg_name?: string;
pkg_type?: string;
}
export interface ItemInfoForPkg {
id: string;
item_number: string;
item_name: string;
size: string | null;
spec?: string | null;
material: string | null;
unit: string | null;
division: string | null;
}
// --- 포장단위 API ---
export async function getPkgUnits() {
const res = await apiClient.get("/packaging/pkg-units");
return res.data as { success: boolean; data: PkgUnit[] };
}
export async function createPkgUnit(data: Partial<PkgUnit>) {
const res = await apiClient.post("/packaging/pkg-units", data);
return res.data as { success: boolean; data: PkgUnit; message?: string };
}
export async function updatePkgUnit(id: string, data: Partial<PkgUnit>) {
const res = await apiClient.put(`/packaging/pkg-units/${id}`, data);
return res.data as { success: boolean; data: PkgUnit };
}
export async function deletePkgUnit(id: string) {
const res = await apiClient.delete(`/packaging/pkg-units/${id}`);
return res.data as { success: boolean; message?: string };
}
// --- 포장단위 매칭품목 API ---
export async function getPkgUnitItems(pkgCode: string) {
const res = await apiClient.get(`/packaging/pkg-unit-items/${encodeURIComponent(pkgCode)}`);
return res.data as { success: boolean; data: PkgUnitItem[] };
}
export async function createPkgUnitItem(data: { pkg_code: string; item_number: string; pkg_qty: number }) {
const res = await apiClient.post("/packaging/pkg-unit-items", data);
return res.data as { success: boolean; data: PkgUnitItem; message?: string };
}
export async function deletePkgUnitItem(id: string) {
const res = await apiClient.delete(`/packaging/pkg-unit-items/${id}`);
return res.data as { success: boolean; message?: string };
}
// --- 적재함 API ---
export async function getLoadingUnits() {
const res = await apiClient.get("/packaging/loading-units");
return res.data as { success: boolean; data: LoadingUnit[] };
}
export async function createLoadingUnit(data: Partial<LoadingUnit>) {
const res = await apiClient.post("/packaging/loading-units", data);
return res.data as { success: boolean; data: LoadingUnit; message?: string };
}
export async function updateLoadingUnit(id: string, data: Partial<LoadingUnit>) {
const res = await apiClient.put(`/packaging/loading-units/${id}`, data);
return res.data as { success: boolean; data: LoadingUnit };
}
export async function deleteLoadingUnit(id: string) {
const res = await apiClient.delete(`/packaging/loading-units/${id}`);
return res.data as { success: boolean; message?: string };
}
// --- 적재함 포장구성 API ---
export async function getLoadingUnitPkgs(loadingCode: string) {
const res = await apiClient.get(`/packaging/loading-unit-pkgs/${encodeURIComponent(loadingCode)}`);
return res.data as { success: boolean; data: LoadingUnitPkg[] };
}
export async function createLoadingUnitPkg(data: { loading_code: string; pkg_code: string; max_load_qty: number; load_method?: string }) {
const res = await apiClient.post("/packaging/loading-unit-pkgs", data);
return res.data as { success: boolean; data: LoadingUnitPkg; message?: string };
}
export async function deleteLoadingUnitPkg(id: string) {
const res = await apiClient.delete(`/packaging/loading-unit-pkgs/${id}`);
return res.data as { success: boolean; message?: string };
}
// --- 품목정보 연동 API ---
export async function getItemsByDivision(divisionLabel: string, keyword?: string) {
const res = await apiClient.get(`/packaging/items/${encodeURIComponent(divisionLabel)}`, {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ItemInfoForPkg[] };
}
export async function getGeneralItems(keyword?: string) {
const res = await apiClient.get("/packaging/items/general", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ItemInfoForPkg[] };
}

View File

@ -2,7 +2,7 @@
* API
*/
import apiClient from "./client";
import { apiClient } from "./client";
// ─── 타입 정의 ───
@ -19,6 +19,7 @@ export interface OrderSummaryItem {
existing_plan_qty: number;
in_progress_qty: number;
required_plan_qty: number;
lead_time: number;
orders: OrderDetail[];
}
@ -94,10 +95,51 @@ export interface GenerateScheduleResponse {
deleted_count: number;
};
schedules: ProductionPlan[];
deletedSchedules?: ProductionPlan[];
keptSchedules?: ProductionPlan[];
}
// ─── API 함수 ───
/** 생산계획 목록 조회 */
export async function getPlans(params?: {
productType?: string;
status?: string;
startDate?: string;
endDate?: string;
itemCode?: string;
}) {
const queryParams = new URLSearchParams();
if (params?.productType) queryParams.set("productType", params.productType);
if (params?.status) queryParams.set("status", params.status);
if (params?.startDate) queryParams.set("startDate", params.startDate);
if (params?.endDate) queryParams.set("endDate", params.endDate);
if (params?.itemCode) queryParams.set("itemCode", params.itemCode);
const qs = queryParams.toString();
const url = `/production/plans${qs ? `?${qs}` : ""}`;
const response = await apiClient.get(url);
return response.data as { success: boolean; data: ProductionPlan[] };
}
/** 자동 스케줄 미리보기 (DB 변경 없이 예상 결과) */
export async function previewSchedule(request: GenerateScheduleRequest) {
const response = await apiClient.post("/production/generate-schedule/preview", request);
return response.data as { success: boolean; data: GenerateScheduleResponse };
}
/** 반제품 계획 미리보기 */
export async function previewSemiSchedule(
planIds: number[],
options?: { considerStock?: boolean; excludeUsed?: boolean }
) {
const response = await apiClient.post("/production/generate-semi-schedule/preview", {
plan_ids: planIds,
options: options || {},
});
return response.data as { success: boolean; data: { count: number; schedules: ProductionPlan[] } };
}
/** 수주 데이터 조회 (품목별 그룹핑) */
export async function getOrderSummary(params?: {
excludePlanned?: boolean;
@ -110,44 +152,44 @@ export async function getOrderSummary(params?: {
if (params?.itemName) queryParams.set("itemName", params.itemName);
const qs = queryParams.toString();
const url = `/api/production/order-summary${qs ? `?${qs}` : ""}`;
const url = `/production/order-summary${qs ? `?${qs}` : ""}`;
const response = await apiClient.get(url);
return response.data as { success: boolean; data: OrderSummaryItem[] };
}
/** 안전재고 부족분 조회 */
export async function getStockShortage() {
const response = await apiClient.get("/api/production/stock-shortage");
const response = await apiClient.get("/production/stock-shortage");
return response.data as { success: boolean; data: StockShortageItem[] };
}
/** 생산계획 상세 조회 */
export async function getPlanById(planId: number) {
const response = await apiClient.get(`/api/production/plan/${planId}`);
const response = await apiClient.get(`/production/plan/${planId}`);
return response.data as { success: boolean; data: ProductionPlan };
}
/** 생산계획 수정 */
export async function updatePlan(planId: number, data: Partial<ProductionPlan>) {
const response = await apiClient.put(`/api/production/plan/${planId}`, data);
const response = await apiClient.put(`/production/plan/${planId}`, data);
return response.data as { success: boolean; data: ProductionPlan };
}
/** 생산계획 삭제 */
export async function deletePlan(planId: number) {
const response = await apiClient.delete(`/api/production/plan/${planId}`);
const response = await apiClient.delete(`/production/plan/${planId}`);
return response.data as { success: boolean; message: string };
}
/** 자동 스케줄 생성 */
export async function generateSchedule(request: GenerateScheduleRequest) {
const response = await apiClient.post("/api/production/generate-schedule", request);
const response = await apiClient.post("/production/generate-schedule", request);
return response.data as { success: boolean; data: GenerateScheduleResponse };
}
/** 스케줄 병합 */
export async function mergeSchedules(scheduleIds: number[], productType?: string) {
const response = await apiClient.post("/api/production/merge-schedules", {
const response = await apiClient.post("/production/merge-schedules", {
schedule_ids: scheduleIds,
product_type: productType || "완제품",
});
@ -159,7 +201,7 @@ export async function generateSemiSchedule(
planIds: number[],
options?: { considerStock?: boolean; excludeUsed?: boolean }
) {
const response = await apiClient.post("/api/production/generate-semi-schedule", {
const response = await apiClient.post("/production/generate-semi-schedule", {
plan_ids: planIds,
options: options || {},
});
@ -168,7 +210,7 @@ export async function generateSemiSchedule(
/** 스케줄 분할 */
export async function splitSchedule(planId: number, splitQty: number) {
const response = await apiClient.post(`/api/production/plan/${planId}/split`, {
const response = await apiClient.post(`/production/plan/${planId}/split`, {
split_qty: splitQty,
});
return response.data as {

View File

@ -372,22 +372,27 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
if (response.data.success && response.data.data) {
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
// API 응답 형식: valueCode, valueLabel (camelCase)
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
// color가 null/undefined/"none"이면 undefined로 유지 (배지 없음)
const rawColor = item.color ?? item.badge_color;
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label, color };
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const rawLabel = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
const rawColor = item.color ?? item.badge_color;
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label: displayLabel, color };
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, rawLabel);
}
});
};
flattenCategoryTree(response.data.data);
mappings[columnName] = mapping;
}
} catch (error) {
// 카테고리 매핑 로드 실패 시 무시
}
}
setCategoryMappings(mappings);
}
} catch (error) {

View File

@ -223,7 +223,9 @@ export const CategorySelectComponent: React.FC<
key={categoryValue.valueId}
value={categoryValue.valueCode}
>
{categoryValue.valueLabel}
{categoryValue.path && categoryValue.path.includes('/')
? categoryValue.path.replace(/\//g, ' / ')
: categoryValue.valueLabel}
</SelectItem>
))}
</SelectContent>

View File

@ -258,7 +258,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const activeValues = response.data.filter((v: any) => v.isActive !== false);
const options = activeValues.map((v: any) => ({
value: v.valueCode,
label: v.valueLabel || v.valueCode,
label: (v.path && v.path.includes('/'))
? v.path.replace(/\//g, ' / ')
: (v.valueLabel || v.valueCode),
}));
setCategoryOptions(options);
}

View File

@ -1613,12 +1613,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const rawLabel = item.value_label || item.valueLabel;
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
valueMap[item.value_code || item.valueCode] = {
label: displayLabel,
color: item.color,
};
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, rawLabel);
}
});
};
flattenCategoryTree(response.data.data);
mappings[columnName] = valueMap;
console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
}
@ -1675,12 +1683,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const rawLabel = item.value_label || item.valueLabel;
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
valueMap[item.value_code || item.valueCode] = {
label: displayLabel,
color: item.color,
};
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, rawLabel);
}
});
};
flattenCategoryTree(response.data.data);
// 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장
const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`;

View File

@ -1337,7 +1337,8 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
for (const item of result.data) {
if (item.valueCode && item.valueLabel) {
labelMap[item.valueCode] = item.valueLabel;
// 계층 경로 표시: path가 있고 '/'를 포함하면 전체 경로를 ' > ' 구분자로 표시
labelMap[item.valueCode] = item.path && item.path.includes('/') ? item.path.replace(/\//g, ' > ') : item.valueLabel;
}
}
}

View File

@ -1287,22 +1287,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const apiClient = (await import("@/lib/api/client")).apiClient;
// 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용)
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>, parentLabel: string = "") => {
items.forEach((item: any) => {
const displayLabel = parentLabel
? `${parentLabel} / ${item.valueLabel}`
: item.valueLabel;
if (item.valueCode) {
mapping[String(item.valueCode)] = {
label: item.valueLabel,
label: displayLabel,
color: item.color,
};
}
if (item.valueId !== undefined && item.valueId !== null) {
mapping[String(item.valueId)] = {
label: item.valueLabel,
label: displayLabel,
color: item.color,
};
}
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenTree(item.children, mapping);
flattenTree(item.children, mapping, item.valueLabel);
}
});
};

View File

@ -372,22 +372,27 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
if (response.data.success && response.data.data) {
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
// API 응답 형식: valueCode, valueLabel (camelCase)
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
// color가 null/undefined/"none"이면 undefined로 유지 (배지 없음)
const rawColor = item.color ?? item.badge_color;
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label, color };
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const rawLabel = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
const rawColor = item.color ?? item.badge_color;
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label: displayLabel, color };
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, rawLabel);
}
});
};
flattenCategoryTree(response.data.data);
mappings[columnName] = mapping;
}
} catch (error) {
// 카테고리 매핑 로드 실패 시 무시
}
}
setCategoryMappings(mappings);
}
} catch (error) {

View File

@ -458,7 +458,7 @@ export function ItemRoutingComponent({
{/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="max-w-[95vw]" style={{ maxWidth: `min(95vw, ${config.addModalMaxWidth || "600px"})` }}>
<DialogContent className="!max-w-none" style={{ width: `min(100%, 95vw, ${config.addModalMaxWidth || "600px"})` }}>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">

View File

@ -3896,13 +3896,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const canDragLeftGroupedColumns = !isDesignMode && columnsToShow.length > 1;
if (groupedLeftData.length > 0) {
return (
<div className="overflow-auto">
<>
{groupedLeftData.map((group, groupIdx) => (
<div key={groupIdx} className="mb-4">
<div className="bg-muted px-3 py-2 text-sm font-semibold">
{group.groupKey} ({group.count})
</div>
<table className="divide-border min-w-full divide-y">
<table className="divide-border min-w-full divide-y" style={{ minWidth: `${Math.max(columnsToShow.length * 120, 400)}px` }}>
<thead className="bg-muted">
<tr>
{columnsToShow.map((col, idx) => {
@ -4016,7 +4016,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</table>
</div>
))}
</div>
</>
);
}
@ -4027,8 +4027,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.leftPanel?.showDelete !== false);
const canDragLeftColumns = !isDesignMode && columnsToShow.length > 1;
return (
<div className="overflow-auto">
<table className="divide-border min-w-full divide-y">
<table className="divide-border min-w-full divide-y" style={{ minWidth: `${Math.max(columnsToShow.length * 120, 400)}px` }}>
<thead className="bg-muted sticky top-0 z-10">
<tr>
{columnsToShow.map((col, idx) => {
@ -4135,7 +4134,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
})}
</tbody>
</table>
</div>
);
})()
)}
@ -5189,19 +5187,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
columnsToShow = [...keyCols, ...applyRuntimeOrder(dataCols, "main")];
}
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
const rightTotalColWidth = columnsToShow.reduce((sum, col) => {
const w = col.width && col.width <= 100 ? col.width : 0;
return sum + w;
}, 0);
const rightConfigColumnStart = columnsToShow.filter((c: any) => c._isKeyColumn).length;
const canDragRightColumns = displayColumns.length > 0;
return (
<div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full">
<table className="min-w-full" style={{ minWidth: `${Math.max(columnsToShow.length * 120, 400)}px` }}>
<thead className="sticky top-0 z-10">
<tr className="border-border/60 border-b-2">
{columnsToShow.map((col, idx) => {
@ -5221,7 +5213,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
isDragging && "opacity-50",
)}
style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
minWidth: col.width ? `${col.width}px` : "80px",
textAlign: col.align || "left",
}}
draggable={isDraggable}
@ -5387,14 +5379,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return filteredData.length > 0 ? (
<div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full text-sm">
<table className="min-w-full text-sm" style={{ minWidth: `${Math.max(columnsToDisplay.length * 120, 400)}px` }}>
<thead className="bg-background sticky top-0 z-10">
<tr className="border-border/60 border-b-2">
{columnsToDisplay.map((col) => (
<th
key={col.name}
className="text-muted-foreground px-3 py-[7px] text-left text-[9px] font-bold tracking-[0.04em] whitespace-nowrap uppercase"
style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}
style={{ minWidth: col.width ? `${col.width}px` : "80px" }}
>
{col.label}
</th>

View File

@ -369,6 +369,8 @@ import {
Trash2,
Lock,
GripVertical,
Loader2,
Search,
} from "lucide-react";
import * as XLSX from "xlsx";
import { FileText, ChevronRightIcon } from "lucide-react";
@ -810,17 +812,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
// 🆕 서버에서 가져온 컬럼별 고유값 캐시 (헤더 필터 드롭다운용)
const [asyncColumnUniqueValues, setAsyncColumnUniqueValues] = useState<
Record<string, { value: string; label: string }[]>
>({});
const [loadingFilterColumn, setLoadingFilterColumn] = useState<string | null>(null);
const [filterSearchTerm, setFilterSearchTerm] = useState("");
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
// 🆕 joinColumnMapping - filteredData에서 사용하므로 먼저 정의해야 함
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터
// 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용)
// 헤더 필터와 필터 빌더는 서버사이드에서 처리됨 (fetchTableDataInternal에서 API 파라미터로 전달)
const filteredData = useMemo(() => {
let result = data;
// 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링
// 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링
if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) {
const addedIds = splitPanelContext.addedItemIds;
result = result.filter((row) => {
@ -829,78 +839,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
// 2. 헤더 필터 적용 (joinColumnMapping 사용 - 조인된 컬럼과 일치해야 함)
if (Object.keys(headerFilters).length > 0) {
result = result.filter((row) => {
return Object.entries(headerFilters).every(([columnName, values]) => {
if (values.size === 0) return true;
// joinColumnMapping을 사용하여 조인된 컬럼명 확인
const mappedColumnName = joinColumnMapping[columnName] || columnName;
// 여러 가능한 컬럼명 시도 (mappedColumnName 우선)
const cellValue = row[mappedColumnName] ?? row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : "";
return values.has(cellStr);
});
});
}
// 3. 🆕 Filter Builder 적용
if (filterGroups.length > 0) {
result = result.filter((row) => {
return filterGroups.every((group) => {
const validConditions = group.conditions.filter(
(c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value),
);
if (validConditions.length === 0) return true;
const evaluateCondition = (value: any, condition: (typeof group.conditions)[0]): boolean => {
const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : "";
const condValue = condition.value.toLowerCase();
switch (condition.operator) {
case "equals":
return strValue === condValue;
case "notEquals":
return strValue !== condValue;
case "contains":
return strValue.includes(condValue);
case "notContains":
return !strValue.includes(condValue);
case "startsWith":
return strValue.startsWith(condValue);
case "endsWith":
return strValue.endsWith(condValue);
case "greaterThan":
return parseFloat(strValue) > parseFloat(condValue);
case "lessThan":
return parseFloat(strValue) < parseFloat(condValue);
case "greaterOrEqual":
return parseFloat(strValue) >= parseFloat(condValue);
case "lessOrEqual":
return parseFloat(strValue) <= parseFloat(condValue);
case "isEmpty":
return strValue === "" || value === null || value === undefined;
case "isNotEmpty":
return strValue !== "" && value !== null && value !== undefined;
default:
return true;
}
};
if (group.logic === "AND") {
return validConditions.every((cond) => evaluateCondition(row[cond.column], cond));
} else {
return validConditions.some((cond) => evaluateCondition(row[cond.column], cond));
}
});
});
}
return result;
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]);
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
@ -1650,16 +1590,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용)
// valueCode만 키로 사용 (valueId까지 넣으면 같은 라벨이 2번 나옴)
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>, parentLabel: string = "") => {
items.forEach((item: any) => {
if (item.valueCode) {
const displayLabel = parentLabel
? `${parentLabel} / ${item.valueLabel}`
: item.valueLabel;
mapping[String(item.valueCode)] = {
label: item.valueLabel,
label: displayLabel,
color: item.color,
};
}
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenTree(item.children, mapping);
flattenTree(item.children, mapping, item.valueLabel);
}
});
};
@ -1956,11 +1899,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
}
// 검색 필터, 연결 필터, RelatedDataButtons 필터 병합
// 🆕 헤더 필터를 서버 필터 형식으로 변환
const headerFilterValues: Record<string, any> = {};
Object.entries(headerFilters).forEach(([columnName, values]) => {
if (values.size > 0) {
const mappedCol = joinColumnMapping[columnName] || columnName;
headerFilterValues[mappedCol] = { value: Array.from(values), operator: "in" };
}
});
// 🆕 필터 빌더를 서버 필터 형식으로 변환
const filterBuilderValues: Record<string, any> = {};
filterGroups.forEach((group) => {
group.conditions.forEach((cond) => {
if (cond.column && (cond.operator === "isEmpty" || cond.operator === "isNotEmpty" || cond.value)) {
filterBuilderValues[cond.column] = { value: cond.value, operator: cond.operator };
}
});
});
// 검색 필터, 연결 필터, RelatedDataButtons 필터, 헤더 필터, 필터 빌더 병합
const filters = {
...(Object.keys(searchValues).length > 0 ? searchValues : {}),
...linkedFilterValues,
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
...headerFilterValues, // 🆕 헤더 필터 추가
...filterBuilderValues, // 🆕 필터 빌더 추가
};
const hasFilters = Object.keys(filters).length > 0;
@ -2137,6 +2101,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
isRelatedButtonTarget,
// 🆕 프리뷰용 회사 코드 오버라이드
companyCode,
// 🆕 서버사이드 헤더 필터 / 필터 빌더
headerFilters,
filterGroups,
joinColumnMapping,
]);
const fetchTableDataDebounced = useCallback(
@ -2594,6 +2562,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return result;
}, [data, tableConfig.columns, joinColumnMapping]);
// 데이터 변경 시 헤더 필터 드롭다운 캐시 초기화
useEffect(() => {
setAsyncColumnUniqueValues({});
}, [data]);
// 🆕 헤더 필터 토글
const toggleHeaderFilter = useCallback((columnName: string, value: string) => {
setHeaderFilters((prev) => {
@ -6122,11 +6095,40 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<span className="text-primary">{sortDirection === "asc" ? "↑" : "↓"}</span>
)}
{/* 🆕 헤더 필터 버튼 */}
{tableConfig.headerFilter !== false &&
columnUniqueValues[column.columnName]?.length > 0 && (
{tableConfig.headerFilter !== false && (
<Popover
open={openFilterColumn === column.columnName}
onOpenChange={(open) => setOpenFilterColumn(open ? column.columnName : null)}
onOpenChange={(open) => {
if (open) {
setOpenFilterColumn(column.columnName);
setFilterSearchTerm("");
// 서버에서 고유값 가져오기
if (!asyncColumnUniqueValues[column.columnName]) {
setLoadingFilterColumn(column.columnName);
const mappedCol = joinColumnMapping[column.columnName] || column.columnName;
const tableName = tableConfig.selectedTable;
if (tableName) {
import("@/lib/api/client").then(({ apiClient }) => {
apiClient
.get(`/table-management/tables/${tableName}/column-values/${mappedCol}`)
.then((res) => {
const values = (res.data?.data || []).map((v: any) => ({
value: String(v.value ?? ""),
label: String(v.label ?? v.value ?? ""),
}));
setAsyncColumnUniqueValues((prev) => ({ ...prev, [column.columnName]: values }));
})
.catch(() => {
setAsyncColumnUniqueValues((prev) => ({ ...prev, [column.columnName]: [] }));
})
.finally(() => setLoadingFilterColumn(null));
});
}
}
} else {
setOpenFilterColumn(null);
}
}}
>
<PopoverTrigger asChild>
<button
@ -6146,7 +6148,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</button>
</PopoverTrigger>
<PopoverContent
className="w-48 p-2"
className="w-56 p-2"
align="start"
onClick={(e) => e.stopPropagation()}
>
@ -6164,35 +6166,66 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</button>
)}
</div>
{/* 검색 입력 */}
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3 w-3 -translate-y-1/2" />
<input
type="text"
value={filterSearchTerm}
onChange={(e) => setFilterSearchTerm(e.target.value)}
placeholder="검색..."
className="border-input bg-background w-full rounded border py-1 pr-2 pl-7 text-xs"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
const isSelected = headerFilters[column.columnName]?.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => toggleHeaderFilter(column.columnName, val)}
>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded border",
isSelected ? "bg-primary border-primary" : "border-input",
)}
>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{(columnUniqueValues[column.columnName]?.length || 0) > 50 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {(columnUniqueValues[column.columnName]?.length || 0) - 50}
{loadingFilterColumn === column.columnName ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs">...</span>
</div>
)}
) : (asyncColumnUniqueValues[column.columnName] || []).length === 0 ? (
<div className="text-muted-foreground px-2 py-2 text-center text-xs">
</div>
) : (() => {
const filteredItems = (asyncColumnUniqueValues[column.columnName] || []).filter((item) => {
if (!filterSearchTerm) return true;
const term = filterSearchTerm.toLowerCase();
return item.value.toLowerCase().includes(term) || item.label.toLowerCase().includes(term);
});
return filteredItems.length === 0 ? (
<div className="text-muted-foreground px-2 py-2 text-center text-xs">
</div>
) : (
<>
{filteredItems.map((item) => {
const isSelected = headerFilters[column.columnName]?.has(item.value);
return (
<div
key={item.value}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => toggleHeaderFilter(column.columnName, item.value)}
>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded border",
isSelected ? "bg-primary border-primary" : "border-input",
)}
>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{item.label || item.value || "(빈 값)"}</span>
</div>
);
})}
</>
);
})()}
</div>
</div>
</PopoverContent>

View File

@ -3,7 +3,7 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Settings, X, ChevronsUpDown } from "lucide-react";
import { Settings, X, ChevronsUpDown, Search } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
import { useActiveTab } from "@/contexts/ActiveTabContext";
@ -77,6 +77,10 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
const [selectOptions, setSelectOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
// 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지)
const [selectedLabels, setSelectedLabels] = useState<Record<string, string>>({});
// select 필터 드롭다운 내 검색 텍스트
const [selectSearchTexts, setSelectSearchTexts] = useState<Record<string, string>>({});
// select 필터 Popover 열림 상태
const [selectPopoverOpen, setSelectPopoverOpen] = useState<Record<string, boolean>>({});
// 높이 감지를 위한 ref
const containerRef = useRef<HTMLDivElement>(null);
@ -695,6 +699,14 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
[] as Array<{ value: string; label: string }>,
);
// 검색 텍스트로 필터링
const searchText = selectSearchTexts[filter.columnName] || "";
const filteredOptions = searchText
? uniqueOptions.filter((option) =>
option.label.toLowerCase().includes(searchText.toLowerCase())
)
: uniqueOptions;
// 항상 다중선택 모드
const selectedValues: string[] = Array.isArray(value) ? value : value ? [value] : [];
@ -719,7 +731,15 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
};
return (
<Popover>
<Popover
open={selectPopoverOpen[filter.columnName] || false}
onOpenChange={(open) => {
setSelectPopoverOpen((prev) => ({ ...prev, [filter.columnName]: open }));
if (!open) {
setSelectSearchTexts((prev) => ({ ...prev, [filter.columnName]: "" }));
}
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
@ -735,12 +755,34 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<div className="border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
type="text"
placeholder="검색..."
value={selectSearchTexts[filter.columnName] || ""}
onChange={(e) =>
setSelectSearchTexts((prev) => ({
...prev,
[filter.columnName]: e.target.value,
}))
}
onKeyDown={(e) => { if (e.key === "Enter") e.preventDefault(); }}
className="h-8 pl-8 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 sm:text-sm"
style={{ outline: "none", boxShadow: "none" }}
autoFocus
/>
</div>
</div>
<div className="max-h-60 overflow-auto">
{uniqueOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs">
{searchText ? "검색 결과 없음" : "옵션 없음"}
</div>
) : (
<div className="p-1">
{uniqueOptions.map((option, index) => (
{filteredOptions.map((option, index) => (
<div
key={`${filter.columnName}-multi-${option.value}-${index}`}
className="hover:bg-accent flex cursor-pointer items-center space-x-2 rounded-sm px-2 py-1.5"

View File

@ -0,0 +1,92 @@
/**
* +
*/
// --- 자동 포맷팅 ---
// 전화번호: 숫자만 추출 → 자동 하이픈
// 010-1234-5678 / 02-1234-5678 / 031-123-4567
export function formatPhone(value: string): string {
const nums = value.replace(/\D/g, "").slice(0, 11);
if (nums.startsWith("02")) {
if (nums.length <= 2) return nums;
if (nums.length <= 5) return `${nums.slice(0, 2)}-${nums.slice(2)}`;
if (nums.length <= 9) return `${nums.slice(0, 2)}-${nums.slice(2, 5)}-${nums.slice(5)}`;
return `${nums.slice(0, 2)}-${nums.slice(2, 6)}-${nums.slice(6)}`;
}
if (nums.length <= 3) return nums;
if (nums.length <= 7) return `${nums.slice(0, 3)}-${nums.slice(3)}`;
return `${nums.slice(0, 3)}-${nums.slice(3, 7)}-${nums.slice(7)}`;
}
// 사업자번호: 000-00-00000
export function formatBusinessNumber(value: string): string {
const nums = value.replace(/\D/g, "").slice(0, 10);
if (nums.length <= 3) return nums;
if (nums.length <= 5) return `${nums.slice(0, 3)}-${nums.slice(3)}`;
return `${nums.slice(0, 3)}-${nums.slice(3, 5)}-${nums.slice(5)}`;
}
// 필드명으로 자동 포맷팅
export function formatField(fieldName: string, value: string): string {
switch (fieldName) {
case "contact_phone":
case "phone":
case "cell_phone":
return formatPhone(value);
case "business_number":
return formatBusinessNumber(value);
default:
return value;
}
}
// --- 유효성 검증 ---
export function validatePhone(value: string): string | null {
if (!value) return null;
const nums = value.replace(/\D/g, "");
if (nums.length < 9) return "전화번호를 끝까지 입력해주세요";
return null;
}
export function validateEmail(value: string): string | null {
if (!value) return null;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "올바른 이메일 형식이 아닙니다";
return null;
}
export function validateBusinessNumber(value: string): string | null {
if (!value) return null;
const nums = value.replace(/\D/g, "");
if (nums.length < 10) return "사업자번호를 끝까지 입력해주세요";
return null;
}
export function validateField(fieldName: string, value: string): string | null {
if (!value) return null;
switch (fieldName) {
case "contact_phone":
case "phone":
case "cell_phone":
return validatePhone(value);
case "email":
return validateEmail(value);
case "business_number":
return validateBusinessNumber(value);
default:
return null;
}
}
export function validateForm(
data: Record<string, any>,
fields: string[]
): Record<string, string> {
const errors: Record<string, string> = {};
for (const field of fields) {
const error = validateField(field, data[field] || "");
if (error) errors[field] = error;
}
return errors;
}

View File

@ -15,6 +15,7 @@ export interface TableCategoryValue {
// 계층 구조
parentValueId?: number;
depth?: number;
path?: string;
// 추가 정보
description?: string;

View File

@ -137,6 +137,7 @@ export type V2SelectSource = "static" | "code" | "db" | "api" | "entity" | "cate
export interface SelectOption {
value: string;
label: string;
displayLabel?: string;
}
/**