4a8413000b
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m17s
Remove legacy v2 input/select and file/media runtimes, add canonical option/file loaders, and document Codex handoff.
531 lines
19 KiB
TypeScript
531 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { apiClient } from "@/lib/api/client";
|
||
import type { FieldOption, FieldRef } from "@/types/invyone-component";
|
||
|
||
/**
|
||
* 로더가 반환하는 옵션 형식 — select-pickers 의 SelectOption 과 호환.
|
||
*
|
||
* FieldOption 은 `string | {value,label}` union 이라 picker 들이 그대로 받기
|
||
* 어려움. 로더 내부에서 객체 형태로 정규화한 결과를 반환한다.
|
||
*/
|
||
export interface LoadedOption {
|
||
value: string;
|
||
label: string;
|
||
}
|
||
|
||
/**
|
||
* use-option-loader
|
||
*
|
||
* InputComponent 의 select 계열 (single / multi / radio / check / tag) 의 옵션을
|
||
* 5 가지 source 에서 비동기로 로드한다.
|
||
*
|
||
* static — config.options 그대로 사용
|
||
* code — 공통코드 (/common-codes/categories/{group}/options) — code_group / codeCategory
|
||
* category — 사용자 정의 카테고리 (/table-categories/{table}/{column}/values)
|
||
* distinct — 현재 테이블 컬럼의 distinct (/entity/{table}/distinct/{column})
|
||
* api — 임의 endpoint
|
||
*
|
||
* 옛 선택 컴포넌트의 옵션 로딩 로직을 input canonical 용으로 정리. apiClient 만 의존.
|
||
*
|
||
* 디자인 모드 가드 — isDesignMode === true 면 static 만 처리하고 API 호출은 모두
|
||
* skip. 캔버스 위에서 옵션 컴포넌트를 끌어 놓는 동안 API 폭주를 방지.
|
||
*
|
||
* 캐시 — 같은 url 결과를 module-scoped Map 에 저장 (브라우저 세션 동안). race 조건
|
||
* 방지로 effect 내 cancelled flag 사용.
|
||
*
|
||
* 계층 / cascade — code source 의 단일 hierarchical (parentField 의 value 로 자식
|
||
* 코드 조회) 까지만 지원. swap / 다단 cascade 는 TODO.
|
||
*
|
||
* 사용 예:
|
||
* const { options, loading } = useOptionLoader({
|
||
* config: componentConfig,
|
||
* tableName,
|
||
* columnName,
|
||
* formData,
|
||
* isDesignMode,
|
||
* });
|
||
*/
|
||
|
||
/**
|
||
* 옵션 검색 필터 (canonical) — InvFieldConfigPanel 의 옵션 필터 UI 가 이 형태로
|
||
* 저장한다. 옛 V2 입력/선택 본체가 삭제되며 canonical 위치로 이전됨.
|
||
*
|
||
* value_type:
|
||
* static — value 가 고정값
|
||
* field — field_ref 가 다른 폼 필드명, runtime 에 그 필드 값 치환
|
||
* user — user_field 가 로그인 사용자 메타 (companyCode 등), runtime 에 치환
|
||
*/
|
||
export interface OptionFilter {
|
||
column: string;
|
||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like" | "isNull" | "isNotNull";
|
||
value_type?: "static" | "field" | "user";
|
||
value?: unknown;
|
||
field_ref?: string;
|
||
user_field?: "companyCode" | "userId" | "deptCode" | "userName";
|
||
}
|
||
|
||
export type OptionSource =
|
||
| "static"
|
||
| "code"
|
||
| "category"
|
||
| "entity"
|
||
| "distinct"
|
||
| "select"
|
||
| "api"
|
||
| "db";
|
||
|
||
interface RawOptionItem {
|
||
value?: unknown;
|
||
label?: unknown;
|
||
// camelCase (legacy / 일부 endpoint)
|
||
valueCode?: string;
|
||
valueLabel?: string;
|
||
// snake_case (백엔드 표준 응답 — table-categories / common-codes hierarchy)
|
||
value_code?: string;
|
||
value_label?: string;
|
||
children?: RawOptionItem[];
|
||
}
|
||
|
||
export interface OptionLoaderConfig {
|
||
source?: string;
|
||
options?: Array<FieldOption | { value: string; label?: string }> | undefined;
|
||
|
||
codeGroup?: string;
|
||
codeCategory?: string;
|
||
|
||
categoryTable?: string;
|
||
categoryColumn?: string;
|
||
|
||
entityTable?: string;
|
||
entityValueColumn?: string;
|
||
entityLabelColumn?: string;
|
||
ref?: FieldRef;
|
||
|
||
table?: string;
|
||
valueColumn?: string;
|
||
labelColumn?: string;
|
||
|
||
apiEndpoint?: string;
|
||
|
||
hierarchical?: boolean;
|
||
parentField?: string;
|
||
|
||
/**
|
||
* 옵션 검색 필터 (canonical) — runtime 에 formData / user context 로 치환된 후
|
||
* 백엔드의 `filters` query 파라미터에 JSON 문자열로 전달됨.
|
||
*/
|
||
filters?: OptionFilter[];
|
||
}
|
||
|
||
/**
|
||
* 로그인 사용자 메타 — `value_type === "user"` 필터 치환에 사용.
|
||
*/
|
||
export interface OptionUserContext {
|
||
companyCode?: string;
|
||
userId?: string;
|
||
deptCode?: string;
|
||
userName?: string;
|
||
}
|
||
|
||
export interface UseOptionLoaderArgs {
|
||
config: OptionLoaderConfig & Record<string, any>;
|
||
tableName?: string;
|
||
columnName?: string;
|
||
formData?: Record<string, any>;
|
||
/** `value_type === "user"` 필터 치환용 사용자 메타. 미지정 시 user 필터는 skip. */
|
||
userContext?: OptionUserContext;
|
||
isDesignMode?: boolean;
|
||
}
|
||
|
||
export interface UseOptionLoaderResult {
|
||
options: LoadedOption[];
|
||
loading: boolean;
|
||
}
|
||
|
||
// FieldOption (string | object) 도 받을 수 있게 normalizeOptions 가 처리.
|
||
// FieldOption 은 import 만 하고 직접 시그니처에는 노출하지 않는다.
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
type _FieldOptionSentinel = FieldOption;
|
||
|
||
// 모듈 스코프 캐시 — url 단위. apiClient 의 baseURL 이 같다는 전제.
|
||
// 캐시 무효화 트리거가 필요한 경우 입력 컴포넌트가 unmount/remount 되어야 함.
|
||
const responseCache = new Map<string, LoadedOption[]>();
|
||
|
||
function normalizeOptions(raw: any[]): LoadedOption[] {
|
||
if (!Array.isArray(raw)) return [];
|
||
return raw
|
||
.map((item): LoadedOption | null => {
|
||
if (item == null) return null;
|
||
if (typeof item === "string") return { value: item, label: item };
|
||
if (typeof item === "object") {
|
||
// 값 키 우선순위: value / code / id (보편)
|
||
// + valueCode / value_code (table-categories tree leaf)
|
||
// + codeValue / code_value (common-codes hierarchy)
|
||
const v =
|
||
item.value ??
|
||
item.code ??
|
||
item.id ??
|
||
item.valueCode ??
|
||
item.value_code ??
|
||
item.codeValue ??
|
||
item.code_value;
|
||
// 라벨 키 우선순위: label / name (보편)
|
||
// + valueLabel / value_label (table-categories)
|
||
// + codeName / code_name (common-codes hierarchy)
|
||
const l =
|
||
item.label ??
|
||
item.name ??
|
||
item.valueLabel ??
|
||
item.value_label ??
|
||
item.codeName ??
|
||
item.code_name ??
|
||
v;
|
||
if (v == null || String(v) === "") return null;
|
||
return { value: String(v), label: l != null ? String(l) : String(v) };
|
||
}
|
||
return null;
|
||
})
|
||
.filter((x): x is LoadedOption => x !== null);
|
||
}
|
||
|
||
function flattenCategoryTree(items: RawOptionItem[], depth = 0): LoadedOption[] {
|
||
const out: LoadedOption[] = [];
|
||
for (const item of items) {
|
||
// 백엔드 응답은 snake_case (value_code/value_label) — camelCase 호환 유지.
|
||
const code =
|
||
item.valueCode ??
|
||
item.value_code ??
|
||
(item.value != null ? String(item.value) : "");
|
||
if (!code) continue;
|
||
const labelRaw =
|
||
item.valueLabel ??
|
||
item.value_label ??
|
||
(item.label != null ? String(item.label) : code);
|
||
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
|
||
out.push({ value: code, label: prefix + labelRaw });
|
||
if (Array.isArray(item.children) && item.children.length > 0) {
|
||
out.push(...flattenCategoryTree(item.children, depth + 1));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function isCanonicalEmpty(v: any): boolean {
|
||
return v === undefined || v === null || v === "";
|
||
}
|
||
|
||
/** ResolvedFilter — runtime 에 value 가 실제 값으로 치환된 후의 형태. */
|
||
interface ResolvedFilter {
|
||
column: string;
|
||
operator: OptionFilter["operator"];
|
||
/** isNull/isNotNull 은 value 없이 전달. in/notIn 은 배열. */
|
||
value?: unknown;
|
||
}
|
||
|
||
/**
|
||
* OptionFilter 배열을 runtime 값으로 치환.
|
||
* - column 빈 값 → skip
|
||
* - value_type=field: formData[field_ref] 값으로 치환. 값 없으면 skip
|
||
* (단 isNull/isNotNull 은 value 없이 통과)
|
||
* - value_type=user: userContext[user_field] 로 치환. 값 없으면 skip
|
||
* - in/notIn: 배열 또는 "a,b,c" 문자열 모두 trim 된 배열로 정규화
|
||
* - isNull/isNotNull: value 없이 항상 통과
|
||
*/
|
||
function resolveFilters(
|
||
filters: OptionFilter[] | undefined,
|
||
formData: Record<string, any> | undefined,
|
||
userContext: OptionUserContext | undefined,
|
||
): ResolvedFilter[] {
|
||
if (!Array.isArray(filters) || filters.length === 0) return [];
|
||
const out: ResolvedFilter[] = [];
|
||
for (const f of filters) {
|
||
const column = typeof f?.column === "string" ? f.column.trim() : "";
|
||
if (!column) continue;
|
||
const op = f.operator;
|
||
|
||
// isNull / isNotNull 은 value 없이 통과
|
||
if (op === "isNull" || op === "isNotNull") {
|
||
out.push({ column, operator: op });
|
||
continue;
|
||
}
|
||
|
||
let raw: unknown;
|
||
const vt = f.value_type || "static";
|
||
if (vt === "static") {
|
||
raw = f.value;
|
||
} else if (vt === "field") {
|
||
if (!f.field_ref) continue;
|
||
raw = formData?.[f.field_ref];
|
||
if (isCanonicalEmpty(raw)) continue;
|
||
} else if (vt === "user") {
|
||
if (!f.user_field) continue;
|
||
raw = userContext?.[f.user_field];
|
||
if (isCanonicalEmpty(raw)) continue;
|
||
} else {
|
||
raw = f.value;
|
||
}
|
||
|
||
// in / notIn 정규화: 배열 또는 콤마 구분 문자열
|
||
if (op === "in" || op === "notIn") {
|
||
let arr: unknown[];
|
||
if (Array.isArray(raw)) {
|
||
arr = raw;
|
||
} else if (typeof raw === "string") {
|
||
arr = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
|
||
} else if (isCanonicalEmpty(raw)) {
|
||
continue;
|
||
} else {
|
||
arr = [raw];
|
||
}
|
||
if (arr.length === 0) continue;
|
||
out.push({ column, operator: op, value: arr });
|
||
continue;
|
||
}
|
||
|
||
// 그 외 operator 는 빈 값 skip
|
||
if (isCanonicalEmpty(raw)) continue;
|
||
out.push({ column, operator: op, value: raw });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function resolveSource(config: OptionLoaderConfig): OptionSource {
|
||
const raw = (config.source || "").toString().toLowerCase();
|
||
if (raw === "static" || raw === "code" || raw === "category" || raw === "entity" ||
|
||
raw === "distinct" || raw === "select" || raw === "api" || raw === "db") {
|
||
return raw as OptionSource;
|
||
}
|
||
// source 미지정 — 기존 데이터를 보고 추정 (옛 키 호환 최소)
|
||
if (Array.isArray(config.options) && config.options.length > 0) return "static";
|
||
if (config.codeGroup || config.codeCategory) return "code";
|
||
if (config.categoryTable && config.categoryColumn) return "category";
|
||
if (config.entityTable || config.ref?.table) return "entity";
|
||
if (config.apiEndpoint) return "api";
|
||
return "static";
|
||
}
|
||
|
||
export function useOptionLoader({
|
||
config,
|
||
tableName,
|
||
columnName,
|
||
formData,
|
||
userContext,
|
||
isDesignMode,
|
||
}: UseOptionLoaderArgs): UseOptionLoaderResult {
|
||
const source = resolveSource(config);
|
||
|
||
// static options 는 외부 의존성 없이 즉시 결정.
|
||
const staticOptions = useMemo<LoadedOption[]>(() => {
|
||
return normalizeOptions(config.options as any[] | undefined ?? []);
|
||
}, [config.options]);
|
||
|
||
// runtime 치환된 filter JSON — fetchUrl 의 dep 으로 stable string 사용해서
|
||
// formData / filters 객체 reference 변경에 의한 effect 폭주 방지.
|
||
// 결과가 빈 배열이면 undefined 로 두어 URL 에 filters query 자체를 안 붙임.
|
||
const resolvedFiltersJson = useMemo<string | undefined>(() => {
|
||
const resolved = resolveFilters(config.filters, formData, userContext);
|
||
if (resolved.length === 0) return undefined;
|
||
return JSON.stringify(resolved);
|
||
}, [
|
||
config.filters,
|
||
formData,
|
||
userContext?.companyCode,
|
||
userContext?.userId,
|
||
userContext?.deptCode,
|
||
userContext?.userName,
|
||
]);
|
||
|
||
// 계층 코드의 parent 값 (다른 폼 필드 참조)
|
||
const parentValue = useMemo<string | undefined>(() => {
|
||
if (!config.hierarchical || !config.parentField) return undefined;
|
||
const v = formData?.[config.parentField];
|
||
if (v == null || v === "") return undefined;
|
||
return String(v);
|
||
}, [config.hierarchical, config.parentField, formData]);
|
||
|
||
// API 호출이 필요한 source 인지
|
||
const needsFetch =
|
||
source !== "static" &&
|
||
// 디자인 모드에서는 캔버스 드래그/리사이즈 등으로 effect 가 자주 트리거되므로
|
||
// API 호출을 통째로 skip 한다. 운영 모드에서만 fetch.
|
||
!isDesignMode;
|
||
|
||
// fetch url 결정 (memo 로 effect dep 안정화)
|
||
const fetchUrl = useMemo<string | null>(() => {
|
||
if (!needsFetch) return null;
|
||
// filters query 헬퍼 — 이미 resolvedFiltersJson 으로 stable string 화 되어 있음.
|
||
// 백엔드 표준: `filters=` URL-encoded JSON. 빈 결과면 query 자체를 안 붙임.
|
||
const filtersQuery = resolvedFiltersJson
|
||
? `filters=${encodeURIComponent(resolvedFiltersJson)}`
|
||
: "";
|
||
const appendFilters = (url: string): string => {
|
||
if (!filtersQuery) return url;
|
||
return url + (url.includes("?") ? "&" : "?") + filtersQuery;
|
||
};
|
||
|
||
if (source === "code") {
|
||
const group = config.codeGroup || config.codeCategory;
|
||
if (!group) return null;
|
||
if (config.hierarchical) {
|
||
// 백엔드 endpoint 는 `/hierarchy?parentCodeValue=...` (children 은 미존재)
|
||
const q = parentValue ? `?parentCodeValue=${encodeURIComponent(parentValue)}` : "";
|
||
// filters 는 공통코드 endpoint 가 처리 안 함 → query 미추가 (TODO: backend 지원 시 활성화)
|
||
return `/common-codes/categories/${encodeURIComponent(group)}/hierarchy${q}`;
|
||
}
|
||
return `/common-codes/categories/${encodeURIComponent(group)}/options`;
|
||
}
|
||
if (source === "category") {
|
||
const t = config.categoryTable || tableName;
|
||
const c = config.categoryColumn || columnName;
|
||
if (!t || !c) return null;
|
||
// filters 는 table-categories endpoint 가 처리 안 함 → query 미추가
|
||
return `/table-categories/${encodeURIComponent(t)}/${encodeURIComponent(c)}/values`;
|
||
}
|
||
if (source === "distinct" || source === "select") {
|
||
// tableName 필수, columnName 은 가상컬럼 (comp_*) 제외
|
||
if (!tableName || !columnName) return null;
|
||
if (columnName.startsWith("comp_")) return null;
|
||
return appendFilters(
|
||
`/entity/${encodeURIComponent(tableName)}/distinct/${encodeURIComponent(columnName)}`,
|
||
);
|
||
}
|
||
if (source === "api") {
|
||
const ep = config.apiEndpoint;
|
||
if (!ep) return null;
|
||
// 외부 endpoint 는 filter 스펙이 정의되어 있지 않으므로 통과 (사용자 정의 URL 그대로)
|
||
return ep;
|
||
}
|
||
if (source === "entity") {
|
||
// entity 는 DB FK 를 직접 걸 수 없는 경우 화면 설정에서 참조 테이블과
|
||
// value/label 컬럼을 지정해 code-name 옵션으로 읽는다.
|
||
const ref = config.ref;
|
||
const t = config.entityTable || ref?.table;
|
||
if (!t) return null;
|
||
const v = config.entityValueColumn || ref?.valueColumn || "id";
|
||
const l = config.entityLabelColumn || ref?.displayColumn || "name";
|
||
return appendFilters(
|
||
`/entity/${encodeURIComponent(t)}/options?value=${encodeURIComponent(v)}&label=${encodeURIComponent(l)}`,
|
||
);
|
||
}
|
||
if (source === "db") {
|
||
// legacy V2 source. table/value/label 컬럼으로 옵션 펼치기.
|
||
const t = config.table;
|
||
if (!t) return null;
|
||
const v = config.valueColumn || "id";
|
||
const l = config.labelColumn || "name";
|
||
return appendFilters(
|
||
`/entity/${encodeURIComponent(t)}/options?value=${encodeURIComponent(v)}&label=${encodeURIComponent(l)}`,
|
||
);
|
||
}
|
||
return null;
|
||
}, [
|
||
needsFetch,
|
||
source,
|
||
config.codeGroup,
|
||
config.codeCategory,
|
||
config.hierarchical,
|
||
parentValue,
|
||
config.categoryTable,
|
||
config.categoryColumn,
|
||
config.apiEndpoint,
|
||
config.table,
|
||
config.valueColumn,
|
||
config.labelColumn,
|
||
// entity 분기 의존성 — entityTable / entityValueColumn / entityLabelColumn 변경 시
|
||
// 옵션 재로드 되도록 명시. ref 는 객체 자체가 매번 바뀌면 effect 폭주하므로
|
||
// 내부 키만 dep 으로 추출.
|
||
config.entityTable,
|
||
config.entityValueColumn,
|
||
config.entityLabelColumn,
|
||
config.ref?.table,
|
||
config.ref?.valueColumn,
|
||
config.ref?.displayColumn,
|
||
// 필터 변경 시 URL 재생성 → cache key 자동 신규화
|
||
resolvedFiltersJson,
|
||
tableName,
|
||
columnName,
|
||
]);
|
||
|
||
const [fetched, setFetched] = useState<LoadedOption[] | null>(() => {
|
||
return fetchUrl ? (responseCache.get(fetchUrl) ?? null) : null;
|
||
});
|
||
const [loading, setLoading] = useState(false);
|
||
const reqIdRef = useRef(0);
|
||
|
||
useEffect(() => {
|
||
if (!fetchUrl) {
|
||
// fetch 가 필요 없는 source / config 미충족 → 이전 fetch 의 loading 상태가
|
||
// 남아있지 않도록 명시적으로 false. race guard 차원에서 reqId 도 +1.
|
||
reqIdRef.current++;
|
||
setFetched(null);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
const cached = responseCache.get(fetchUrl);
|
||
if (cached) {
|
||
// cache hit — 비동기 분기로 빠지지 않으므로 finally 가 안 돌아간다.
|
||
// loading 이 true 인 상태에서 cache hit 가 발생하면 stuck 위험 → 직접 false.
|
||
reqIdRef.current++;
|
||
setFetched(cached);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
const myReq = ++reqIdRef.current;
|
||
setLoading(true);
|
||
apiClient
|
||
.get(fetchUrl)
|
||
.then((response) => {
|
||
if (cancelled || myReq !== reqIdRef.current) return;
|
||
const data = response?.data;
|
||
let opts: LoadedOption[] = [];
|
||
// 응답 shape 적응
|
||
if (Array.isArray(data)) {
|
||
opts = normalizeOptions(data);
|
||
} else if (data && typeof data === "object") {
|
||
if (data.success && Array.isArray(data.data)) {
|
||
// category 트리 vs flat 분기
|
||
if (source === "category") {
|
||
opts = flattenCategoryTree(data.data as RawOptionItem[]);
|
||
} else {
|
||
opts = normalizeOptions(data.data);
|
||
}
|
||
} else if (Array.isArray((data as any).options)) {
|
||
opts = normalizeOptions((data as any).options);
|
||
}
|
||
}
|
||
responseCache.set(fetchUrl, opts);
|
||
setFetched(opts);
|
||
})
|
||
.catch((err) => {
|
||
if (cancelled || myReq !== reqIdRef.current) return;
|
||
console.warn("[useOptionLoader] load failed:", fetchUrl, err);
|
||
setFetched([]);
|
||
})
|
||
.finally(() => {
|
||
if (cancelled || myReq !== reqIdRef.current) return;
|
||
setLoading(false);
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [fetchUrl, source]);
|
||
|
||
// 최종 옵션 결정
|
||
// - source = static : staticOptions
|
||
// - source = api 등 외부 : fetched 가 null 이면 (아직 로딩 전) static fallback,
|
||
// fetched 가 있으면 fetched 우선
|
||
// - isDesignMode 에서는 fetch 자체를 skip 하므로 staticOptions 만 사용
|
||
const options = useMemo<LoadedOption[]>(() => {
|
||
if (source === "static") return staticOptions;
|
||
if (isDesignMode) return staticOptions;
|
||
if (fetched == null) return staticOptions; // 첫 로드 전 — static 으로 silhouette
|
||
return fetched;
|
||
}, [source, isDesignMode, staticOptions, fetched]);
|
||
|
||
return { options, loading };
|
||
}
|