diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts new file mode 100644 index 00000000..c804963f --- /dev/null +++ b/backend-node/src/controllers/packagingController.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }); + } +} diff --git a/backend-node/src/routes/packagingRoutes.ts b/backend-node/src/routes/packagingRoutes.ts index f501269e..db921caa 100644 --- a/backend-node/src/routes/packagingRoutes.ts +++ b/backend-node/src/routes/packagingRoutes.ts @@ -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; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 4c5bdc57..9d5d56a5 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2346,19 +2346,24 @@ export class ScreenManagementService { } /** - * 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료) + * 메뉴별 화면 목록 조회 + * company_code 매칭: 본인 회사 할당 + SUPER_ADMIN 글로벌 할당('*') 모두 조회 + * 본인 회사 할당이 우선, 없으면 글로벌 할당 사용 */ async getScreensByMenu( menuObjid: number, companyCode: string, ): Promise { const screens = await query( - `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], ); diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 20175b5e..6f7ba4a4 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -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 = () => (
@@ -10,11 +12,52 @@ const LoadingFallback = () => (
); -/** - * 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리. - * 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다. - * 매핑되지 않은 URL은 catch-all fallback으로 처리된다. - */ +function ScreenCodeResolver({ screenCode }: { screenCode: string }) { + const [screenId, setScreenId] = useState(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 ; + if (!screenId) { + return ( +
+

화면을 찾을 수 없습니다 (코드: {screenCode})

+
+ ); + } + return ; +} + +const DashboardViewPage = dynamic( + () => import("@/app/(main)/dashboard/[dashboardId]/page"), + { ssr: false, loading: LoadingFallback }, +); + const ADMIN_PAGE_REGISTRY: Record> = { // 관리자 메인 "/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }), @@ -62,6 +105,16 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/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> = { "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }), }; -// 매핑되지 않은 URL용 Fallback +const DYNAMIC_ADMIN_IMPORTS: Record Promise> = { + "/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; + extractParams: (match: RegExpMatchArray) => Record; +}> = [ + { + 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 }) { + const [Component, setComponent] = useState | 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 ; + if (!Component) return ; + if (params) return ; + return ; +} + function AdminPageFallback({ url }: { url: string }) { return (

페이지 로딩 불가

-

- 경로: {url} -

-

- AdminPageRenderer 레지스트리에 이 URL을 추가해주세요. -

+

경로: {url}

+

해당 페이지가 존재하지 않습니다.

); @@ -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 ; + console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl }); + + // 화면 할당: /screens/[id] + const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/); + if (screensIdMatch) { + console.log("[AdminPageRenderer] → /screens/[id] 매칭:", screensIdMatch[1]); + return ; } - return ; + // 화면 할당: /screen/[code] (구 형식) + const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/); + if (screenCodeMatch) { + console.log("[AdminPageRenderer] → /screen/[code] 매칭:", screenCodeMatch[1]); + return ; + } + + // 대시보드 할당: /dashboard/[id] + const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/); + if (dashboardMatch) { + console.log("[AdminPageRenderer] → /dashboard/[id] 매칭:", dashboardMatch[1]); + return ; + } + + // URL 직접 입력: 레지스트리 매칭 + const PageComponent = useMemo(() => { + return ADMIN_PAGE_REGISTRY[cleanUrl] || null; + }, [cleanUrl]); + + if (PageComponent) { + console.log("[AdminPageRenderer] → 레지스트리 매칭:", cleanUrl); + return ; + } + + // 레지스트리에 없으면 동적 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 ; + } + } + + // 정적 동적 import 목록에 있으면 + if (DYNAMIC_ADMIN_IMPORTS[cleanUrl]) { + console.log("[AdminPageRenderer] → 동적 import:", cleanUrl); + return ; + } + + console.error("[AdminPageRenderer] 미등록 URL:", cleanUrl); + return ; } diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index ad9a6aaf..095552d5 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -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 = () => { diff --git a/frontend/components/layout/TabContent.tsx b/frontend/components/layout/TabContent.tsx index 0c1fabfb..d6e36817 100644 --- a/frontend/components/layout/TabContent.tsx +++ b/frontend/components/layout/TabContent.tsx @@ -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 ( = ({ config, )} + + {/* 데이터 바인딩 설정 */} + +
+
+ { + if (checked) { + updateConfig("dataBinding", { + sourceComponentId: config.dataBinding?.sourceComponentId || "", + sourceColumn: config.dataBinding?.sourceColumn || "", + }); + } else { + updateConfig("dataBinding", undefined); + } + }} + /> + +
+ + {config.dataBinding && ( +
+

+ v2-table-list에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다 +

+
+ + { + updateConfig("dataBinding", { + ...config.dataBinding, + sourceComponentId: e.target.value, + }); + }} + placeholder="예: tbl_items" + className="h-7 text-xs" + /> +

+ 같은 화면 내 v2-table-list 컴포넌트의 ID +

+
+
+ + { + updateConfig("dataBinding", { + ...config.dataBinding, + sourceColumn: e.target.value, + }); + }} + placeholder="예: item_number" + className="h-7 text-xs" + /> +

+ 선택된 행에서 가져올 컬럼명 +

+
+
+ )} +
); }; diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index 8611aeda..adbd53a0 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -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 { diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index 90c4f801..b6f929be 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -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(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 = ( ); + + // dataBinding이 있으면 wrapper로 감싸서 이벤트 구독 + if (dataBinding?.sourceComponentId && dataBinding?.sourceColumn) { + return ( + + {inputElement} + + ); + } + + return inputElement; } } diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 94ab366a..9d987d5e 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -5103,6 +5103,7 @@ export const SplitPanelLayoutComponent: React.FC }} /> )} + ); }; diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index b738d317..ed41f578 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -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; }; // 🆕 삭제 버튼 설정 diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 2accfe1e..b7524f41 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -2080,11 +2080,19 @@ export const TableListComponent: React.FC = ({ }; 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; + + 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 = ({ const renderCheckboxHeader = () => { if (!tableConfig.checkbox?.selectAll) return null; + if (tableConfig.checkbox?.multiple === false) return null; return ; };