feat: Enhance entity options retrieval with additional fields support

- Updated the `getEntityOptions` function to accept an optional `fields` parameter, allowing clients to specify additional columns to be retrieved.
- Implemented logic to dynamically include extra columns in the SQL query based on the provided `fields`, improving flexibility in data retrieval.
- Enhanced the response to indicate whether extra fields were included, facilitating better client-side handling of the data.
- Added logging for authentication failures in the `AuthGuard` component to improve debugging and user experience.
- Integrated auto-fill functionality in the `V2Select` component to automatically populate fields based on selected entity references, enhancing user interaction.
- Updated the `ItemSearchModal` to support multi-selection of items, improving usability in item management scenarios.
This commit is contained in:
DDD1542 2026-02-25 11:45:28 +09:00
parent 72068d003a
commit 2b175a21f4
15 changed files with 828 additions and 129 deletions

View File

@ -115,7 +115,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
try {
const { tableName } = req.params;
const { value = "id", label = "name" } = req.query;
const { value = "id", label = "name", fields } = req.query;
// tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
@ -167,9 +167,21 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// autoFill용 추가 컬럼 처리
let extraColumns = "";
if (fields && typeof fields === "string") {
const requestedFields = fields.split(",").map((f) => f.trim()).filter(Boolean);
const validExtra = requestedFields.filter(
(f) => existingColumns.has(f) && f !== valueColumn && f !== effectiveLabelColumn
);
if (validExtra.length > 0) {
extraColumns = ", " + validExtra.map((f) => `"${f}"`).join(", ");
}
}
// 쿼리 실행 (최대 500개)
const query = `
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label${extraColumns}
FROM ${tableName}
${whereClause}
ORDER BY ${effectiveLabelColumn} ASC
@ -184,6 +196,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
labelColumn: effectiveLabelColumn,
companyCode,
rowCount: result.rowCount,
extraFields: extraColumns ? true : false,
});
res.json({

View File

@ -3,6 +3,7 @@
import { useEffect, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { AuthLogger } from "@/lib/authLogger";
import { Loader2 } from "lucide-react";
interface AuthGuardProps {
@ -41,11 +42,13 @@ export function AuthGuard({
}
if (requireAuth && !isLoggedIn) {
AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`);
router.push(redirectTo);
return;
}
if (requireAdmin && !isAdmin) {
AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`);
router.push(redirectTo);
return;
}

View File

@ -3968,10 +3968,10 @@ export default function ScreenDesigner({
label: column.columnLabel || column.columnName,
tableName: table.tableName,
columnName: column.columnName,
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
componentType: v2Mapping.componentType, // v2-input, v2-select 등
required: isEntityJoinColumn ? false : column.required,
readonly: false,
parentId: formContainerId,
componentType: v2Mapping.componentType,
position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
@ -3995,12 +3995,11 @@ export default function ScreenDesigner({
},
componentConfig: {
type: v2Mapping.componentType, // v2-input, v2-select 등
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
...v2Mapping.componentConfig,
},
};
} else {
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
return;
}
} else {
// 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용
@ -4036,9 +4035,9 @@ export default function ScreenDesigner({
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
tableName: table.tableName,
columnName: column.columnName,
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
componentType: v2Mapping.componentType, // v2-input, v2-select 등
required: isEntityJoinColumn ? false : column.required,
readonly: false,
componentType: v2Mapping.componentType,
position: { x, y, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
@ -4062,8 +4061,7 @@ export default function ScreenDesigner({
},
componentConfig: {
type: v2Mapping.componentType, // v2-input, v2-select 등
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
...v2Mapping.componentConfig,
},
};
}

View File

@ -622,6 +622,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
config: configProp,
value,
onChange,
onFormDataChange,
tableName,
columnName,
isDesignMode, // 🔧 디자인 모드 (클릭 방지)
@ -630,6 +631,9 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
// config가 없으면 기본값 사용
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
// 엔티티 자동 채움: 같은 폼의 다른 컴포넌트 중 참조 테이블 컬럼을 자동 감지
const allComponents = (props as any).allComponents as any[] | undefined;
const [options, setOptions] = useState<SelectOption[]>(config.options || []);
const [loading, setLoading] = useState(false);
const [optionsLoaded, setOptionsLoaded] = useState(false);
@ -742,10 +746,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
const valueCol = entityValueColumn || "id";
const labelCol = entityLabelColumn || "name";
const response = await apiClient.get(`/entity/${entityTable}/options`, {
params: {
value: valueCol,
label: labelCol,
},
params: { value: valueCol, label: labelCol },
});
const data = response.data;
if (data.success && data.data) {
@ -819,6 +820,70 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
loadOptions();
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]);
// 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지
const autoFillTargets = useMemo(() => {
if (source !== "entity" || !entityTable || !allComponents) return [];
const targets: Array<{ sourceField: string; targetColumnName: string }> = [];
for (const comp of allComponents) {
if (comp.id === id) continue;
// overrides 구조 지원 (DB에서 로드 시 overrides 안에 데이터가 있음)
const ov = (comp as any).overrides || {};
const compColumnName = comp.columnName || ov.columnName || comp.componentConfig?.columnName || "";
// 방법1: entityJoinTable 속성이 있는 경우
const joinTable = comp.entityJoinTable || ov.entityJoinTable || comp.componentConfig?.entityJoinTable;
const joinColumn = comp.entityJoinColumn || ov.entityJoinColumn || comp.componentConfig?.entityJoinColumn;
if (joinTable === entityTable && joinColumn) {
targets.push({ sourceField: joinColumn, targetColumnName: compColumnName });
continue;
}
// 방법2: columnName이 "테이블명.컬럼명" 형식인 경우 (예: item_info.unit)
if (compColumnName.includes(".")) {
const [prefix, actualColumn] = compColumnName.split(".");
if (prefix === entityTable && actualColumn) {
targets.push({ sourceField: actualColumn, targetColumnName: compColumnName });
}
}
}
return targets;
}, [source, entityTable, allComponents, id]);
// 엔티티 autoFill 적용 래퍼
const handleChangeWithAutoFill = useCallback((newValue: string | string[]) => {
onChange?.(newValue);
if (autoFillTargets.length === 0 || !onFormDataChange || !entityTable) return;
const selectedKey = typeof newValue === "string" ? newValue : newValue[0];
if (!selectedKey) return;
const valueCol = entityValueColumn || "id";
apiClient.get(`/table-management/tables/${entityTable}/data-with-joins`, {
params: {
page: 1,
size: 1,
search: JSON.stringify({ [valueCol]: selectedKey }),
autoFilter: JSON.stringify({ enabled: true, filterColumn: "company_code", userField: "companyCode" }),
},
}).then((res) => {
const responseData = res.data?.data;
const rows = responseData?.data || responseData?.rows || [];
if (rows.length > 0) {
const fullData = rows[0];
for (const target of autoFillTargets) {
const sourceValue = fullData[target.sourceField];
if (sourceValue !== undefined) {
onFormDataChange(target.targetColumnName, sourceValue);
}
}
}
}).catch((err) => console.error("autoFill 조회 실패:", err));
}, [onChange, autoFillTargets, onFormDataChange, entityTable, entityValueColumn]);
// 모드별 컴포넌트 렌더링
const renderSelect = () => {
if (loading) {
@ -876,12 +941,12 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
switch (config.mode) {
case "dropdown":
case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운
case "combobox":
return (
<DropdownSelect
options={options}
value={value}
onChange={onChange}
onChange={handleChangeWithAutoFill}
placeholder="선택"
searchable={config.mode === "combobox" ? true : config.searchable}
multiple={config.multiple}
@ -897,18 +962,18 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<RadioSelect
options={options}
value={typeof value === "string" ? value : value?.[0]}
onChange={(v) => onChange?.(v)}
onChange={(v) => handleChangeWithAutoFill(v)}
disabled={isDisabled}
/>
);
case "check":
case "checkbox": // 🔧 기존 저장된 값 호환
case "checkbox":
return (
<CheckSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
@ -919,7 +984,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<TagSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
@ -930,7 +995,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<TagboxSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
onChange={handleChangeWithAutoFill}
placeholder={config.placeholder || "선택하세요"}
maxSelect={config.maxSelect}
disabled={isDisabled}
@ -943,7 +1008,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<ToggleSelect
options={options}
value={typeof value === "string" ? value : value?.[0]}
onChange={(v) => onChange?.(v)}
onChange={(v) => handleChangeWithAutoFill(v)}
disabled={isDisabled}
/>
);
@ -953,7 +1018,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<SwapSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
@ -964,7 +1029,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<DropdownSelect
options={options}
value={value}
onChange={onChange}
onChange={handleChangeWithAutoFill}
disabled={isDisabled}
style={heightStyle}
/>

View File

@ -302,6 +302,15 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
. .
</p>
)}
{/* 자동 채움 안내 */}
{config.entityTable && entityColumns.length > 0 && (
<div className="border-t pt-3">
<p className="text-muted-foreground text-[10px]">
({config.entityTable}) , .
</p>
</div>
)}
</div>
)}

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { apiCall } from "@/lib/api/client";
import { AuthLogger } from "@/lib/authLogger";
interface UserInfo {
userId: string;
@ -161,13 +162,15 @@ export const useAuth = () => {
const token = TokenManager.getToken();
if (!token || TokenManager.isTokenExpired(token)) {
AuthLogger.log("AUTH_CHECK_FAIL", `refreshUserData: 토큰 ${!token ? "없음" : "만료됨"}`);
setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false });
setLoading(false);
return;
}
// 토큰이 유효하면 우선 인증된 상태로 설정
AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작");
setAuthStatus({
isLoggedIn: true,
isAdmin: false,
@ -186,15 +189,16 @@ export const useAuth = () => {
};
setAuthStatus(finalAuthStatus);
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`);
// API 결과가 비인증이면 상태만 업데이트 (리다이렉트는 client.ts가 처리)
if (!finalAuthStatus.isLoggedIn) {
AuthLogger.log("AUTH_CHECK_FAIL", "API 응답에서 비인증 상태 반환 → 토큰 제거");
TokenManager.removeToken();
setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false });
}
} else {
// userInfo 조회 실패 → 토큰에서 최소 정보 추출하여 유지
AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도");
try {
const payload = JSON.parse(atob(token.split(".")[1]));
const tempUser: UserInfo = {
@ -210,14 +214,14 @@ export const useAuth = () => {
isAdmin: tempUser.isAdmin,
});
} catch {
// 토큰 파싱도 실패하면 비인증 상태로 전환
AuthLogger.log("AUTH_CHECK_FAIL", "토큰 파싱 실패 → 비인증 전환");
TokenManager.removeToken();
setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false });
}
}
} catch {
// API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도
AuthLogger.log("AUTH_CHECK_FAIL", "API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도");
try {
const payload = JSON.parse(atob(token.split(".")[1]));
const tempUser: UserInfo = {
@ -233,6 +237,7 @@ export const useAuth = () => {
isAdmin: tempUser.isAdmin,
});
} catch {
AuthLogger.log("AUTH_CHECK_FAIL", "최종 fallback 실패 → 비인증 전환");
TokenManager.removeToken();
setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false });
@ -408,19 +413,19 @@ export const useAuth = () => {
const token = TokenManager.getToken();
if (token && !TokenManager.isTokenExpired(token)) {
// 유효한 토큰 → 우선 인증 상태로 설정 후 API 확인
AuthLogger.log("AUTH_CHECK_START", `초기 인증 확인: 유효한 토큰 존재 (경로: ${window.location.pathname})`);
setAuthStatus({
isLoggedIn: true,
isAdmin: false,
});
refreshUserData();
} else if (token && TokenManager.isTokenExpired(token)) {
// 만료된 토큰 → 정리 (리다이렉트는 AuthGuard에서 처리)
AuthLogger.log("TOKEN_EXPIRED_DETECTED", `초기 확인 시 만료된 토큰 발견 → 정리 (경로: ${window.location.pathname})`);
TokenManager.removeToken();
setAuthStatus({ isLoggedIn: false, isAdmin: false });
setLoading(false);
} else {
// 토큰 없음 → 비인증 상태 (리다이렉트는 AuthGuard에서 처리)
AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`);
setAuthStatus({ isLoggedIn: false, isAdmin: false });
setLoading(false);
}

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { MenuItem, MenuState } from "@/types/menu";
import { apiClient } from "@/lib/api/client";
import { AuthLogger } from "@/lib/authLogger";
/**
*
@ -84,8 +85,8 @@ export const useMenu = (user: any, authLoading: boolean) => {
} else {
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
}
} catch {
// API 실패 시 빈 메뉴로 유지 (401은 client.ts 인터셉터가 리다이렉트 처리)
} catch (err: any) {
AuthLogger.log("MENU_LOAD_FAIL", `메뉴 로드 실패: ${err?.response?.status || err?.message || "unknown"}`);
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
}
}, [convertToUpperCaseKeys, buildMenuTree]);

View File

@ -1,4 +1,14 @@
import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios";
import { AuthLogger } from "@/lib/authLogger";
const authLog = (event: string, detail: string) => {
if (typeof window === "undefined") return;
try {
AuthLogger.log(event as any, detail);
} catch {
// 로거 실패해도 앱 동작에 영향 없음
}
};
// API URL 동적 설정 - 환경변수 우선 사용
const getApiBaseUrl = (): string => {
@ -149,9 +159,12 @@ const refreshToken = async (): Promise<string | null> => {
try {
const currentToken = TokenManager.getToken();
if (!currentToken) {
authLog("TOKEN_REFRESH_FAIL", "갱신 시도했으나 토큰 자체가 없음");
return null;
}
authLog("TOKEN_REFRESH_START", `남은시간: ${Math.round(TokenManager.getTimeUntilExpiry(currentToken) / 60000)}`);
const response = await axios.post(
`${API_BASE_URL}/auth/refresh`,
{},
@ -165,10 +178,13 @@ const refreshToken = async (): Promise<string | null> => {
if (response.data?.success && response.data?.data?.token) {
const newToken = response.data.data.token;
TokenManager.setToken(newToken);
authLog("TOKEN_REFRESH_SUCCESS", "토큰 갱신 완료");
return newToken;
}
authLog("TOKEN_REFRESH_FAIL", `API 응답 실패: success=${response.data?.success}`);
return null;
} catch {
} catch (err: any) {
authLog("TOKEN_REFRESH_FAIL", `API 호출 에러: ${err?.response?.status || err?.message || "unknown"}`);
return null;
}
};
@ -210,16 +226,21 @@ const setupVisibilityRefresh = (): void => {
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
const token = TokenManager.getToken();
if (!token) return;
if (!token) {
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음");
return;
}
if (TokenManager.isTokenExpired(token)) {
// 만료됐으면 갱신 시도
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 감지 → 갱신 시도");
refreshToken().then((newToken) => {
if (!newToken) {
authLog("REDIRECT_TO_LOGIN", "탭 복귀 후 토큰 갱신 실패로 리다이렉트");
redirectToLogin();
}
});
} else if (TokenManager.isTokenExpiringSoon(token)) {
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 임박 → 갱신 시도");
refreshToken();
}
}
@ -268,6 +289,7 @@ const redirectToLogin = (): void => {
if (isRedirecting) return;
if (window.location.pathname === "/login") return;
authLog("REDIRECT_TO_LOGIN", `리다이렉트 실행 (from: ${window.location.pathname})`);
isRedirecting = true;
TokenManager.removeToken();
window.location.href = "/login";
@ -301,15 +323,13 @@ apiClient.interceptors.request.use(
if (token) {
if (!TokenManager.isTokenExpired(token)) {
// 유효한 토큰 → 그대로 사용
config.headers.Authorization = `Bearer ${token}`;
} else {
// 만료된 토큰 → 갱신 시도 후 사용
authLog("TOKEN_EXPIRED_DETECTED", `요청 전 토큰 만료 감지 (${config.url}) → 갱신 시도`);
const newToken = await refreshToken();
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`;
}
// 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리)
}
}
@ -378,12 +398,16 @@ apiClient.interceptors.response.use(
// 401 에러 처리 (핵심 개선)
if (status === 401 && typeof window !== "undefined") {
const errorData = error.response?.data as { error?: { code?: string } };
const errorData = error.response?.data as { error?: { code?: string; details?: string } };
const errorCode = errorData?.error?.code;
const errorDetails = errorData?.error?.details;
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
authLog("API_401_RECEIVED", `URL: ${url} | 코드: ${errorCode || "없음"} | 상세: ${errorDetails || "없음"}`);
// 이미 재시도한 요청이면 로그인으로
if (originalRequest?._retry) {
authLog("REDIRECT_TO_LOGIN", `재시도 후에도 401 (${url}) → 로그인 리다이렉트`);
redirectToLogin();
return Promise.reject(error);
}
@ -395,6 +419,7 @@ apiClient.interceptors.response.use(
originalRequest._retry = true;
try {
authLog("API_401_RETRY", `토큰 만료로 갱신 후 재시도 (${url})`);
const newToken = await refreshToken();
if (newToken) {
isRefreshing = false;
@ -404,17 +429,18 @@ apiClient.interceptors.response.use(
} else {
isRefreshing = false;
onRefreshFailed(new Error("토큰 갱신 실패"));
authLog("REDIRECT_TO_LOGIN", `토큰 갱신 실패 (${url}) → 로그인 리다이렉트`);
redirectToLogin();
return Promise.reject(error);
}
} catch (refreshError) {
isRefreshing = false;
onRefreshFailed(refreshError as Error);
authLog("REDIRECT_TO_LOGIN", `토큰 갱신 예외 (${url}) → 로그인 리다이렉트`);
redirectToLogin();
return Promise.reject(error);
}
} else {
// 다른 요청이 이미 갱신 중 → 갱신 완료 대기 후 재시도
try {
const newToken = await waitForTokenRefresh();
originalRequest._retry = true;
@ -427,6 +453,7 @@ apiClient.interceptors.response.use(
}
// TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`);
redirectToLogin();
}

225
frontend/lib/authLogger.ts Normal file
View File

@ -0,0 +1,225 @@
/**
*
* - //
* - localStorage에
* - window.__AUTH_LOG.show()
*/
const STORAGE_KEY = "auth_debug_log";
const MAX_ENTRIES = 200;
export type AuthEventType =
| "TOKEN_SET"
| "TOKEN_REMOVED"
| "TOKEN_EXPIRED_DETECTED"
| "TOKEN_REFRESH_START"
| "TOKEN_REFRESH_SUCCESS"
| "TOKEN_REFRESH_FAIL"
| "REDIRECT_TO_LOGIN"
| "API_401_RECEIVED"
| "API_401_RETRY"
| "AUTH_CHECK_START"
| "AUTH_CHECK_SUCCESS"
| "AUTH_CHECK_FAIL"
| "AUTH_GUARD_BLOCK"
| "AUTH_GUARD_PASS"
| "MENU_LOAD_FAIL"
| "VISIBILITY_CHANGE"
| "MIDDLEWARE_REDIRECT";
interface AuthLogEntry {
timestamp: string;
event: AuthEventType;
detail: string;
tokenStatus: string;
url: string;
stack?: string;
}
function getTokenSummary(): string {
if (typeof window === "undefined") return "SSR";
const token = localStorage.getItem("authToken");
if (!token) return "없음";
try {
const payload = JSON.parse(atob(token.split(".")[1]));
const exp = payload.exp * 1000;
const now = Date.now();
const remainMs = exp - now;
if (remainMs <= 0) {
return `만료됨(${Math.abs(Math.round(remainMs / 60000))}분 전)`;
}
const remainMin = Math.round(remainMs / 60000);
const remainHour = Math.floor(remainMin / 60);
const min = remainMin % 60;
return `유효(${remainHour}h${min}m 남음, user:${payload.userId})`;
} catch {
return "파싱실패";
}
}
function getCallStack(): string {
try {
const stack = new Error().stack || "";
const lines = stack.split("\n").slice(3, 7);
return lines.map((l) => l.trim()).join(" <- ");
} catch {
return "";
}
}
function writeLog(event: AuthEventType, detail: string) {
if (typeof window === "undefined") return;
const entry: AuthLogEntry = {
timestamp: new Date().toISOString(),
event,
detail,
tokenStatus: getTokenSummary(),
url: window.location.pathname + window.location.search,
stack: getCallStack(),
};
// 콘솔 출력 (그룹)
const isError = ["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK"].includes(event);
const logFn = isError ? console.warn : console.debug;
logFn(`[AuthLog] ${event}: ${detail} | 토큰: ${entry.tokenStatus} | ${entry.url}`);
// localStorage에 저장
try {
const stored = localStorage.getItem(STORAGE_KEY);
const logs: AuthLogEntry[] = stored ? JSON.parse(stored) : [];
logs.push(entry);
// 최대 개수 초과 시 오래된 것 제거
while (logs.length > MAX_ENTRIES) {
logs.shift();
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(logs));
} catch {
// localStorage 공간 부족 등의 경우 무시
}
}
/**
*
*/
function getLogs(): AuthLogEntry[] {
if (typeof window === "undefined") return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/**
*
*/
function clearLogs() {
if (typeof window === "undefined") return;
localStorage.removeItem(STORAGE_KEY);
}
/**
*
*/
function showLogs(filter?: AuthEventType | "ERROR") {
const logs = getLogs();
if (logs.length === 0) {
console.log("[AuthLog] 저장된 로그가 없습니다.");
return;
}
let filtered = logs;
if (filter === "ERROR") {
filtered = logs.filter((l) =>
["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK", "AUTH_CHECK_FAIL", "TOKEN_EXPIRED_DETECTED"].includes(l.event)
);
} else if (filter) {
filtered = logs.filter((l) => l.event === filter);
}
console.log(`\n[AuthLog] 총 ${filtered.length}건 (전체 ${logs.length}건)`);
console.log("─".repeat(120));
filtered.forEach((entry, i) => {
const time = entry.timestamp.replace("T", " ").split(".")[0];
console.log(
`${i + 1}. [${time}] ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}\n`
);
});
}
/**
*
*/
function getLastRedirectReason(): AuthLogEntry | null {
const logs = getLogs();
for (let i = logs.length - 1; i >= 0; i--) {
if (logs[i].event === "REDIRECT_TO_LOGIN") {
return logs[i];
}
}
return null;
}
/**
*
*/
function downloadLogs() {
if (typeof window === "undefined") return;
const logs = getLogs();
if (logs.length === 0) {
console.log("[AuthLog] 저장된 로그가 없습니다.");
return;
}
const text = logs
.map((entry, i) => {
const time = entry.timestamp.replace("T", " ").split(".")[0];
return `[${i + 1}] ${time} | ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}`;
})
.join("\n\n");
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `auth-debug-log_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-")}.txt`;
a.click();
URL.revokeObjectURL(url);
console.log("[AuthLog] 로그 파일 다운로드 완료");
}
// 전역 접근 가능하게 등록
if (typeof window !== "undefined") {
(window as any).__AUTH_LOG = {
show: showLogs,
errors: () => showLogs("ERROR"),
clear: clearLogs,
download: downloadLogs,
lastRedirect: getLastRedirectReason,
raw: getLogs,
};
}
export const AuthLogger = {
log: writeLog,
getLogs,
clearLogs,
showLogs,
downloadLogs,
getLastRedirectReason,
};
export default AuthLogger;

View File

@ -8,6 +8,76 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter";
// 통합 폼 시스템 import
import { useV2FormOptional } from "@/components/v2/V2FormContext";
import { apiClient } from "@/lib/api/client";
// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵)
const columnMetaCache: Record<string, Record<string, any>> = {};
const columnMetaLoading: Record<string, Promise<void>> = {};
async function loadColumnMeta(tableName: string): Promise<void> {
if (columnMetaCache[tableName] || columnMetaLoading[tableName]) return;
columnMetaLoading[tableName] = (async () => {
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`);
const data = response.data.data || response.data;
const columns = data.columns || data || [];
const map: Record<string, any> = {};
for (const col of columns) {
const name = col.column_name || col.columnName;
if (name) map[name] = col;
}
columnMetaCache[tableName] = map;
} catch {
columnMetaCache[tableName] = {};
} finally {
delete columnMetaLoading[tableName];
}
})();
await columnMetaLoading[tableName];
}
// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완)
function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any {
if (!tableName || !columnName) return componentConfig;
const meta = columnMetaCache[tableName]?.[columnName];
if (!meta) return componentConfig;
const inputType = meta.input_type || meta.inputType;
if (!inputType) return componentConfig;
// 이미 source가 올바르게 설정된 경우 건드리지 않음
const existingSource = componentConfig?.source;
if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") {
return componentConfig;
}
const merged = { ...componentConfig };
// source가 미설정/기본값일 때만 DB 메타데이터로 보완
if (inputType === "entity") {
const refTable = meta.reference_table || meta.referenceTable;
const refColumn = meta.reference_column || meta.referenceColumn;
const displayCol = meta.display_column || meta.displayColumn;
if (refTable && !merged.entityTable) {
merged.source = "entity";
merged.entityTable = refTable;
merged.entityValueColumn = refColumn || "id";
merged.entityLabelColumn = displayCol || "name";
}
} else if (inputType === "category" && !existingSource) {
merged.source = "category";
} else if (inputType === "select" && !existingSource) {
const detail = typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : (meta.detail_settings || {});
if (detail.options && !merged.options?.length) {
merged.options = detail.options;
}
}
return merged;
}
// 컴포넌트 렌더러 인터페이스
export interface ComponentRenderer {
@ -175,6 +245,15 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
children,
...props
}) => {
// 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드)
const screenTableName = props.tableName || (component as any).tableName;
const [, forceUpdate] = React.useState(0);
React.useEffect(() => {
if (screenTableName && !columnMetaCache[screenTableName]) {
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
}
}, [screenTableName]);
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
const extractTypeFromUrl = (url: string | undefined): string | undefined => {
@ -550,24 +629,34 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
height: finalStyle.height,
};
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
const isEntityJoinColumn = fieldName?.includes(".");
const baseColumnName = isEntityJoinColumn ? undefined : fieldName;
const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {});
// 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제
const effectiveComponent = isEntityJoinColumn
? { ...component, componentConfig: mergedComponentConfig, readonly: false }
: { ...component, componentConfig: mergedComponentConfig };
const rendererProps = {
component,
component: effectiveComponent,
isSelected,
onClick,
onDragStart,
onDragEnd,
size: component.size || newComponent.defaultSize,
position: component.position,
config: component.componentConfig,
componentConfig: component.componentConfig,
config: mergedComponentConfig,
componentConfig: mergedComponentConfig,
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
...(component.componentConfig || {}),
...(mergedComponentConfig || {}),
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
style: mergedStyle,
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
label: effectiveLabel,
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달
inputType: (component as any).inputType || component.componentConfig?.inputType,
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType,
columnName: (component as any).columnName || component.componentConfig?.columnName,
value: currentValue, // formData에서 추출한 현재 값 전달
// 새로운 기능들 전달
@ -607,9 +696,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드)
mode: component.componentConfig?.mode || mode,
isInModal,
readonly: component.readonly,
// 🆕 disabledFields 체크 또는 기존 readonly
disabled: disabledFields?.includes(fieldName) || component.readonly,
readonly: isEntityJoinColumn ? false : component.readonly,
disabled: isEntityJoinColumn ? false : (disabledFields?.includes(fieldName) || component.readonly),
originalData,
allComponents,
onUpdateLayout,

View File

@ -13,6 +13,7 @@ import {
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@ -26,6 +27,7 @@ import {
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { apiClient } from "@/lib/api/client";
@ -82,7 +84,7 @@ const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`;
interface ItemSearchModalProps {
open: boolean;
onClose: () => void;
onSelect: (item: ItemInfo) => void;
onSelect: (items: ItemInfo[]) => void;
companyCode?: string;
}
@ -94,6 +96,7 @@ function ItemSearchModal({
}: ItemSearchModalProps) {
const [searchText, setSearchText] = useState("");
const [items, setItems] = useState<ItemInfo[]>([]);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const searchItems = useCallback(
@ -109,7 +112,7 @@ function ItemSearchModal({
enableEntityJoin: true,
companyCodeOverride: companyCode,
});
setItems(result.data || []);
setItems((result.data || []) as ItemInfo[]);
} catch (error) {
console.error("[BomItemEditor] 품목 검색 실패:", error);
} finally {
@ -122,6 +125,7 @@ function ItemSearchModal({
useEffect(() => {
if (open) {
setSearchText("");
setSelectedItems(new Set());
searchItems("");
}
}, [open, searchItems]);
@ -180,6 +184,15 @@ function ItemSearchModal({
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="w-8 px-2 py-2 text-center">
<Checkbox
checked={selectedItems.size > 0 && selectedItems.size === items.length}
onCheckedChange={(checked) => {
if (checked) setSelectedItems(new Set(items.map((i) => i.id)));
else setSelectedItems(new Set());
}}
/>
</th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
@ -191,11 +204,31 @@ function ItemSearchModal({
<tr
key={item.id}
onClick={() => {
onSelect(item);
onClose();
setSelectedItems((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
}}
className="hover:bg-accent cursor-pointer border-t transition-colors"
className={cn(
"cursor-pointer border-t transition-colors",
selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent",
)}
>
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={(checked) => {
setSelectedItems((prev) => {
const next = new Set(prev);
if (checked) next.add(item.id);
else next.delete(item.id);
return next;
});
}}
/>
</td>
<td className="px-3 py-2 font-mono">
{item.item_number}
</td>
@ -208,6 +241,25 @@ function ItemSearchModal({
</table>
)}
</div>
{selectedItems.size > 0 && (
<DialogFooter className="gap-2 sm:gap-0">
<span className="text-muted-foreground text-xs sm:text-sm">
{selectedItems.size}
</span>
<Button
onClick={() => {
const selected = items.filter((i) => selectedItems.has(i.id));
onSelect(selected);
onClose();
}}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="mr-1 h-4 w-4" />
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
@ -227,6 +279,10 @@ interface TreeNodeRowProps {
onFieldChange: (tempId: string, field: string, value: string) => void;
onDelete: (tempId: string) => void;
onAddChild: (parentTempId: string) => void;
onDragStart: (e: React.DragEvent, tempId: string) => void;
onDragOver: (e: React.DragEvent, tempId: string) => void;
onDrop: (e: React.DragEvent, tempId: string) => void;
isDragOver?: boolean;
}
function TreeNodeRow({
@ -241,6 +297,10 @@ function TreeNodeRow({
onFieldChange,
onDelete,
onAddChild,
onDragStart,
onDragOver,
onDrop,
isDragOver,
}: TreeNodeRowProps) {
const indentPx = depth * 32;
const visibleColumns = columns.filter((c) => c.visible !== false);
@ -319,8 +379,13 @@ function TreeNodeRow({
"group flex items-center gap-2 rounded-md border px-2 py-1.5",
"transition-colors hover:bg-accent/30",
depth > 0 && "ml-2 border-l-2 border-l-primary/20",
isDragOver && "border-primary bg-primary/5 border-dashed",
)}
style={{ marginLeft: `${indentPx}px` }}
draggable
onDragStart={(e) => onDragStart(e, node.tempId)}
onDragOver={(e) => onDragOver(e, node.tempId)}
onDrop={(e) => onDrop(e, node.tempId)}
>
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
@ -409,7 +474,7 @@ export function BomItemEditorComponent({
// 설정값 추출
const cfg = useMemo(() => component?.componentConfig || {}, [component]);
const mainTableName = cfg.mainTableName || "bom_detail";
const parentKeyColumn = cfg.parentKeyColumn || "parent_detail_id";
const parentKeyColumn = (cfg.parentKeyColumn && cfg.parentKeyColumn !== "id") ? cfg.parentKeyColumn : "parent_detail_id";
const columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]);
const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]);
const fkColumn = cfg.foreignKeyColumn || "bom_id";
@ -431,7 +496,14 @@ export function BomItemEditorComponent({
for (const col of categoryColumns) {
const categoryRef = `${mainTableName}.${col.key}`;
if (categoryOptionsMap[categoryRef]) continue;
const alreadyLoaded = await new Promise<boolean>((resolve) => {
setCategoryOptionsMap((prev) => {
resolve(!!prev[categoryRef]);
return prev;
});
});
if (alreadyLoaded) continue;
try {
const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`);
@ -455,11 +527,23 @@ export function BomItemEditorComponent({
// ─── 데이터 로드 ───
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
const sourceTable = cfg.dataSource?.sourceTable || "item_info";
const loadBomDetails = useCallback(
async (id: string) => {
if (!id) return;
setLoading(true);
try {
// isSourceDisplay 컬럼을 추가 조인 컬럼으로 요청
const displayCols = columns.filter((c) => c.isSourceDisplay);
const additionalJoinColumns = displayCols.map((col) => ({
sourceTable,
sourceColumn: sourceFk,
joinAlias: `${sourceFk}_${col.key}`,
referenceTable: sourceTable,
}));
const result = await entityJoinApi.getTableDataWithJoins(mainTableName, {
page: 1,
size: 500,
@ -467,9 +551,20 @@ export function BomItemEditorComponent({
sortBy: "seq_no",
sortOrder: "asc",
enableEntityJoin: true,
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined,
});
const rows = (result.data || []).map((row: Record<string, any>) => {
const mapped = { ...row };
for (const key of Object.keys(row)) {
if (key.startsWith(`${sourceFk}_`)) {
const shortKey = key.replace(`${sourceFk}_`, "");
if (!mapped[shortKey]) mapped[shortKey] = row[key];
}
}
return mapped;
});
const rows = result.data || [];
const tree = buildTree(rows);
setTreeData(tree);
@ -483,7 +578,7 @@ export function BomItemEditorComponent({
setLoading(false);
}
},
[mainTableName, fkColumn],
[mainTableName, fkColumn, sourceFk, sourceTable, columns],
);
useEffect(() => {
@ -548,10 +643,13 @@ export function BomItemEditorComponent({
id: node.id,
tempId: node.tempId,
[parentKeyColumn]: parentId,
[fkColumn]: bomId,
seq_no: String(idx + 1),
level: String(level),
_isNew: node._isNew,
_targetTable: mainTableName,
_fkColumn: fkColumn,
_deferSave: true,
});
if (node.children.length > 0) {
traverse(node.children, node.id || node.tempId, level + 1);
@ -560,7 +658,7 @@ export function BomItemEditorComponent({
};
traverse(nodes, null, 0);
return result;
}, [parentKeyColumn, mainTableName]);
}, [parentKeyColumn, mainTableName, fkColumn, bomId]);
// 트리 변경 시 부모에게 알림
const notifyChange = useCallback(
@ -627,53 +725,56 @@ export function BomItemEditorComponent({
setItemSearchOpen(true);
}, []);
// 품목 선택 후 추가 (동적 데이터)
// 품목 선택 후 추가 (다중 선택 지원)
const handleItemSelect = useCallback(
(item: ItemInfo) => {
// 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식)
const sourceData: Record<string, any> = {};
const sourceTable = cfg.dataSource?.sourceTable;
if (sourceTable) {
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
sourceData[sourceFk] = item.id;
// 소스 표시 컬럼의 데이터 병합
Object.keys(item).forEach((key) => {
sourceData[`_display_${key}`] = (item as any)[key];
sourceData[key] = (item as any)[key];
});
(selectedItemsList: ItemInfo[]) => {
let newTree = [...treeData];
for (const item of selectedItemsList) {
const sourceData: Record<string, any> = {};
const sourceTable = cfg.dataSource?.sourceTable;
if (sourceTable) {
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
sourceData[sourceFk] = item.id;
Object.keys(item).forEach((key) => {
sourceData[`_display_${key}`] = (item as any)[key];
sourceData[key] = (item as any)[key];
});
}
const newNode: BomItemNode = {
tempId: generateTempId(),
parent_detail_id: null,
seq_no: 0,
level: 0,
children: [],
_isNew: true,
data: {
...sourceData,
quantity: "1",
loss_rate: "0",
remark: "",
},
};
if (addTargetParentId === null) {
newNode.seq_no = newTree.length + 1;
newNode.level = 0;
newTree = [...newTree, newNode];
} else {
newTree = findAndUpdate(newTree, addTargetParentId, (parent) => {
newNode.parent_detail_id = parent.id || parent.tempId;
newNode.seq_no = parent.children.length + 1;
newNode.level = parent.level + 1;
return {
...parent,
children: [...parent.children, newNode],
};
});
}
}
const newNode: BomItemNode = {
tempId: generateTempId(),
parent_detail_id: null,
seq_no: 0,
level: 0,
children: [],
_isNew: true,
data: {
...sourceData,
quantity: "1",
loss_rate: "0",
remark: "",
},
};
let newTree: BomItemNode[];
if (addTargetParentId === null) {
newNode.seq_no = treeData.length + 1;
newNode.level = 0;
newTree = [...treeData, newNode];
} else {
newTree = findAndUpdate(treeData, addTargetParentId, (parent) => {
newNode.parent_detail_id = parent.id || parent.tempId;
newNode.seq_no = parent.children.length + 1;
newNode.level = parent.level + 1;
return {
...parent,
children: [...parent.children, newNode],
};
});
if (addTargetParentId !== null) {
setExpandedNodes((prev) => new Set([...prev, addTargetParentId]));
}
@ -692,6 +793,101 @@ export function BomItemEditorComponent({
});
}, []);
// ─── 드래그 재정렬 ───
const [dragId, setDragId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
// 트리에서 노드를 제거하고 반환
const removeNode = (nodes: BomItemNode[], tempId: string): { tree: BomItemNode[]; removed: BomItemNode | null } => {
const result: BomItemNode[] = [];
let removed: BomItemNode | null = null;
for (const node of nodes) {
if (node.tempId === tempId) {
removed = node;
} else {
const childResult = removeNode(node.children, tempId);
if (childResult.removed) removed = childResult.removed;
result.push({ ...node, children: childResult.tree });
}
}
return { tree: result, removed };
};
// 노드가 대상의 자손인지 확인 (자기 자신의 하위로 드래그 방지)
const isDescendant = (nodes: BomItemNode[], parentId: string, childId: string): boolean => {
const find = (list: BomItemNode[]): BomItemNode | null => {
for (const n of list) {
if (n.tempId === parentId) return n;
const found = find(n.children);
if (found) return found;
}
return null;
};
const parent = find(nodes);
if (!parent) return false;
const check = (children: BomItemNode[]): boolean => {
for (const c of children) {
if (c.tempId === childId) return true;
if (check(c.children)) return true;
}
return false;
};
return check(parent.children);
};
const handleDragStart = useCallback((e: React.DragEvent, tempId: string) => {
setDragId(tempId);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", tempId);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, tempId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverId(tempId);
}, []);
const handleDrop = useCallback((e: React.DragEvent, targetTempId: string) => {
e.preventDefault();
setDragOverId(null);
if (!dragId || dragId === targetTempId) return;
// 자기 자신의 하위로 드래그 방지
if (isDescendant(treeData, dragId, targetTempId)) return;
const { tree: treeWithout, removed } = removeNode(treeData, dragId);
if (!removed) return;
// 대상 노드 바로 뒤에 같은 레벨로 삽입
const insertAfter = (nodes: BomItemNode[], afterId: string, node: BomItemNode): { result: BomItemNode[]; inserted: boolean } => {
const result: BomItemNode[] = [];
let inserted = false;
for (const n of nodes) {
result.push(n);
if (n.tempId === afterId) {
result.push({ ...node, level: n.level, parent_detail_id: n.parent_detail_id });
inserted = true;
} else if (!inserted) {
const childResult = insertAfter(n.children, afterId, node);
if (childResult.inserted) {
result[result.length - 1] = { ...n, children: childResult.result };
inserted = true;
}
}
}
return { result, inserted };
};
const { result, inserted } = insertAfter(treeWithout, targetTempId, removed);
if (inserted) {
const reindex = (nodes: BomItemNode[], depth = 0): BomItemNode[] =>
nodes.map((n, i) => ({ ...n, seq_no: i + 1, level: depth, children: reindex(n.children, depth + 1) }));
notifyChange(reindex(result));
}
setDragId(null);
}, [dragId, treeData, notifyChange]);
// ─── 재귀 렌더링 ───
const renderNodes = (nodes: BomItemNode[], depth: number) => {
@ -711,6 +907,10 @@ export function BomItemEditorComponent({
onFieldChange={handleFieldChange}
onDelete={handleDelete}
onAddChild={handleAddChild}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
isDragOver={dragOverId === node.tempId}
/>
{isExpanded &&
node.children.length > 0 &&
@ -898,7 +1098,7 @@ export function BomItemEditorComponent({
</div>
{/* 트리 목록 */}
<div className="space-y-1">
<div className="max-h-[400px] space-y-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm"> ...</span>

View File

@ -84,30 +84,45 @@ export function BomTreeComponent({
return null;
}, [formData, selectedRowsData]);
// 선택된 BOM 헤더 정보 추출
// 선택된 BOM 헤더 정보 추출 (조인 필드명 매핑 포함)
const selectedHeaderData = useMemo(() => {
if (selectedRowsData && selectedRowsData.length > 0) {
return selectedRowsData[0] as BomHeaderInfo;
}
if (formData?.id) return formData as unknown as BomHeaderInfo;
return null;
const raw = selectedRowsData?.[0] || (formData?.id ? formData : null);
if (!raw) return null;
return {
...raw,
item_name: raw.item_id_item_name || raw.item_name || "",
item_code: raw.item_id_item_number || raw.item_code || "",
item_type: raw.item_id_division || raw.item_id_type || raw.item_type || "",
} as BomHeaderInfo;
}, [formData, selectedRowsData]);
// BOM 디테일 데이터 로드
const detailTable = config.detailTable || "bom_detail";
const foreignKey = config.foreignKey || "bom_id";
const sourceFk = "child_item_id";
const loadBomDetails = useCallback(async (bomId: string) => {
if (!bomId) return;
setLoading(true);
try {
const result = await entityJoinApi.getTableDataWithJoins("bom_detail", {
const result = await entityJoinApi.getTableDataWithJoins(detailTable, {
page: 1,
size: 500,
search: { bom_id: bomId },
search: { [foreignKey]: bomId },
sortBy: "seq_no",
sortOrder: "asc",
enableEntityJoin: true,
});
const rows = result.data || [];
const rows = (result.data || []).map((row: Record<string, any>) => {
const mapped = { ...row };
// 엔티티 조인 필드 매핑: child_item_id_item_name → child_item_name 등
mapped.child_item_name = row[`${sourceFk}_item_name`] || row.child_item_name || "";
mapped.child_item_code = row[`${sourceFk}_item_number`] || row.child_item_code || "";
mapped.child_item_type = row[`${sourceFk}_type`] || row[`${sourceFk}_division`] || row.child_item_type || "";
return mapped;
});
const tree = buildTree(rows);
setTreeData(tree);
const firstLevelIds = new Set<string>(tree.map((n: BomTreeNode) => n.id));
@ -117,7 +132,7 @@ export function BomTreeComponent({
} finally {
setLoading(false);
}
}, []);
}, [detailTable, foreignKey]);
// 평면 데이터 -> 트리 구조 변환
const buildTree = (flatData: any[]): BomTreeNode[] => {

View File

@ -13,7 +13,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2SelectDefinition;
render(): React.ReactElement {
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props;
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, allComponents, ...restProps } = this.props as any;
// 컴포넌트 설정 추출
const config = component.componentConfig || component.config || {};
@ -107,8 +107,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
// 디버깅 필요시 주석 해제
// console.log("🔍 [V2SelectRenderer]", { componentId: component.id, effectiveStyle, effectiveSize });
// 🔧 restProps에서 style, size 제외 (effectiveStyle/effectiveSize가 우선되어야 함)
const { style: _style, size: _size, ...restPropsClean } = restProps as any;
const { style: _style, size: _size, allComponents: _allComp, ...restPropsClean } = restProps as any;
return (
<V2Select
@ -119,9 +118,10 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
disabled={config.disabled || component.disabled}
value={currentValue}
onChange={handleChange}
onFormDataChange={isInteractive ? onFormDataChange : undefined}
allComponents={allComponents}
config={{
mode: config.mode || "dropdown",
// 🔧 카테고리 타입이면 source를 무조건 "category"로 강제 (테이블 타입 관리 설정 우선)
source: isCategoryType ? "category" : (config.source || "distinct"),
multiple: config.multiple || false,
searchable: config.searchable ?? true,
@ -131,7 +131,6 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
entityTable: config.entityTable,
entityLabelColumn: config.entityLabelColumn,
entityValueColumn: config.entityValueColumn,
// 🔧 카테고리 소스 지원 (tableName, columnName 폴백)
categoryTable: config.categoryTable || (isCategoryType ? tableName : undefined),
categoryColumn: config.categoryColumn || (isCategoryType ? columnName : undefined),
}}

View File

@ -1300,6 +1300,9 @@ export class ButtonActionExecutor {
// _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리)
if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue;
// _deferSave 플래그가 있으면 메인 저장 후 처리 (마스터-디테일 순차 저장)
if (firstItem?._deferSave) continue;
// 🆕 V2Repeater가 등록된 테이블이면 RepeaterFieldGroup 저장 스킵
// V2Repeater가 repeaterSave 이벤트로 저장 처리함
// @ts-ignore - window에 동적 속성 사용
@ -1390,6 +1393,7 @@ export class ButtonActionExecutor {
_existingRecord: __,
_originalItemIds: ___,
_deletedItemIds: ____,
_fkColumn: itemFkColumn,
...dataToSave
} = item;
@ -1398,12 +1402,18 @@ export class ButtonActionExecutor {
delete dataToSave.id;
}
// BOM 에디터 등 마스터-디테일: FK 값이 없으면 메인 저장 결과의 ID 주입
if (itemFkColumn && (!dataToSave[itemFkColumn] || dataToSave[itemFkColumn] === null)) {
const mainSavedId = saveResult?.data?.id || saveResult?.data?.data?.id || context.formData?.id;
if (mainSavedId) {
dataToSave[itemFkColumn] = mainSavedId;
}
}
// 🆕 공통 필드 병합 + 사용자 정보 추가
// 개별 항목 데이터를 먼저 넣고, 공통 필드로 덮어씀 (공통 필드가 우선)
// 이유: 사용자가 공통 필드(출고상태 등)를 변경하면 모든 항목에 적용되어야 함
const dataWithMeta: Record<string, unknown> = {
...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터
...commonFields, // 범용 폼 모달의 공통 필드 (outbound_status 등) - 공통 필드가 우선!
...dataToSave,
...commonFields,
created_by: context.userId,
updated_by: context.userId,
company_code: context.companyCode,
@ -1781,6 +1791,45 @@ export class ButtonActionExecutor {
// 🔧 formData를 리피터에 전달하여 각 행에 병합 저장
const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id;
// _deferSave 데이터 처리 (마스터-디테일 순차 저장: 메인 저장 후 디테일 저장)
if (savedId) {
for (const [fieldKey, fieldValue] of Object.entries(context.formData)) {
let parsedData = fieldValue;
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
try { parsedData = JSON.parse(fieldValue); } catch { continue; }
}
if (!Array.isArray(parsedData) || parsedData.length === 0) continue;
if (!parsedData[0]?._deferSave) continue;
const targetTable = parsedData[0]?._targetTable;
if (!targetTable) continue;
for (const item of parsedData) {
const { _targetTable: _, _isNew, _deferSave: __, _fkColumn: fkCol, tempId: ___, ...data } = item;
if (!data.id || data.id === "") delete data.id;
// FK 주입
if (fkCol) data[fkCol] = savedId;
// 시스템 필드 추가
data.created_by = context.userId;
data.updated_by = context.userId;
data.company_code = context.companyCode;
try {
const isNew = _isNew || !item.id || item.id === "";
if (isNew) {
await apiClient.post(`/table-management/tables/${targetTable}/add`, data);
} else {
await apiClient.put(`/table-management/tables/${targetTable}/${item.id}`, data);
}
} catch (err: any) {
console.error(`[handleSave] 디테일 저장 실패 (${targetTable}):`, err.response?.data || err.message);
}
}
}
}
// 메인 폼 데이터 구성 (사용자 정보 포함)
const mainFormData = {
...formData,

View File

@ -182,6 +182,8 @@ export interface V2SelectProps extends V2BaseProps {
config: V2SelectConfig;
value?: string | string[];
onChange?: (value: string | string[]) => void;
onFormDataChange?: (fieldName: string, value: any) => void;
formData?: Record<string, any>;
}
// ===== V2Date =====