Add environment variable example and update .gitignore

- Created a new .env.example file to provide a template for environment variables, including database connection details, JWT settings, encryption keys, and external API keys.
- Updated .gitignore to include additional test output directories and archive files, ensuring that unnecessary files are not tracked by Git.
- Removed outdated approval test reports and scripts that are no longer needed, streamlining the project structure.

These changes improve the clarity of environment configuration and maintain a cleaner repository.
This commit is contained in:
kjs
2026-04-01 12:12:15 +09:00
parent 250a83b581
commit ccb0c8df4c
112 changed files with 1165 additions and 11644 deletions
+5
View File
@@ -79,10 +79,15 @@ export interface WIWorkItemDetail {
unit?: string;
lower_limit?: string;
upper_limit?: string;
base_value?: string;
tolerance?: string;
duration_minutes?: number;
input_type?: string;
lookup_target?: string;
display_fields?: string;
condition_base_value?: string;
condition_tolerance?: string;
condition_unit?: string;
}
export interface WIWorkItem {
@@ -100,6 +100,38 @@ function ItemSearchModal({
const [items, setItems] = useState<ItemInfo[]>([]);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [catLabels, setCatLabels] = useState<Record<string, Record<string, string>>>({});
// item_info 카테고리 라벨 로드 (division, unit, type)
useEffect(() => {
const loadLabels = async () => {
for (const col of ["division", "unit", "type"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?includeInactive=true`);
const vals = res.data?.data || [];
if (vals.length > 0) {
const map: Record<string, string> = {};
vals.forEach((v: any) => { const code = v.valueCode || v.value_code; const label = v.valueLabel || v.value_label; if (code) map[code] = label; });
setCatLabels((prev) => ({ ...prev, [col]: map }));
}
} catch { /* 무시 */ }
}
};
loadLabels();
}, []);
const resolveCatLabel = (value: string, ...cols: string[]) => {
if (!value) return "-";
const resolve = (code: string) => {
for (const col of cols) { if (catLabels[col]?.[code]) return catLabels[col][code]; }
return code;
};
if (value.includes(",") || value.includes(";")) {
const delim = value.includes(";") ? ";" : ",";
return value.split(delim).map(s => resolve(s.trim())).filter(Boolean).join(", ");
}
return resolve(value);
};
const searchItems = useCallback(
async (query: string) => {
@@ -244,8 +276,8 @@ function ItemSearchModal({
{alreadyAdded && <span className="text-muted-foreground ml-1 text-[10px]">()</span>}
</td>
<td className="px-3 py-2">{item.item_name}</td>
<td className="px-3 py-2">{item.type}</td>
<td className="px-3 py-2">{item.unit}</td>
<td className="px-3 py-2">{resolveCatLabel(item.type || "", "division", "type")}</td>
<td className="px-3 py-2">{resolveCatLabel(item.unit || "", "unit")}</td>
</tr>
);
})}
@@ -142,24 +142,35 @@ export function BomTreeComponent({
const showHistory = features.showHistory !== false;
const showVersion = features.showVersion !== false;
// 카테고리 라벨 캐시 (inputType === "category"인 모든 컬럼)
// 카테고리 라벨 캐시
const [categoryLabels, setCategoryLabels] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const categoryColumns = displayColumns.filter((c) => c.inputType === "category");
if (categoryColumns.length === 0) return;
const loadLabels = async () => {
// inputType === "category"인 컬럼의 카테고리 로드
const categoryColumns = displayColumns.filter((c) => c.inputType === "category");
for (const col of categoryColumns) {
try {
const res = await apiClient.get(`/table-categories/${detailTable}/${col.key}/values?includeInactive=true`);
const vals = res.data?.data || [];
if (vals.length > 0) {
const map: Record<string, string> = {};
vals.forEach((v: any) => { map[v.value_code] = v.value_label; });
vals.forEach((v: any) => { const code = v.valueCode || v.value_code; const label = v.valueLabel || v.value_label; if (code) map[code] = label; });
setCategoryLabels((prev) => ({ ...prev, [col.key]: map }));
}
} catch { /* 무시 */ }
}
// item_info의 division/unit/type 카테고리 항상 로드 (BOM 헤더/상세의 구분/단위 컬럼용)
for (const col of ["division", "unit", "type"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?includeInactive=true`);
const vals = res.data?.data || [];
if (vals.length > 0) {
const map: Record<string, string> = {};
vals.forEach((v: any) => { const code = v.valueCode || v.value_code; const label = v.valueLabel || v.value_label; if (code) map[code] = label; });
setCategoryLabels((prev) => ({ ...prev, [`item_${col}`]: map }));
}
} catch { /* 무시 */ }
}
};
loadLabels();
}, [detailTable, displayColumns]);
@@ -449,7 +460,21 @@ export function BomTreeComponent({
const getItemTypeLabel = (type: string) => {
const map: Record<string, string> = { product: "제품", semi: "반제품", material: "원자재", part: "부품" };
return map[type] || type || "-";
if (map[type]) return map[type];
// 카테고리 라벨에서 코드→라벨 변환 (division, type 모두 확인)
const fromDiv = categoryLabels["item_division"]?.[type];
if (fromDiv) return fromDiv;
const fromType = categoryLabels["item_type"]?.[type];
if (fromType) return fromType;
// 콤마/세미콜론 구분 다중값인 경우 각각 변환
if (type && (type.includes(";") || type.includes(","))) {
const delimiter = type.includes(";") ? ";" : ",";
return type.split(delimiter).map(t => {
const trimmed = t.trim();
return map[trimmed] || categoryLabels["item_division"]?.[trimmed] || categoryLabels["item_type"]?.[trimmed] || trimmed;
}).join(", ");
}
return type || "-";
};
const getItemTypeBadge = (type: string) => {
@@ -545,14 +570,15 @@ export function BomTreeComponent({
}
if (col.key === "unit") {
const unitLabel = categoryLabels[col.key]?.[String(value)] || value;
const unitLabel = categoryLabels[col.key]?.[String(value)] || categoryLabels["item_unit"]?.[String(value)] || value;
return <span className="text-muted-foreground">{unitLabel || "-"}</span>;
}
// fallback: 카테고리 라벨이 로드된 컬럼이면 라벨로 변환
if (categoryLabels[col.key] && value) {
const label = categoryLabels[col.key][String(value)] || String(value);
return <span className="text-muted-foreground">{label || "-"}</span>;
if (value) {
const label = categoryLabels[col.key]?.[String(value)]
|| categoryLabels[`item_${col.key}`]?.[String(value)];
if (label) return <span className="text-muted-foreground">{label}</span>;
}
return <span className="text-muted-foreground">{value ?? "-"}</span>;
@@ -1233,22 +1233,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const strValue = String(value);
if (mapping && mapping[strValue]) {
const categoryData = mapping[strValue];
return categoryData.label || strValue;
}
// 전역 폴백: 컬럼명으로 매핑을 못 찾았을 때, 전체 매핑에서 값 검색
if (!mapping && (strValue.startsWith("CAT_") || strValue.startsWith("CATEGORY_"))) {
// 카테고리 코드 라벨 변환 헬퍼
const resolveLabel = (code: string): string | null => {
if (mapping && mapping[code]) return mapping[code].label || code;
for (const key of Object.keys(categoryMappings)) {
const m = categoryMappings[key];
if (m && m[strValue]) {
const categoryData = m[strValue];
return categoryData.label || strValue;
}
if (m && m[code]) return m[code].label || code;
}
return null;
};
// 콤마/세미콜론 구분 다중값 처리
const looksLikeCatCode = (v: string) => v.startsWith("CAT_") || v.startsWith("CATEGORY_");
if (looksLikeCatCode(strValue) || strValue.includes(",") || strValue.includes(";")) {
const delimiter = strValue.includes(";") ? ";" : ",";
const codes = strValue.includes(delimiter) ? strValue.split(delimiter).map(s => s.trim()).filter(Boolean) : [strValue];
if (codes.some(c => looksLikeCatCode(c))) {
const labels = codes.map(code => resolveLabel(code) || code);
return labels.join(", ");
}
}
// 단일값 변환
if (mapping && mapping[strValue]) {
return mapping[strValue].label || strValue;
}
const resolved = resolveLabel(strValue);
if (resolved) return resolved;
// 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체)
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) {
return formatDateValue(value, "YYYY-MM-DD");
@@ -2429,6 +2441,31 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
}
// 카테고리 타입이 아닌 컬럼 중 division/unit/type 등은 item_info에서 fallback 로드
// 엔티티 조인 컬럼명은 "item_id_division" 형태이므로 끝부분으로 매칭
const KNOWN_CAT_SUFFIXES = ["division", "unit", "type", "material"];
const leftPanelCols = componentConfig.leftPanel?.columns || [];
for (const col of leftPanelCols) {
const colName = (col as any).name || (col as any).columnName || (col as any).column_name;
if (!colName || mappings[colName]) continue;
const suffix = KNOWN_CAT_SUFFIXES.find(s => colName === s || colName.endsWith(`_${s}`));
if (!suffix) continue;
try {
const fbRes = await apiClient.get(`/table-categories/item_info/${suffix}/values?includeInactive=true`);
if (fbRes.data.success && fbRes.data.data?.length > 0) {
const fbMap: Record<string, { label: string; color?: string }> = {};
const flatFb = (items: any[]) => {
items.forEach((item: any) => {
fbMap[item.value_code || item.valueCode] = { label: item.value_label || item.valueLabel, color: item.color };
if (item.children?.length) flatFb(item.children);
});
};
flatFb(fbRes.data.data);
if (Object.keys(fbMap).length > 0) mappings[colName] = fbMap;
}
} catch { /* 무시 */ }
}
setLeftCategoryMappings(mappings);
} catch (error) {
console.error("좌측 카테고리 매핑 로드 실패:", error);
@@ -4036,7 +4073,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</th>
</tr>
</thead>
<tbody className="divide-border divide-y bg-white">
<tbody className="divide-border divide-y bg-card">
<tr className="hover:bg-muted cursor-pointer">
<td className="px-3 py-2 text-sm whitespace-nowrap"> 1-1</td>
<td className="px-3 py-2 text-sm whitespace-nowrap"> 1-2</td>
@@ -4155,7 +4192,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)}
</tr>
</thead>
<tbody className="divide-border divide-y bg-white">
<tbody className="divide-border divide-y bg-card">
{group.items.map((item, idx) => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
const itemId = item[sourceColumn] || item.id || item.ID || idx;
@@ -4167,9 +4204,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tr
key={itemId}
onClick={() => handleLeftItemSelect(item)}
className={`group hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : ""
}`}
className={`group hover:bg-accent cursor-pointer transition-colors ${isSelected ? "bg-primary/10" : ""}`}
>
{columnsToShow.map((col, colIdx) => (
<td
@@ -4190,7 +4225,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</td>
))}
{hasGroupedLeftActions && (
<td className="bg-card group-hover:bg-accent sticky right-0 z-10 px-3 py-2 text-right">
<td className={`sticky right-0 z-10 px-3 py-2 text-right ${isSelected ? "bg-transparent" : "bg-card"}`}>
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{componentConfig.leftPanel?.showEdit !== false && (
<button
@@ -4275,7 +4310,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)}
</tr>
</thead>
<tbody className="divide-border divide-y bg-white">
<tbody className="divide-border divide-y bg-card">
{filteredData.map((item, idx) => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
const itemId = item[sourceColumn] || item.id || item.ID || idx;
@@ -4310,7 +4345,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</td>
))}
{hasLeftTableActions && (
<td className="bg-card group-hover:bg-accent sticky right-0 z-10 px-3 py-2 text-right">
<td className={`sticky right-0 z-10 px-3 py-2 text-right ${isSelected ? "bg-transparent" : "bg-card"}`}>
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{componentConfig.leftPanel?.showEdit !== false && (
<button
@@ -1775,6 +1775,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 연쇄관계 매핑이 없는 경우 무시
}
// 카테고리 타입이 아닌 컬럼 중 division/unit/type 등은 item_info에서 fallback 로드
const KNOWN_CAT_COLS = ["division", "unit", "type", "material"];
const allColNames = (tableConfig.columns || []).map((c: any) => c.columnName);
for (const colName of allColNames) {
if (mappings[colName]) continue;
if (!KNOWN_CAT_COLS.includes(colName)) continue;
try {
const fbRes = await apiClient.get(`/table-categories/item_info/${colName}/values?includeInactive=true`);
if (fbRes.data.success && fbRes.data.data?.length > 0) {
const fbMapping: Record<string, { label: string; color?: string }> = {};
flattenTree(fbRes.data.data, fbMapping);
if (Object.keys(fbMapping).length > 0) mappings[colName] = fbMapping;
}
} catch { /* 무시 */ }
}
setCategoryMappings(mappings);
if (Object.keys(mappings).length > 0) {
setCategoryMappingsKey((prev) => prev + 1);