feat: implement packaging unit and item management APIs

- Added CRUD operations for packaging units and their associated items in the new `packagingController.ts`.
- Implemented routes for managing packaging units and items in `packagingRoutes.ts`.
- Enhanced error handling and logging for better traceability.
- Ensured company code filtering for data access based on user roles.

Made-with: Cursor
This commit is contained in:
kjs 2026-03-12 01:18:09 +09:00
parent 7269867d91
commit 09c3fa4708
12 changed files with 1005 additions and 88 deletions

View File

@ -0,0 +1,478 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
import { getPool } from "../database/db";
// ──────────────────────────────────────────────
// 포장단위 (pkg_unit) CRUD
// ──────────────────────────────────────────────
export async function getPkgUnits(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
let sql: string;
let params: any[];
if (companyCode === "*") {
sql = `SELECT * FROM pkg_unit ORDER BY company_code, created_date DESC`;
params = [];
} else {
sql = `SELECT * FROM pkg_unit WHERE company_code = $1 ORDER BY created_date DESC`;
params = [companyCode];
}
const result = await pool.query(sql, params);
logger.info("포장단위 목록 조회", { 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 createPkgUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const {
pkg_code, pkg_name, pkg_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, volume_l, remarks,
} = req.body;
if (!pkg_code || !pkg_name) {
res.status(400).json({ success: false, message: "포장코드와 포장명은 필수입니다." });
return;
}
const dup = await pool.query(
`SELECT id FROM pkg_unit WHERE pkg_code = $1 AND company_code = $2`,
[pkg_code, companyCode]
);
if (dup.rowCount && dup.rowCount > 0) {
res.status(409).json({ success: false, message: "이미 존재하는 포장코드입니다." });
return;
}
const result = await pool.query(
`INSERT INTO pkg_unit
(company_code, pkg_code, pkg_name, pkg_type, status,
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, writer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *`,
[companyCode, pkg_code, pkg_name, pkg_type, status || "ACTIVE",
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks,
req.user!.userId]
);
logger.info("포장단위 등록", { companyCode, pkg_code });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("포장단위 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function updatePkgUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const {
pkg_name, pkg_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, volume_l, remarks,
} = req.body;
const result = await pool.query(
`UPDATE pkg_unit SET
pkg_name=$1, pkg_type=$2, status=$3,
width_mm=$4, length_mm=$5, height_mm=$6,
self_weight_kg=$7, max_load_kg=$8, volume_l=$9, remarks=$10,
updated_date=NOW(), writer=$11
WHERE id=$12 AND company_code=$13
RETURNING *`,
[pkg_name, pkg_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, volume_l, remarks,
req.user!.userId, id, companyCode]
);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("포장단위 수정", { companyCode, id });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("포장단위 수정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deletePkgUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await client.query("BEGIN");
await client.query(
`DELETE FROM pkg_unit_item WHERE pkg_code = (SELECT pkg_code FROM pkg_unit WHERE id=$1 AND company_code=$2) AND company_code=$2`,
[id, companyCode]
);
const result = await client.query(
`DELETE FROM pkg_unit WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
await client.query("COMMIT");
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("포장단위 삭제", { companyCode, id });
res.json({ success: true });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("포장단위 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// ──────────────────────────────────────────────
// 포장단위 매칭품목 (pkg_unit_item) CRUD
// ──────────────────────────────────────────────
export async function getPkgUnitItems(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { pkgCode } = req.params;
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`,
[pkgCode, companyCode]
);
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 createPkgUnitItem(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const { pkg_code, item_number, pkg_qty } = req.body;
if (!pkg_code || !item_number) {
res.status(400).json({ success: false, message: "포장코드와 품번은 필수입니다." });
return;
}
const result = await pool.query(
`INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer)
VALUES ($1,$2,$3,$4,$5)
RETURNING *`,
[companyCode, pkg_code, item_number, pkg_qty, req.user!.userId]
);
logger.info("매칭품목 추가", { companyCode, pkg_code, item_number });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("매칭품목 추가 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deletePkgUnitItem(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("매칭품목 삭제", { companyCode, id });
res.json({ success: true });
} catch (error: any) {
logger.error("매칭품목 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ──────────────────────────────────────────────
// 적재함 (loading_unit) CRUD
// ──────────────────────────────────────────────
export async function getLoadingUnits(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
let sql: string;
let params: any[];
if (companyCode === "*") {
sql = `SELECT * FROM loading_unit ORDER BY company_code, created_date DESC`;
params = [];
} else {
sql = `SELECT * FROM loading_unit WHERE company_code = $1 ORDER BY created_date DESC`;
params = [companyCode];
}
const result = await pool.query(sql, params);
logger.info("적재함 목록 조회", { 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 createLoadingUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const {
loading_code, loading_name, loading_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, max_stack, remarks,
} = req.body;
if (!loading_code || !loading_name) {
res.status(400).json({ success: false, message: "적재함코드와 적재함명은 필수입니다." });
return;
}
const dup = await pool.query(
`SELECT id FROM loading_unit WHERE loading_code=$1 AND company_code=$2`,
[loading_code, companyCode]
);
if (dup.rowCount && dup.rowCount > 0) {
res.status(409).json({ success: false, message: "이미 존재하는 적재함코드입니다." });
return;
}
const result = await pool.query(
`INSERT INTO loading_unit
(company_code, loading_code, loading_name, loading_type, status,
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, writer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *`,
[companyCode, loading_code, loading_name, loading_type, status || "ACTIVE",
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks,
req.user!.userId]
);
logger.info("적재함 등록", { companyCode, loading_code });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("적재함 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateLoadingUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const {
loading_name, loading_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, max_stack, remarks,
} = req.body;
const result = await pool.query(
`UPDATE loading_unit SET
loading_name=$1, loading_type=$2, status=$3,
width_mm=$4, length_mm=$5, height_mm=$6,
self_weight_kg=$7, max_load_kg=$8, max_stack=$9, remarks=$10,
updated_date=NOW(), writer=$11
WHERE id=$12 AND company_code=$13
RETURNING *`,
[loading_name, loading_type, status,
width_mm, length_mm, height_mm,
self_weight_kg, max_load_kg, max_stack, remarks,
req.user!.userId, id, companyCode]
);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("적재함 수정", { companyCode, id });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("적재함 수정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteLoadingUnit(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await client.query("BEGIN");
await client.query(
`DELETE FROM loading_unit_pkg WHERE loading_code = (SELECT loading_code FROM loading_unit WHERE id=$1 AND company_code=$2) AND company_code=$2`,
[id, companyCode]
);
const result = await client.query(
`DELETE FROM loading_unit WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
await client.query("COMMIT");
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("적재함 삭제", { companyCode, id });
res.json({ success: true });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("적재함 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// ──────────────────────────────────────────────
// 적재함 포장구성 (loading_unit_pkg) CRUD
// ──────────────────────────────────────────────
export async function getLoadingUnitPkgs(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { loadingCode } = req.params;
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`,
[loadingCode, companyCode]
);
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 createLoadingUnitPkg(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const { loading_code, pkg_code, max_load_qty, load_method } = req.body;
if (!loading_code || !pkg_code) {
res.status(400).json({ success: false, message: "적재함코드와 포장코드는 필수입니다." });
return;
}
const result = await pool.query(
`INSERT INTO loading_unit_pkg (company_code, loading_code, pkg_code, max_load_qty, load_method, writer)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING *`,
[companyCode, loading_code, pkg_code, max_load_qty, load_method, req.user!.userId]
);
logger.info("적재구성 추가", { companyCode, loading_code, pkg_code });
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("적재구성 추가 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteLoadingUnitPkg(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return;
}
logger.info("적재구성 삭제", { companyCode, id });
res.json({ success: true });
} catch (error: any) {
logger.error("적재구성 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -1,10 +1,36 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
} from "../controllers/packagingController";
const router = Router();
router.use(authenticateToken);
// TODO: 포장/적재정보 관리 API 구현 예정
// 포장단위
router.get("/pkg-units", getPkgUnits);
router.post("/pkg-units", createPkgUnit);
router.put("/pkg-units/:id", updatePkgUnit);
router.delete("/pkg-units/:id", deletePkgUnit);
// 포장단위 매칭품목
router.get("/pkg-unit-items/:pkgCode", getPkgUnitItems);
router.post("/pkg-unit-items", createPkgUnitItem);
router.delete("/pkg-unit-items/:id", deletePkgUnitItem);
// 적재함
router.get("/loading-units", getLoadingUnits);
router.post("/loading-units", createLoadingUnit);
router.put("/loading-units/:id", updateLoadingUnit);
router.delete("/loading-units/:id", deleteLoadingUnit);
// 적재함 포장구성
router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
export default router;

View File

@ -2346,19 +2346,24 @@ export class ScreenManagementService {
}
/**
* ( Raw Query )
*
* company_code 매칭: 본인 + SUPER_ADMIN ('*')
* ,
*/
async getScreensByMenu(
menuObjid: number,
companyCode: string,
): Promise<ScreenDefinition[]> {
const screens = await query<any>(
`SELECT sd.* FROM screen_menu_assignments sma
`SELECT sd.*
FROM screen_menu_assignments sma
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
WHERE sma.menu_objid = $1
AND sma.company_code = $2
AND (sma.company_code = $2 OR sma.company_code = '*')
AND sma.is_active = 'Y'
ORDER BY sma.display_order ASC`,
ORDER BY
CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END,
sma.display_order ASC`,
[menuObjid, companyCode],
);

View File

@ -1,8 +1,10 @@
"use client";
import React, { useMemo } from "react";
import React, { useMemo, useState, useEffect } from "react";
import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react";
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
import { apiClient } from "@/lib/api/client";
const LoadingFallback = () => (
<div className="flex h-full items-center justify-center">
@ -10,11 +12,52 @@ const LoadingFallback = () => (
</div>
);
/**
* URL .
* .
* URL은 catch-all fallback으로 .
*/
function ScreenCodeResolver({ screenCode }: { screenCode: string }) {
const [screenId, setScreenId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const numericId = parseInt(screenCode);
if (!isNaN(numericId)) {
setScreenId(numericId);
setLoading(false);
return;
}
const resolve = async () => {
try {
const res = await apiClient.get("/screen-management/screens", {
params: { searchTerm: screenCode, size: 50 },
});
const items = res.data?.data?.data || res.data?.data || [];
const arr = Array.isArray(items) ? items : [];
const exact = arr.find((s: any) => s.screenCode === screenCode);
const target = exact || arr[0];
if (target) setScreenId(target.screenId || target.screen_id);
} catch {
console.error("스크린 코드 변환 실패:", screenCode);
} finally {
setLoading(false);
}
};
resolve();
}, [screenCode]);
if (loading) return <LoadingFallback />;
if (!screenId) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground"> (: {screenCode})</p>
</div>
);
}
return <ScreenViewPageWrapper screenIdProp={screenId} />;
}
const DashboardViewPage = dynamic(
() => import("@/app/(main)/dashboard/[dashboardId]/page"),
{ ssr: false, loading: LoadingFallback },
);
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
// 관리자 메인
"/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }),
@ -62,6 +105,16 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }),
// 결재 관리
"/admin/approvalTemplate": dynamic(() => import("@/app/(main)/admin/approvalTemplate/page"), { ssr: false, loading: LoadingFallback }),
"/admin/approvalBox": dynamic(() => import("@/app/(main)/admin/approvalBox/page"), { ssr: false, loading: LoadingFallback }),
"/admin/approvalMng": dynamic(() => import("@/app/(main)/admin/approvalMng/page"), { ssr: false, loading: LoadingFallback }),
// 시스템
"/admin/audit-log": dynamic(() => import("@/app/(main)/admin/audit-log/page"), { ssr: false, loading: LoadingFallback }),
"/admin/system-notices": dynamic(() => import("@/app/(main)/admin/system-notices/page"), { ssr: false, loading: LoadingFallback }),
"/admin/aiAssistant": dynamic(() => import("@/app/(main)/admin/aiAssistant/page"), { ssr: false, loading: LoadingFallback }),
// 기타
"/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }),
@ -73,18 +126,115 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }),
};
// 매핑되지 않은 URL용 Fallback
const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
"/admin/aiAssistant/dashboard": () => import("@/app/(main)/admin/aiAssistant/dashboard/page"),
"/admin/aiAssistant/history": () => import("@/app/(main)/admin/aiAssistant/history/page"),
"/admin/aiAssistant/api-keys": () => import("@/app/(main)/admin/aiAssistant/api-keys/page"),
"/admin/aiAssistant/api-test": () => import("@/app/(main)/admin/aiAssistant/api-test/page"),
"/admin/aiAssistant/usage": () => import("@/app/(main)/admin/aiAssistant/usage/page"),
"/admin/aiAssistant/chat": () => import("@/app/(main)/admin/aiAssistant/chat/page"),
"/admin/screenMng/barcodeList": () => import("@/app/(main)/admin/screenMng/barcodeList/page"),
"/admin/automaticMng/batchmngList/create": () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"),
"/admin/systemMng/dataflow/node-editorList": () => import("@/app/(main)/admin/systemMng/dataflow/node-editorList/page"),
"/admin/standards/new": () => import("@/app/(main)/admin/standards/new/page"),
};
const DYNAMIC_ADMIN_PATTERNS: Array<{
pattern: RegExp;
getImport: (match: RegExpMatchArray) => Promise<any>;
extractParams: (match: RegExpMatchArray) => Record<string, string>;
}> = [
{
pattern: /^\/admin\/userMng\/rolesList\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/userMng/rolesList/[id]/page"),
extractParams: (m) => ({ id: m[1] }),
},
{
pattern: /^\/admin\/screenMng\/dashboardList\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"),
extractParams: (m) => ({ id: m[1] }),
},
{
pattern: /^\/admin\/automaticMng\/flowMgmtList\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/automaticMng/flowMgmtList/[id]/page"),
extractParams: (m) => ({ id: m[1] }),
},
{
pattern: /^\/admin\/automaticMng\/batchmngList\/edit\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page"),
extractParams: (m) => ({ id: m[1] }),
},
{
pattern: /^\/admin\/screenMng\/barcodeList\/designer\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page"),
extractParams: (m) => ({ labelId: m[1] }),
},
{
pattern: /^\/admin\/screenMng\/reportList\/designer\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/screenMng/reportList/designer/[reportId]/page"),
extractParams: (m) => ({ reportId: m[1] }),
},
{
pattern: /^\/admin\/systemMng\/dataflow\/edit\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"),
extractParams: (m) => ({ diagramId: m[1] }),
},
{
pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/,
getImport: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"),
extractParams: (m) => ({ companyCode: m[1] }),
},
{
pattern: /^\/admin\/standards\/([^/]+)\/edit$/,
getImport: () => import("@/app/(main)/admin/standards/[webType]/edit/page"),
extractParams: (m) => ({ webType: m[1] }),
},
{
pattern: /^\/admin\/standards\/([^/]+)$/,
getImport: () => import("@/app/(main)/admin/standards/[webType]/page"),
extractParams: (m) => ({ webType: m[1] }),
},
];
function DynamicAdminLoader({ url, params }: { url: string; params?: Record<string, string> }) {
const [Component, setComponent] = useState<React.ComponentType<any> | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const staticImport = DYNAMIC_ADMIN_IMPORTS[url];
if (staticImport) {
staticImport()
.then((mod) => setComponent(() => mod.default))
.catch(() => setFailed(true));
return;
}
for (const { pattern, getImport, extractParams } of DYNAMIC_ADMIN_PATTERNS) {
const match = url.match(pattern);
if (match) {
getImport()
.then((mod) => setComponent(() => mod.default))
.catch(() => setFailed(true));
return;
}
}
setFailed(true);
}, [url]);
if (failed) return <AdminPageFallback url={url} />;
if (!Component) return <LoadingFallback />;
if (params) return <Component params={Promise.resolve(params)} />;
return <Component />;
}
function AdminPageFallback({ url }: { url: string }) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-lg font-semibold text-foreground"> </p>
<p className="mt-1 text-sm text-muted-foreground">
: {url}
</p>
<p className="mt-2 text-xs text-muted-foreground">
AdminPageRenderer URL을 .
</p>
<p className="mt-1 text-sm text-muted-foreground">: {url}</p>
<p className="mt-2 text-xs text-muted-foreground"> .</p>
</div>
</div>
);
@ -95,15 +245,58 @@ interface AdminPageRendererProps {
}
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
const PageComponent = useMemo(() => {
// URL에서 쿼리스트링/해시 제거 후 매칭
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
}, [url]);
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
if (!PageComponent) {
return <AdminPageFallback url={url} />;
console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl });
// 화면 할당: /screens/[id]
const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/);
if (screensIdMatch) {
console.log("[AdminPageRenderer] → /screens/[id] 매칭:", screensIdMatch[1]);
return <ScreenViewPageWrapper screenIdProp={parseInt(screensIdMatch[1])} />;
}
return <PageComponent />;
// 화면 할당: /screen/[code] (구 형식)
const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/);
if (screenCodeMatch) {
console.log("[AdminPageRenderer] → /screen/[code] 매칭:", screenCodeMatch[1]);
return <ScreenCodeResolver screenCode={screenCodeMatch[1]} />;
}
// 대시보드 할당: /dashboard/[id]
const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/);
if (dashboardMatch) {
console.log("[AdminPageRenderer] → /dashboard/[id] 매칭:", dashboardMatch[1]);
return <DashboardViewPage params={Promise.resolve({ dashboardId: dashboardMatch[1] })} />;
}
// URL 직접 입력: 레지스트리 매칭
const PageComponent = useMemo(() => {
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
}, [cleanUrl]);
if (PageComponent) {
console.log("[AdminPageRenderer] → 레지스트리 매칭:", cleanUrl);
return <PageComponent />;
}
// 레지스트리에 없으면 동적 import 시도
// 동적 라우트 패턴 매칭 (params 추출)
for (const { pattern, extractParams } of DYNAMIC_ADMIN_PATTERNS) {
const match = cleanUrl.match(pattern);
if (match) {
const params = extractParams(match);
console.log("[AdminPageRenderer] → 동적 라우트 매칭:", cleanUrl, params);
return <DynamicAdminLoader url={cleanUrl} params={params} />;
}
}
// 정적 동적 import 목록에 있으면
if (DYNAMIC_ADMIN_IMPORTS[cleanUrl]) {
console.log("[AdminPageRenderer] → 동적 import:", cleanUrl);
return <DynamicAdminLoader url={cleanUrl} />;
}
console.error("[AdminPageRenderer] 미등록 URL:", cleanUrl);
return <AdminPageFallback url={url} />;
}

View File

@ -202,12 +202,26 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle);
const menuUrl = menu.menu_url || menu.MENU_URL || "#";
const screenCode = menu.screen_code || menu.SCREEN_CODE || null;
const menuType = String(menu.menu_type ?? menu.MENU_TYPE ?? "");
let screenId: number | null = null;
const screensMatch = menuUrl.match(/^\/screens\/(\d+)/);
if (screensMatch) {
screenId = parseInt(screensMatch[1]);
}
return {
id: menuId,
objid: menuId,
name: displayName,
tabTitle,
icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON),
url: menu.menu_url || menu.MENU_URL || "#",
url: menuUrl,
screenCode,
screenId,
menuType,
children: children.length > 0 ? children : undefined,
hasChildren: children.length > 0,
};
@ -341,42 +355,76 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const handleMenuClick = async (menu: any) => {
if (menu.hasChildren) {
toggleMenu(menu.id);
} else {
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
if (typeof window !== "undefined") {
localStorage.setItem("currentMenuName", menuName);
return;
}
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
if (typeof window !== "undefined") {
localStorage.setItem("currentMenuName", menuName);
}
const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0");
const isAdminMenu = menu.menuType === "0";
console.log("[handleMenuClick] 메뉴 클릭:", {
menuName,
menuObjid,
menuType: menu.menuType,
isAdminMenu,
screenId: menu.screenId,
screenCode: menu.screenCode,
url: menu.url,
fullMenu: menu,
});
// 관리자 메뉴 (menu_type = 0): URL 직접 입력 → admin 탭
if (isAdminMenu) {
if (menu.url && menu.url !== "#") {
console.log("[handleMenuClick] → admin 탭:", menu.url);
openTab({ type: "admin", title: menuName, adminUrl: menu.url });
if (isMobile) setSidebarOpen(false);
} else {
toast.warning("이 메뉴에는 연결된 페이지가 없습니다.");
}
return;
}
// 사용자 메뉴 (menu_type = 1, 2): 화면/대시보드 할당
// 1) screenId가 메뉴 URL에서 추출된 경우 바로 screen 탭
if (menu.screenId) {
console.log("[handleMenuClick] → screen 탭 (URL에서 screenId 추출):", menu.screenId);
openTab({ type: "screen", title: menuName, screenId: menu.screenId, menuObjid });
if (isMobile) setSidebarOpen(false);
return;
}
// 2) screen_menu_assignments 테이블 조회
if (menuObjid) {
try {
const menuObjid = menu.objid || menu.id;
console.log("[handleMenuClick] → screen_menu_assignments 조회 시도, menuObjid:", menuObjid);
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
console.log("[handleMenuClick] → 조회 결과:", assignedScreens);
if (assignedScreens.length > 0) {
const firstScreen = assignedScreens[0];
openTab({
type: "screen",
title: menuName,
screenId: firstScreen.screenId,
menuObjid: parseInt(menuObjid),
});
console.log("[handleMenuClick] → screen 탭 (assignments):", assignedScreens[0].screenId);
openTab({ type: "screen", title: menuName, screenId: assignedScreens[0].screenId, menuObjid });
if (isMobile) setSidebarOpen(false);
return;
}
} catch {
console.warn("할당된 화면 조회 실패");
}
if (menu.url && menu.url !== "#") {
openTab({
type: "admin",
title: menuName,
adminUrl: menu.url,
});
if (isMobile) setSidebarOpen(false);
} else {
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
} catch (err) {
console.error("[handleMenuClick] 할당된 화면 조회 실패:", err);
}
}
// 3) 대시보드 할당 (/dashboard/xxx) → admin 탭으로 렌더링 (AdminPageRenderer가 처리)
if (menu.url && menu.url.startsWith("/dashboard/")) {
console.log("[handleMenuClick] → 대시보드 탭:", menu.url);
openTab({ type: "admin", title: menuName, adminUrl: menu.url });
if (isMobile) setSidebarOpen(false);
return;
}
console.warn("[handleMenuClick] 어떤 조건에도 매칭 안 됨:", { menuName, menuType: menu.menuType, url: menu.url, screenId: menu.screenId });
toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요.");
};
const handleModeSwitch = () => {

View File

@ -226,6 +226,14 @@ function TabPageRenderer({
tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string };
refreshKey: number;
}) {
console.log("[TabPageRenderer] 탭 렌더링:", {
tabId: tab.id,
type: tab.type,
screenId: tab.screenId,
adminUrl: tab.adminUrl,
menuObjid: tab.menuObjid,
});
if (tab.type === "screen" && tab.screenId != null) {
return (
<ScreenViewPageWrapper
@ -244,5 +252,6 @@ function TabPageRenderer({
);
}
console.warn("[TabPageRenderer] 렌더링 불가 - 매칭 조건 없음:", tab);
return null;
}

View File

@ -480,6 +480,72 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
</div>
</>
)}
{/* 데이터 바인딩 설정 */}
<Separator className="my-2" />
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="dataBindingEnabled"
checked={!!config.dataBinding?.sourceComponentId}
onCheckedChange={(checked) => {
if (checked) {
updateConfig("dataBinding", {
sourceComponentId: config.dataBinding?.sourceComponentId || "",
sourceColumn: config.dataBinding?.sourceColumn || "",
});
} else {
updateConfig("dataBinding", undefined);
}
}}
/>
<Label htmlFor="dataBindingEnabled" className="text-xs font-semibold">
</Label>
</div>
{config.dataBinding && (
<div className="space-y-2 rounded border p-2">
<p className="text-[10px] text-muted-foreground">
v2-table-list에서
</p>
<div className="space-y-1">
<Label className="text-xs font-medium"> ID</Label>
<Input
value={config.dataBinding?.sourceComponentId || ""}
onChange={(e) => {
updateConfig("dataBinding", {
...config.dataBinding,
sourceComponentId: e.target.value,
});
}}
placeholder="예: tbl_items"
className="h-7 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
v2-table-list ID
</p>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium"> </Label>
<Input
value={config.dataBinding?.sourceColumn || ""}
onChange={(e) => {
updateConfig("dataBinding", {
...config.dataBinding,
sourceColumn: e.target.value,
});
}}
placeholder="예: item_number"
className="h-7 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
</div>
)}
</div>
</div>
);
};

View File

@ -42,6 +42,8 @@ export interface MenuItem {
TRANSLATED_DESC?: string;
menu_icon?: string;
MENU_ICON?: string;
screen_code?: string;
SCREEN_CODE?: string;
}
export interface MenuFormData {

View File

@ -1,10 +1,78 @@
"use client";
import React from "react";
import React, { useEffect, useRef } from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2InputDefinition } from "./index";
import { V2Input } from "@/components/v2/V2Input";
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
/**
* dataBinding이 v2-input을 wrapper
* v2-table-list의 TABLE_DATA_CHANGE
* formData에
*/
function DataBindingWrapper({
dataBinding,
columnName,
onFormDataChange,
isInteractive,
children,
}: {
dataBinding: { sourceComponentId: string; sourceColumn: string };
columnName: string;
onFormDataChange?: (field: string, value: any) => void;
isInteractive?: boolean;
children: React.ReactNode;
}) {
const lastBoundValueRef = useRef<any>(null);
useEffect(() => {
if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return;
console.log("[DataBinding] 구독 시작:", {
sourceComponentId: dataBinding.sourceComponentId,
sourceColumn: dataBinding.sourceColumn,
targetColumn: columnName,
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
});
const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_DATA_CHANGE, (payload: any) => {
console.log("[DataBinding] TABLE_DATA_CHANGE 수신:", {
payloadSource: payload.source,
expectedSource: dataBinding.sourceComponentId,
dataLength: payload.data?.length,
match: payload.source === dataBinding.sourceComponentId,
});
if (payload.source !== dataBinding.sourceComponentId) return;
const selectedData = payload.data;
if (selectedData && selectedData.length > 0) {
const value = selectedData[0][dataBinding.sourceColumn];
console.log("[DataBinding] 바인딩 값:", { column: dataBinding.sourceColumn, value, columnName });
if (value !== lastBoundValueRef.current) {
lastBoundValueRef.current = value;
if (onFormDataChange && columnName) {
onFormDataChange(columnName, value ?? "");
}
}
} else {
if (lastBoundValueRef.current !== null) {
lastBoundValueRef.current = null;
if (onFormDataChange && columnName) {
onFormDataChange(columnName, "");
}
}
}
});
return () => unsubscribe();
}, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName, onFormDataChange, isInteractive]);
return <>{children}</>;
}
/**
* V2Input
@ -16,41 +84,37 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
render(): React.ReactElement {
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props;
// 컴포넌트 설정 추출
const config = component.componentConfig || component.config || {};
const columnName = component.columnName;
const tableName = component.tableName || this.props.tableName;
// formData에서 현재 값 가져오기
const currentValue = formData?.[columnName] ?? component.value ?? "";
// 값 변경 핸들러
const handleChange = (value: any) => {
console.log("🔄 [V2InputRenderer] handleChange 호출:", {
columnName,
value,
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
});
if (isInteractive && onFormDataChange && columnName) {
onFormDataChange(columnName, value);
} else {
console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", {
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
columnName,
});
}
};
// 라벨: style.labelText 우선, 없으면 component.label 사용
// 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 style 객체로 업데이트하므로)
const style = component.style || {};
const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay;
// labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김)
const effectiveLabel = labelDisplay === true ? style.labelText || component.label : undefined;
return (
const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding;
if (dataBinding || (config as any).dataBinding || (component as any).dataBinding) {
console.log("[V2InputRenderer] dataBinding 탐색:", {
componentId: component.id,
columnName,
configKeys: Object.keys(config),
configDataBinding: config.dataBinding,
componentDataBinding: (component as any).dataBinding,
nestedDataBinding: config.componentConfig?.dataBinding,
finalDataBinding: dataBinding,
});
}
const inputElement = (
<V2Input
id={component.id}
value={currentValue}
@ -77,10 +141,26 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
{...restProps}
label={effectiveLabel}
required={component.required || isColumnRequiredByMeta(tableName, columnName)}
readonly={config.readonly || component.readonly}
readonly={config.readonly || component.readonly || !!dataBinding?.sourceComponentId}
disabled={config.disabled || component.disabled}
/>
);
// dataBinding이 있으면 wrapper로 감싸서 이벤트 구독
if (dataBinding?.sourceComponentId && dataBinding?.sourceColumn) {
return (
<DataBindingWrapper
dataBinding={dataBinding}
columnName={columnName}
onFormDataChange={onFormDataChange}
isInteractive={isInteractive}
>
{inputElement}
</DataBindingWrapper>
);
}
return inputElement;
}
}

View File

@ -5103,6 +5103,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}}
/>
)}
</div>
);
};

View File

@ -118,9 +118,9 @@ export interface AdditionalTabConfig {
// 추가 버튼 설정 (모달 화면 연결 지원)
addButton?: {
enabled: boolean;
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
mode: "auto" | "modal";
modalScreenId?: number;
buttonLabel?: string;
};
deleteButton?: {
@ -161,9 +161,9 @@ export interface SplitPanelLayoutConfig {
// 추가 버튼 설정 (모달 화면 연결 지원)
addButton?: {
enabled: boolean;
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
mode: "auto" | "modal";
modalScreenId?: number;
buttonLabel?: string;
};
columns?: Array<{
@ -334,10 +334,10 @@ export interface SplitPanelLayoutConfig {
// 🆕 추가 버튼 설정 (모달 화면 연결 지원)
addButton?: {
enabled: boolean; // 추가 버튼 표시 여부 (기본: true)
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
enabled: boolean;
mode: "auto" | "modal";
modalScreenId?: number;
buttonLabel?: string;
};
// 🆕 삭제 버튼 설정

View File

@ -2080,11 +2080,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
const handleRowSelection = (rowKey: string, checked: boolean) => {
const newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
const isMultiSelect = tableConfig.checkbox?.multiple !== false;
let newSelectedRows: Set<string>;
if (isMultiSelect) {
newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
} else {
newSelectedRows.delete(rowKey);
}
} else {
newSelectedRows.delete(rowKey);
// 단일 선택: 기존 선택 해제 후 새 항목만 선택
newSelectedRows = checked ? new Set([rowKey]) : new Set();
}
setSelectedRows(newSelectedRows);
@ -4154,6 +4162,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const renderCheckboxHeader = () => {
if (!tableConfig.checkbox?.selectAll) return null;
if (tableConfig.checkbox?.multiple === false) return null;
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
};