Compare commits

..

No commits in common. "c98b2ccb4307f974ce14298c406eeed6106664cf" and "f6a02b5182a4f0b3900dc8da9188ad995dcce1e7" have entirely different histories.

17 changed files with 574 additions and 890 deletions

View File

@ -233,35 +233,8 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
const { moldCode } = req.params;
const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body;
let finalSerialNumber = serial_number;
// 일련번호가 비어있으면 채번 규칙으로 자동 생성
if (!finalSerialNumber) {
try {
const { numberingRuleService } = await import("../services/numberingRuleService");
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode,
"mold_serial",
"serial_number"
);
if (rule) {
// formData에 mold_code를 포함 (reference 파트에서 참조)
const formData = { mold_code: moldCode, ...req.body };
finalSerialNumber = await numberingRuleService.allocateCode(
rule.ruleId,
companyCode,
formData
);
logger.info("일련번호 자동 채번 완료", { serialNumber: finalSerialNumber, ruleId: rule.ruleId });
}
} catch (numError: any) {
logger.error("일련번호 자동 채번 실패", { error: numError.message });
}
}
if (!finalSerialNumber) {
res.status(400).json({ success: false, message: "일련번호를 생성할 수 없습니다. 채번 규칙을 확인해주세요." });
if (!serial_number) {
res.status(400).json({ success: false, message: "일련번호는 필수입니다." });
return;
}
@ -271,7 +244,7 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
RETURNING *
`;
const params = [
companyCode, moldCode, finalSerialNumber, status || "STORED",
companyCode, moldCode, serial_number, status || "STORED",
progress || 0, work_description || null, manager || null,
completion_date || null, remarks || null, userId,
];

View File

@ -405,30 +405,6 @@ router.post(
}
);
// 테이블+컬럼 기반 채번 규칙 조회 (메인 API)
router.get(
"/by-column/:tableName/:columnName",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.params;
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode,
tableName,
columnName
);
return res.json({ success: true, data: rule });
} catch (error: any) {
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", {
error: error.message,
});
return res.status(500).json({ success: false, error: error.message });
}
}
);
// ==================== 테스트 테이블용 API ====================
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회

View File

@ -3019,72 +3019,3 @@ export async function toggleColumnUnique(
});
}
}
/**
* ( )
*
* @route GET /api/table-management/numbering-columns
*/
export async function getNumberingColumnsByCompany(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode;
logger.info("회사별 채번 컬럼 조회 요청", { companyCode });
if (!companyCode) {
res.status(400).json({
success: false,
message: "회사 코드를 확인할 수 없습니다.",
});
return;
}
const { getPool } = await import("../database/db");
const pool = getPool();
const targetCompanyCode = companyCode === "*" ? "*" : companyCode;
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
ttc.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'numbering'
AND ttc.company_code = $1
ORDER BY ttc.table_name, ttc.column_name
`;
const columnsResult = await pool.query(columnsQuery, [targetCompanyCode]);
logger.info("채번 컬럼 조회 완료", {
companyCode,
rowCount: columnsResult.rows.length,
});
res.json({
success: true,
data: columnsResult.rows,
});
} catch (error: any) {
logger.error("채번 컬럼 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "채번 컬럼 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}

View File

@ -25,7 +25,6 @@ import {
toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
getNumberingColumnsByCompany, // 채번 타입 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
@ -255,12 +254,6 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
*/
router.get("/category-columns", getCategoryColumnsByCompany);
/**
*
* GET /api/table-management/numbering-columns
*/
router.get("/numbering-columns", getNumberingColumnsByCompany);
/**
*
* GET /api/table-management/menu/:menuObjid/category-columns

View File

@ -494,7 +494,7 @@ class MasterDetailExcelService {
/**
* , ID를
* numbering_rules table_name + column_name + company_code로
* , (*) fallback
*/
private async detectNumberingRuleForColumn(
tableName: string,
@ -502,58 +502,32 @@ class MasterDetailExcelService {
companyCode?: string
): Promise<{ numberingRuleId: string } | null> {
try {
// 1. table_type_columns에서 numbering 타입인지 확인
// 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저)
const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($3, '*')`
: `AND company_code = '*'`;
const ttcParams = companyCode && companyCode !== "*"
const params = companyCode && companyCode !== "*"
? [tableName, columnName, companyCode]
: [tableName, columnName];
const ttcResult = await query<any>(
`SELECT input_type FROM table_type_columns
const result = await query<any>(
`SELECT input_type, detail_settings, company_code
FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
AND input_type = 'numbering' LIMIT 1`,
ttcParams
);
if (ttcResult.length === 0) return null;
// 2. numbering_rules에서 table_name + column_name으로 규칙 조회
const ruleCompanyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($3, '*')`
: `AND company_code = '*'`;
const ruleParams = companyCode && companyCode !== "*"
? [tableName, columnName, companyCode]
: [tableName, columnName];
const ruleResult = await query<any>(
`SELECT rule_id FROM numbering_rules
WHERE table_name = $1 AND column_name = $2 ${ruleCompanyCondition}
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
LIMIT 1`,
ruleParams
);
if (ruleResult.length > 0) {
return { numberingRuleId: ruleResult[0].rule_id };
}
// 3. fallback: detail_settings.numberingRuleId (하위 호환)
const fallbackResult = await query<any>(
`SELECT detail_settings FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
AND input_type = 'numbering'
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
ttcParams
params
);
for (const row of fallbackResult) {
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) {
return { numberingRuleId: settings.numberingRuleId };
// 채번 타입인 행 찾기 (회사별 우선)
for (const row of result) {
if (row.input_type === "numbering") {
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) {
return { numberingRuleId: settings.numberingRuleId };
}
}
}
@ -566,7 +540,7 @@ class MasterDetailExcelService {
/**
*
* numbering_rules table_name + column_name으로
* , (*) fallback
* @returns Map<columnName, numberingRuleId>
*/
private async detectAllNumberingColumns(
@ -575,7 +549,6 @@ class MasterDetailExcelService {
): Promise<Map<string, string>> {
const numberingCols = new Map<string, string>();
try {
// 1. table_type_columns에서 numbering 타입 컬럼 목록 조회
const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($2, '*')`
: `AND company_code = '*'`;
@ -583,26 +556,22 @@ class MasterDetailExcelService {
? [tableName, companyCode]
: [tableName];
const ttcResult = await query<any>(
`SELECT DISTINCT column_name FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}`,
const result = await query<any>(
`SELECT column_name, detail_settings, company_code
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
params
);
// 2. 각 컬럼에 대해 numbering_rules에서 규칙 조회
for (const row of ttcResult) {
const ruleResult = await query<any>(
`SELECT rule_id FROM numbering_rules
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
LIMIT 1`,
companyCode && companyCode !== "*"
? [tableName, row.column_name, companyCode]
: [tableName, row.column_name]
);
if (ruleResult.length > 0) {
numberingCols.set(row.column_name, ruleResult[0].rule_id);
// 컬럼별로 회사 설정 우선 적용
for (const row of result) {
if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) {
numberingCols.set(row.column_name, settings.numberingRuleId);
}
}

View File

@ -172,16 +172,6 @@ class NumberingRuleService {
break;
}
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
prefixParts.push(String(formData[refColumn]));
} else {
prefixParts.push("");
}
break;
}
default:
break;
}
@ -1255,14 +1245,6 @@ class NumberingRuleService {
return "";
}
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "REF";
}
default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return "";
@ -1393,13 +1375,6 @@ class NumberingRuleService {
return catMapping2?.format || "CATEGORY";
}
case "reference": {
const refCol2 = autoConfig.referenceColumnName;
if (refCol2 && formData && formData[refCol2]) {
return String(formData[refCol2]);
}
return "REF";
}
default:
return "";
}
@ -1549,15 +1524,6 @@ class NumberingRuleService {
return "";
}
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
return "";
}
default:
return "";
}
@ -1781,53 +1747,7 @@ class NumberingRuleService {
`;
const params = [companyCode, tableName, columnName];
let result = await pool.query(query, params);
// fallback: column_name이 비어있는 레거시 규칙 검색
if (result.rows.length === 0) {
const fallbackQuery = `
SELECT
r.rule_id AS "ruleId",
r.rule_name AS "ruleName",
r.description,
r.separator,
r.reset_period AS "resetPeriod",
r.current_sequence AS "currentSequence",
r.table_name AS "tableName",
r.column_name AS "columnName",
r.company_code AS "companyCode",
r.category_column AS "categoryColumn",
r.category_value_id AS "categoryValueId",
cv.value_label AS "categoryValueLabel",
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules r
LEFT JOIN category_values cv ON r.category_value_id = cv.value_id
WHERE r.company_code = $1
AND r.table_name = $2
AND (r.column_name IS NULL OR r.column_name = '')
AND r.category_value_id IS NULL
ORDER BY r.updated_at DESC
LIMIT 1
`;
result = await pool.query(fallbackQuery, [companyCode, tableName]);
// 찾으면 column_name 자동 업데이트 (레거시 데이터 마이그레이션)
if (result.rows.length > 0) {
const foundRule = result.rows[0];
await pool.query(
`UPDATE numbering_rules SET column_name = $1 WHERE rule_id = $2 AND company_code = $3`,
[columnName, foundRule.ruleId, companyCode]
);
result.rows[0].columnName = columnName;
logger.info("레거시 채번 규칙 자동 매핑 완료", {
ruleId: foundRule.ruleId,
tableName,
columnName,
});
}
}
const result = await pool.query(query, params);
if (result.rows.length === 0) {
logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", {
@ -1840,6 +1760,7 @@ class NumberingRuleService {
const rule = result.rows[0];
// 파트 정보 조회 (테스트 테이블)
const partsQuery = `
SELECT
id,
@ -1858,7 +1779,7 @@ class NumberingRuleService {
]);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", {
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
ruleId: rule.ruleId,
ruleName: rule.ruleName,
});

View File

@ -2691,32 +2691,6 @@ export class TableManagementService {
logger.info(`created_date 자동 추가: ${data.created_date}`);
}
// 채번 자동 적용: input_type = 'numbering'인 컬럼에 값이 비어있으면 자동 채번
try {
const companyCode = data.company_code || "*";
const numberingColsResult = await query<any>(
`SELECT DISTINCT column_name FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering'
AND company_code IN ($2, '*')`,
[tableName, companyCode]
);
for (const row of numberingColsResult) {
const col = row.column_name;
if (!data[col] || data[col] === "" || data[col] === "자동 생성됩니다") {
const { numberingRuleService } = await import("./numberingRuleService");
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, col);
if (rule) {
const generatedCode = await numberingRuleService.allocateCode(rule.ruleId, companyCode, data);
data[col] = generatedCode;
logger.info(`채번 자동 적용: ${tableName}.${col} = ${generatedCode}`);
}
}
}
} catch (numErr: any) {
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
}
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
const skippedColumns: string[] = [];
const existingColumns = Object.keys(data).filter((col) => {

View File

@ -669,6 +669,38 @@ export default function TableManagementPage() {
console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings);
}
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
console.log("🔍 Numbering 저장 체크:", {
inputType: column.inputType,
numberingRuleId: column.numberingRuleId,
hasNumberingRuleId: !!column.numberingRuleId,
});
if (column.inputType === "numbering") {
let existingSettings: Record<string, unknown> = {};
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
try {
existingSettings = JSON.parse(finalDetailSettings);
} catch {
existingSettings = {};
}
}
// numberingRuleId가 있으면 저장, 없으면 제거
if (column.numberingRuleId) {
const numberingSettings = {
...existingSettings,
numberingRuleId: column.numberingRuleId,
};
finalDetailSettings = JSON.stringify(numberingSettings);
console.log("🔧 Numbering 설정 JSON 생성:", numberingSettings);
} else {
// numberingRuleId가 없으면 빈 객체
finalDetailSettings = JSON.stringify(existingSettings);
console.log("🔧 Numbering 규칙 없이 저장:", existingSettings);
}
}
const columnSetting = {
columnName: column.columnName,
columnLabel: column.displayName,
@ -812,6 +844,28 @@ export default function TableManagementPage() {
// detailSettings 계산
let finalDetailSettings = column.detailSettings || "";
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
if (column.inputType === "numbering" && column.numberingRuleId) {
let existingSettings: Record<string, unknown> = {};
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
try {
existingSettings = JSON.parse(finalDetailSettings);
} catch {
existingSettings = {};
}
}
const numberingSettings = {
...existingSettings,
numberingRuleId: column.numberingRuleId,
};
finalDetailSettings = JSON.stringify(numberingSettings);
console.log("🔧 전체저장 - Numbering 설정 JSON 생성:", {
columnName: column.columnName,
numberingRuleId: column.numberingRuleId,
finalDetailSettings,
});
}
// 🆕 Entity 타입인 경우 detailSettings에 엔티티 설정 포함
if (column.inputType === "entity" && column.referenceTable) {
let existingSettings: Record<string, unknown> = {};
@ -1933,7 +1987,118 @@ export default function TableManagementPage() {
)}
</>
)}
{/* 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요) */}
{/* 입력 타입이 'numbering'인 경우 채번규칙 선택 */}
{column.inputType === "numbering" && (
<div className="w-64">
<label className="text-muted-foreground mb-1 block text-xs"></label>
<Popover
open={numberingComboboxOpen[column.columnName] || false}
onOpenChange={(open) =>
setNumberingComboboxOpen((prev) => ({
...prev,
[column.columnName]: open,
}))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={numberingComboboxOpen[column.columnName] || false}
disabled={numberingRulesLoading}
className="bg-background h-8 w-full justify-between text-xs"
>
<span className="truncate">
{numberingRulesLoading
? "로딩 중..."
: column.numberingRuleId
? numberingRules.find((r) => r.ruleId === column.numberingRuleId)
?.ruleName || column.numberingRuleId
: "채번규칙 선택..."}
</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="규칙 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
<CommandItem
value="none"
onSelect={() => {
const columnIndex = columns.findIndex(
(c) => c.columnName === column.columnName,
);
handleColumnChange(columnIndex, "numberingRuleId", undefined);
setNumberingComboboxOpen((prev) => ({
...prev,
[column.columnName]: false,
}));
// 자동 저장 제거 - 전체 저장 버튼으로 저장
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
!column.numberingRuleId ? "opacity-100" : "opacity-0",
)}
/>
-- --
</CommandItem>
{numberingRules.map((rule) => (
<CommandItem
key={rule.ruleId}
value={`${rule.ruleName} ${rule.ruleId}`}
onSelect={() => {
const columnIndex = columns.findIndex(
(c) => c.columnName === column.columnName,
);
// 상태 업데이트만 (자동 저장 제거)
handleColumnChange(columnIndex, "numberingRuleId", rule.ruleId);
setNumberingComboboxOpen((prev) => ({
...prev,
[column.columnName]: false,
}));
// 전체 저장 버튼으로 저장
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.numberingRuleId === rule.ruleId
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{rule.ruleName}</span>
{rule.tableName && (
<span className="text-muted-foreground text-[10px]">
{rule.tableName}.{rule.columnName}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{column.numberingRuleId && (
<div className="bg-primary/10 text-primary mt-1 flex items-center gap-1 rounded px-2 py-0.5 text-[10px]">
<Check className="h-2.5 w-2.5" />
<span> </span>
</div>
)}
</div>
)}
</div>
</div>
<div className="pl-4">

View File

@ -18,7 +18,6 @@ interface AutoConfigPanelProps {
config?: any;
onChange: (config: any) => void;
isPreview?: boolean;
tableName?: string;
}
interface TableInfo {
@ -38,7 +37,6 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
config = {},
onChange,
isPreview = false,
tableName,
}) => {
// 1. 순번 (자동 증가)
if (partType === "sequence") {
@ -163,18 +161,6 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
);
}
// 6. 참조 (마스터-디테일 분번)
if (partType === "reference") {
return (
<ReferenceConfigSection
config={config}
onChange={onChange}
isPreview={isPreview}
tableName={tableName}
/>
);
}
return null;
};
@ -1102,94 +1088,3 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
</div>
);
};
function ReferenceConfigSection({
config,
onChange,
isPreview,
tableName,
}: {
config: any;
onChange: (c: any) => void;
isPreview: boolean;
tableName?: string;
}) {
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loadingCols, setLoadingCols] = useState(false);
useEffect(() => {
if (!tableName) return;
setLoadingCols(true);
const loadEntityColumns = async () => {
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(
`/screen-management/tables/${tableName}/columns`
);
const allCols = response.data?.data || response.data || [];
const entityCols = allCols.filter(
(c: any) =>
(c.inputType || c.input_type) === "entity" ||
(c.inputType || c.input_type) === "numbering"
);
setColumns(
entityCols.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName:
c.columnLabel || c.column_label || c.columnName || c.column_name,
dataType: c.dataType || c.data_type || "",
inputType: c.inputType || c.input_type || "",
}))
);
} catch {
setColumns([]);
} finally {
setLoadingCols(false);
}
};
loadEntityColumns();
}, [tableName]);
return (
<div className="space-y-3">
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={config.referenceColumnName || ""}
onValueChange={(value) =>
onChange({ ...config, referenceColumnName: value })
}
disabled={isPreview || loadingCols}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue
placeholder={
loadingCols
? "로딩 중..."
: columns.length === 0
? "엔티티 컬럼 없음"
: "컬럼 선택"
}
/>
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
className="text-xs"
>
{col.displayName} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
/
</p>
</div>
</div>
);
}

View File

@ -16,7 +16,6 @@ interface NumberingRuleCardProps {
onUpdate: (updates: Partial<NumberingRulePart>) => void;
onDelete: () => void;
isPreview?: boolean;
tableName?: string;
}
export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
@ -24,7 +23,6 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
onUpdate,
onDelete,
isPreview = false,
tableName,
}) => {
return (
<Card className="border-border bg-card flex-1">
@ -59,7 +57,6 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
date: { dateFormat: "YYYYMMDD" },
text: { textValue: "CODE" },
category: { categoryKey: "", categoryMappings: [] },
reference: { referenceColumnName: "" },
};
onUpdate({
partType: newPartType,
@ -108,7 +105,6 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
config={part.autoConfig}
onChange={(autoConfig) => onUpdate({ autoConfig })}
isPreview={isPreview}
tableName={tableName}
/>
) : (
<ManualConfigPanel

View File

@ -1,30 +1,36 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, FolderTree } from "lucide-react";
import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { apiClient } from "@/lib/api/client";
import {
saveNumberingRuleToTest,
deleteNumberingRuleFromTest,
getNumberingRulesFromTest,
} from "@/lib/api/numberingRule";
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
interface NumberingColumn {
tableName: string;
tableLabel: string;
columnName: string;
columnLabel: string;
}
interface GroupedColumns {
tableLabel: string;
columns: NumberingColumn[];
// 카테고리 값 트리 노드 타입
interface CategoryValueNode {
valueId: number;
valueCode: string;
valueLabel: string;
depth: number;
path: string;
parentValueId: number | null;
children?: CategoryValueNode[];
}
interface NumberingRuleDesignerProps {
@ -48,100 +54,138 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
currentTableName,
menuObjid,
}) => {
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
const [loading, setLoading] = useState(false);
const [columnSearch, setColumnSearch] = useState("");
const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록");
const [rightTitle, setRightTitle] = useState("규칙 편집");
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태 (개별 파트 사이 구분자)
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
// 좌측: 채번 타입 컬럼 목록 로드
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
interface CategoryOption {
tableName: string;
columnName: string;
displayName: string; // "테이블명.컬럼명" 형식
}
const [allCategoryOptions, setAllCategoryOptions] = useState<CategoryOption[]>([]);
const [selectedCategoryKey, setSelectedCategoryKey] = useState<string>(""); // "tableName.columnName"
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
const [categoryValueOpen, setCategoryValueOpen] = useState(false);
const [loadingCategories, setLoadingCategories] = useState(false);
useEffect(() => {
loadNumberingColumns();
loadRules();
loadAllCategoryOptions(); // 전체 카테고리 옵션 로드
}, []);
const loadNumberingColumns = async () => {
// currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화
useEffect(() => {
if (currentRule?.categoryColumn) {
setSelectedCategoryKey(currentRule.categoryColumn);
} else {
setSelectedCategoryKey("");
}
}, [currentRule?.categoryColumn]);
// 카테고리 키 선택 시 해당 카테고리 값 로드
useEffect(() => {
if (selectedCategoryKey) {
const [tableName, columnName] = selectedCategoryKey.split(".");
if (tableName && columnName) {
loadCategoryValues(tableName, columnName);
}
} else {
setCategoryValues([]);
}
}, [selectedCategoryKey]);
// 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼)
const loadAllCategoryOptions = async () => {
try {
// category_values 테이블에서 고유한 테이블.컬럼 조합 조회
const response = await getAllCategoryKeys();
if (response.success && response.data) {
const options: CategoryOption[] = response.data.map((item) => ({
tableName: item.tableName,
columnName: item.columnName,
displayName: `${item.tableName}.${item.columnName}`,
}));
setAllCategoryOptions(options);
console.log("전체 카테고리 옵션 로드:", options);
}
} catch (error) {
console.error("카테고리 옵션 목록 조회 실패:", error);
}
};
// 특정 카테고리 컬럼의 값 트리 조회
const loadCategoryValues = async (tableName: string, columnName: string) => {
setLoadingCategories(true);
try {
const response = await getCategoryTree(tableName, columnName);
if (response.success && response.data) {
setCategoryValues(response.data);
console.log("카테고리 값 로드:", { tableName, columnName, count: response.data.length });
} else {
setCategoryValues([]);
}
} catch (error) {
console.error("카테고리 값 트리 조회 실패:", error);
setCategoryValues([]);
} finally {
setLoadingCategories(false);
}
};
// 카테고리 값을 플랫 리스트로 변환 (UI에서 선택용)
const flattenCategoryValues = (nodes: CategoryValueNode[], result: CategoryValueNode[] = []): CategoryValueNode[] => {
for (const node of nodes) {
result.push(node);
if (node.children && node.children.length > 0) {
flattenCategoryValues(node.children, result);
}
}
return result;
};
const flatCategoryValues = flattenCategoryValues(categoryValues);
const loadRules = useCallback(async () => {
setLoading(true);
try {
const response = await apiClient.get("/table-management/numbering-columns");
if (response.data.success && response.data.data) {
setNumberingColumns(response.data.data);
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작 (test 테이블):", {
menuObjid,
hasMenuObjid: !!menuObjid,
});
// test 테이블에서 조회
const response = await getNumberingRulesFromTest(menuObjid);
console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답 (test 테이블):", {
menuObjid,
success: response.success,
rulesCount: response.data?.length || 0,
rules: response.data,
});
if (response.success && response.data) {
setSavedRules(response.data);
} else {
toast.error(response.error || "규칙 목록을 불러올 수 없습니다");
}
} catch (error: any) {
console.error("채번 컬럼 목록 로드 실패:", error);
toast.error(`로딩 실패: ${error.message}`);
} finally {
setLoading(false);
}
};
// 컬럼 선택 시 해당 컬럼의 채번 규칙 로드
const handleSelectColumn = async (tableName: string, columnName: string) => {
setSelectedColumn({ tableName, columnName });
setLoading(true);
try {
const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
if (response.data.success && response.data.data) {
const rule = response.data.data as NumberingRuleConfig;
setCurrentRule(JSON.parse(JSON.stringify(rule)));
} else {
// 규칙 없으면 신규 생성 모드
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: `${columnName} 채번`,
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table",
tableName,
columnName,
};
setCurrentRule(newRule);
}
} catch {
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: `${columnName} 채번`,
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table",
tableName,
columnName,
};
setCurrentRule(newRule);
} finally {
setLoading(false);
}
};
// 테이블별로 그룹화
const groupedColumns = numberingColumns.reduce<Record<string, GroupedColumns>>((acc, col) => {
if (!acc[col.tableName]) {
acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] };
}
acc[col.tableName].columns.push(col);
return acc;
}, {});
// 검색 필터 적용
const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => {
if (!columnSearch) return true;
const search = columnSearch.toLowerCase();
return (
tableName.toLowerCase().includes(search) ||
group.tableLabel.toLowerCase().includes(search) ||
group.columns.some(
(c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search)
)
);
});
}, [menuObjid]);
useEffect(() => {
if (currentRule) {
@ -299,20 +343,60 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
return part;
});
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
// menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지
const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null;
const effectiveScopeType = effectiveMenuObjid ? "menu" : (currentRule.scopeType || "global");
const ruleToSave = {
...currentRule,
parts: partsWithDefaults,
scopeType: "table" as const,
tableName: selectedColumn?.tableName || currentRule.tableName || "",
columnName: selectedColumn?.columnName || currentRule.columnName || "",
scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준)
};
console.log("💾 채번 규칙 저장:", {
currentTableName,
menuObjid,
"currentRule.tableName": currentRule.tableName,
"currentRule.menuObjid": currentRule.menuObjid,
"ruleToSave.tableName": ruleToSave.tableName,
"ruleToSave.menuObjid": ruleToSave.menuObjid,
"ruleToSave.scopeType": ruleToSave.scopeType,
ruleToSave,
});
// 테스트 테이블에 저장 (numbering_rules)
const response = await saveNumberingRuleToTest(ruleToSave);
if (response.success && response.data) {
// 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
// setSavedRules 내부에서 prev를 사용해서 existing 확인 (클로저 문제 방지)
setSavedRules((prev) => {
const savedData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
const existsInPrev = prev.some((r) => r.ruleId === ruleToSave.ruleId);
console.log("🔍 [handleSave] setSavedRules:", {
ruleId: ruleToSave.ruleId,
existsInPrev,
prevCount: prev.length,
});
if (existsInPrev) {
// 기존 규칙 업데이트
return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? savedData : r));
} else {
// 새 규칙 추가
return [...prev, savedData];
}
});
setCurrentRule(currentData);
setSelectedRuleId(response.data.ruleId);
await onSave?.(response.data);
toast.success("채번 규칙이 저장되었습니다");
} else {
@ -323,62 +407,143 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
} finally {
setLoading(false);
}
}, [currentRule, onSave, selectedColumn]);
}, [currentRule, onSave, currentTableName, menuObjid]);
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
console.log("🔍 [handleSelectRule] 규칙 선택:", {
ruleId: rule.ruleId,
ruleName: rule.ruleName,
partsCount: rule.parts?.length || 0,
parts: rule.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
});
setSelectedRuleId(rule.ruleId);
// 깊은 복사하여 객체 참조 분리 (좌측 목록과 편집 영역의 객체가 공유되지 않도록)
const ruleCopy = JSON.parse(JSON.stringify(rule)) as NumberingRuleConfig;
console.log("🔍 [handleSelectRule] 깊은 복사 후:", {
ruleId: ruleCopy.ruleId,
partsCount: ruleCopy.parts?.length || 0,
parts: ruleCopy.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
});
setCurrentRule(ruleCopy);
toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`);
}, []);
const handleDeleteSavedRule = useCallback(
async (ruleId: string) => {
setLoading(true);
try {
const response = await deleteNumberingRuleFromTest(ruleId);
if (response.success) {
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));
if (selectedRuleId === ruleId) {
setSelectedRuleId(null);
setCurrentRule(null);
}
toast.success("규칙이 삭제되었습니다");
} else {
showErrorToast("채번 규칙 삭제에 실패했습니다", response.error, { guidance: "잠시 후 다시 시도해 주세요." });
}
} catch (error: any) {
showErrorToast("채번 규칙 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally {
setLoading(false);
}
},
[selectedRuleId],
);
const handleNewRule = useCallback(() => {
console.log("📋 새 규칙 생성:", { currentTableName, menuObjid });
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: "새 채번 규칙",
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table", // ⚠️ 임시: DB 제약 조건 때문에 table 유지
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
menuObjid: menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
};
console.log("📋 생성된 규칙 정보:", newRule);
setSelectedRuleId(newRule.ruleId);
setCurrentRule(newRule);
toast.success("새 규칙이 생성되었습니다");
}, [currentTableName, menuObjid]);
return (
<div className={`flex h-full gap-4 ${className}`}>
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
<div className="flex w-72 flex-shrink-0 flex-col gap-3">
<h2 className="text-sm font-semibold sm:text-base"> </h2>
{/* 좌측: 저장된 규칙 목록 */}
<div className="flex w-80 flex-shrink-0 flex-col gap-4">
<div className="flex items-center justify-between">
{editingLeftTitle ? (
<Input
value={leftTitle}
onChange={(e) => setLeftTitle(e.target.value)}
onBlur={() => setEditingLeftTitle(false)}
onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)}
className="h-8 text-sm font-semibold"
autoFocus
/>
) : (
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
)}
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingLeftTitle(true)}>
<Edit2 className="h-3 w-3" />
</Button>
</div>
<Input
value={columnSearch}
onChange={(e) => setColumnSearch(e.target.value)}
placeholder="검색..."
className="h-8 text-xs"
/>
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
<div className="flex-1 space-y-1 overflow-y-auto">
{loading && numberingColumns.length === 0 ? (
<div className="flex-1 space-y-2 overflow-y-auto">
{loading ? (
<div className="flex h-32 items-center justify-center">
<p className="text-muted-foreground text-xs"> ...</p>
</div>
) : filteredGroups.length === 0 ? (
) : savedRules.length === 0 ? (
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
<p className="text-muted-foreground text-xs">
{numberingColumns.length === 0
? "채번 타입 컬럼이 없습니다"
: "검색 결과가 없습니다"}
</p>
<p className="text-muted-foreground text-xs"> </p>
</div>
) : (
filteredGroups.map(([tableName, group]) => (
<div key={tableName} className="mb-2">
<div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
<FolderTree className="h-3 w-3" />
<span>{group.tableLabel}</span>
<span className="text-muted-foreground/60">({group.columns.length})</span>
</div>
{group.columns.map((col) => {
const isSelected =
selectedColumn?.tableName === col.tableName &&
selectedColumn?.columnName === col.columnName;
return (
<div
key={`${col.tableName}.${col.columnName}`}
className={cn(
"cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
isSelected
? "bg-primary/10 text-primary border-primary border font-medium"
: "hover:bg-accent"
)}
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
>
{col.columnLabel}
savedRules.map((rule) => (
<Card
key={rule.ruleId}
className={`border-border hover:bg-accent cursor-pointer py-2 transition-colors ${
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
}`}
onClick={() => handleSelectRule(rule)}
>
<CardHeader className="px-3 py-0">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle>
</div>
);
})}
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDeleteSavedRule(rule.ruleId);
}}
>
<Trash2 className="text-destructive h-3 w-3" />
</Button>
</div>
</CardHeader>
</Card>
))
)}
</div>
@ -392,9 +557,8 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
{!currentRule ? (
<div className="flex h-full flex-col items-center justify-center">
<div className="text-center">
<FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground mb-2 text-lg font-medium"> </p>
<p className="text-muted-foreground text-sm"> </p>
<p className="text-muted-foreground mb-2 text-lg font-medium"> </p>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
) : (
@ -460,7 +624,6 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
onDelete={() => handleDeletePart(part.order)}
isPreview={isPreview}
tableName={selectedColumn?.tableName}
/>
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
{index < currentRule.parts.length - 1 && (

View File

@ -619,40 +619,45 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
try {
// 채번 규칙 ID 캐싱 (한 번만 조회)
if (!numberingRuleIdRef.current) {
// table_name + column_name 기반으로 채번 규칙 조회
try {
const { apiClient } = await import("@/lib/api/client");
const ruleResponse = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
if (ruleResponse.data?.success && ruleResponse.data?.data?.ruleId) {
numberingRuleIdRef.current = ruleResponse.data.data.ruleId;
const { getTableColumns } = await import("@/lib/api/tableManagement");
const columnsResponse = await getTableColumns(tableName);
if (onFormDataChange && columnName) {
onFormDataChange(`${columnName}_numberingRuleId`, ruleResponse.data.data.ruleId);
}
}
} catch {
// by-column 조회 실패 시 detailSettings fallback
if (!columnsResponse.success || !columnsResponse.data) {
console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse);
return;
}
const columns = columnsResponse.data.columns || columnsResponse.data;
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
if (!targetColumn) {
console.warn("컬럼 정보를 찾을 수 없습니다:", columnName);
return;
}
// detailSettings에서 numberingRuleId 추출
if (targetColumn.detailSettings) {
try {
const { getTableColumns } = await import("@/lib/api/tableManagement");
const columnsResponse = await getTableColumns(tableName);
if (columnsResponse.success && columnsResponse.data) {
const columns = columnsResponse.data.columns || columnsResponse.data;
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
if (targetColumn?.detailSettings) {
const parsed = typeof targetColumn.detailSettings === "string"
? JSON.parse(targetColumn.detailSettings)
: targetColumn.detailSettings;
numberingRuleIdRef.current = parsed.numberingRuleId || null;
}
// 문자열이면 파싱, 객체면 그대로 사용
const parsed = typeof targetColumn.detailSettings === "string"
? JSON.parse(targetColumn.detailSettings)
: targetColumn.detailSettings;
numberingRuleIdRef.current = parsed.numberingRuleId || null;
// 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해)
if (parsed.numberingRuleId && onFormDataChange && columnName) {
onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId);
}
} catch { /* ignore */ }
} catch {
// JSON 파싱 실패
}
}
}
const numberingRuleId = numberingRuleIdRef.current;
if (!numberingRuleId) {
console.warn("채번 규칙을 찾을 수 없습니다. 옵션설정 > 채번설정에서 규칙을 생성하세요.", { tableName, columnName });
console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName });
return;
}

View File

@ -929,42 +929,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return result;
}, []);
// 프로그레스바 셀 렌더링 (부모 값 대비 자식 값 비율)
const renderProgressCell = useCallback(
(col: any, item: any, parentData: any) => {
const current = Number(item[col.numerator] || 0);
const max = Number(parentData?.[col.denominator] || item[col.denominator] || 0);
const percentage = max > 0 ? Math.round((current / max) * 100) : 0;
const barWidth = Math.min(percentage, 100);
const barColor =
percentage > 100
? "bg-red-600"
: percentage >= 90
? "bg-red-500"
: percentage >= 70
? "bg-amber-500"
: "bg-emerald-500";
return (
<div className="flex min-w-[120px] items-center gap-2">
<div className="flex-1">
<div className="bg-muted h-2 w-full rounded-full">
<div
className={`h-2 rounded-full transition-all ${barColor}`}
style={{ width: `${barWidth}%` }}
/>
</div>
<div className="text-muted-foreground mt-0.5 text-[10px]">
{current.toLocaleString()} / {max.toLocaleString()}
</div>
</div>
<span className="shrink-0 text-xs font-medium">{percentage}%</span>
</div>
);
},
[],
);
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
const formatCellValue = useCallback(
(
@ -3986,14 +3950,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
>
{tabSummaryColumns.map((col: any) => (
<td key={col.name} className="px-3 py-2 text-xs">
{col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem)
: formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
rightCategoryMappings,
col.format,
)}
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
rightCategoryMappings,
col.format,
)}
</td>
))}
{hasTabActions && (
@ -4102,14 +4064,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
>
{listSummaryColumns.map((col: any) => (
<td key={col.name} className="px-3 py-2 text-xs">
{col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem)
: formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
rightCategoryMappings,
col.format,
)}
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
rightCategoryMappings,
col.format,
)}
</td>
))}
{hasTabActions && (
@ -4526,14 +4486,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="px-3 py-2 text-xs whitespace-nowrap"
style={{ textAlign: col.align || "left" }}
>
{col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem)
: formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
rightCategoryMappings,
col.format,
)}
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
rightCategoryMappings,
col.format,
)}
</td>
))}
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */}

View File

@ -28,10 +28,10 @@ import { CSS } from "@dnd-kit/utilities";
// 드래그 가능한 컬럼 아이템
function SortableColumnRow({
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange, onProgressChange, availableChildColumns, availableParentColumns,
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
}: {
id: string;
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean; type?: string; numerator?: string; denominator?: string };
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
index: number;
isNumeric: boolean;
isEntityJoin?: boolean;
@ -41,9 +41,6 @@ function SortableColumnRow({
onRemove: () => void;
onShowInSummaryChange?: (checked: boolean) => void;
onShowInDetailChange?: (checked: boolean) => void;
onProgressChange?: (updates: { numerator?: string; denominator?: string }) => void;
availableChildColumns?: Array<{ columnName: string; columnLabel: string }>;
availableParentColumns?: Array<{ columnName: string; columnLabel: string }>;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = { transform: CSS.Transform.toString(transform), transition };
@ -56,44 +53,12 @@ function SortableColumnRow({
"flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5",
isDragging && "z-50 opacity-50 shadow-md",
isEntityJoin && "border-blue-200 bg-blue-50/30",
col.type === "progress" && "border-emerald-200 bg-emerald-50/30",
)}
>
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
<GripVertical className="h-3 w-3" />
</div>
{col.type === "progress" ? (
<Popover>
<PopoverTrigger asChild>
<button className="shrink-0 cursor-pointer rounded bg-emerald-100 px-1 text-[9px] font-medium text-emerald-700 hover:bg-emerald-200" title="클릭하여 설정 변경">BAR</button>
</PopoverTrigger>
<PopoverContent className="w-56 space-y-2 p-3" align="start">
<p className="text-xs font-medium"> </p>
<div className="space-y-1">
<Label className="text-[10px]"> ( )</Label>
<Select value={col.numerator || ""} onValueChange={(v) => onProgressChange?.({ numerator: v })}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
<SelectContent>
{(availableChildColumns || []).map((c) => (
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">{c.columnLabel || c.columnName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> ( )</Label>
<Select value={col.denominator || ""} onValueChange={(v) => onProgressChange?.({ denominator: v })}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
<SelectContent>
{(availableParentColumns || []).map((c) => (
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">{c.columnLabel || c.columnName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
) : isEntityJoin ? (
{isEntityJoin ? (
<Link2 className="h-3 w-3 shrink-0 text-blue-500" title="Entity 조인 컬럼" />
) : (
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
@ -691,13 +656,6 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
newColumns[index] = { ...newColumns[index], showInDetail: checked };
updateTab({ columns: newColumns });
}}
onProgressChange={(updates) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], ...updates };
updateTab({ columns: newColumns });
}}
availableChildColumns={tabColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))}
availableParentColumns={leftTableColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))}
/>
);
})}
@ -727,104 +685,6 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
))}
</div>
{/* 프로그레스 컬럼 추가 */}
{tab.tableName && (
<div className="border-border/60 my-2 border-t pt-2">
<details className="group">
<summary className="flex cursor-pointer list-none items-center gap-2 select-none">
<ChevronRight className="h-3 w-3 shrink-0 text-emerald-500 transition-transform group-open:rotate-90" />
<span className="text-[10px] font-medium text-emerald-600"> </span>
</summary>
<div className="mt-2 space-y-2 rounded-md border border-emerald-200 bg-emerald-50/50 p-2">
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
id={`tab-${tabIndex}-progress-label`}
placeholder="예: 샷수 현황"
className="h-7 text-xs"
defaultValue=""
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"> ( )</Label>
<Select
onValueChange={(v) => {
const el = document.getElementById(`tab-${tabIndex}-progress-numerator`) as HTMLInputElement;
if (el) el.value = v;
}}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tabColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" id={`tab-${tabIndex}-progress-numerator`} />
</div>
<div className="space-y-1">
<Label className="text-[10px]"> ( )</Label>
<Select
onValueChange={(v) => {
const el = document.getElementById(`tab-${tabIndex}-progress-denominator`) as HTMLInputElement;
if (el) el.value = v;
}}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{leftTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" id={`tab-${tabIndex}-progress-denominator`} />
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 w-full text-xs text-emerald-700 border-emerald-300 hover:bg-emerald-100"
onClick={() => {
const labelEl = document.getElementById(`tab-${tabIndex}-progress-label`) as HTMLInputElement;
const numEl = document.getElementById(`tab-${tabIndex}-progress-numerator`) as HTMLInputElement;
const denEl = document.getElementById(`tab-${tabIndex}-progress-denominator`) as HTMLInputElement;
const label = labelEl?.value || "프로그레스";
const numerator = numEl?.value;
const denominator = denEl?.value;
if (!numerator || !denominator) return;
updateTab({
columns: [
...selectedColumns,
{
name: `progress_${numerator}_${denominator}`,
label,
width: 200,
type: "progress",
numerator,
denominator,
} as any,
],
});
if (labelEl) labelEl.value = "";
}}
>
</Button>
</div>
</details>
</div>
)}
{/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */}
{(() => {
const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null;

View File

@ -35,20 +35,14 @@ export const StatusCountComponent: React.FC<StatusCountComponentProps> = ({
setLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${tableName}/data`, {
page: 1,
size: 9999,
search: relationColumn ? { [relationColumn]: parentValue } : {},
const res = await apiClient.get(`/table-management/data/${tableName}`, {
params: {
autoFilter: "true",
[relationColumn]: parentValue,
},
});
const responseData = res.data?.data;
let rows: any[] = [];
if (Array.isArray(responseData)) {
rows = responseData;
} else if (responseData && typeof responseData === "object") {
rows = Array.isArray(responseData.data) ? responseData.data :
Array.isArray(responseData.rows) ? responseData.rows : [];
}
const rows: any[] = res.data?.data || res.data?.rows || res.data || [];
const grouped: Record<string, number> = {};
for (const row of rows) {
@ -75,7 +69,7 @@ export const StatusCountComponent: React.FC<StatusCountComponentProps> = ({
};
const getCount = (item: StatusCountItem) => {
if (item.value === "__TOTAL__" || item.value === "__ALL__") {
if (item.value === "__TOTAL__") {
return Object.values(counts).reduce((sum, c) => sum + c, 0);
}
const values = item.value.split(",").map((v) => v.trim());

View File

@ -233,47 +233,6 @@ export const StatusCountConfigPanel: React.FC<StatusCountConfigPanelProps> = ({
);
};
// 상태 컬럼의 카테고리 값 로드
const [statusCategoryValues, setStatusCategoryValues] = useState<Array<{ value: string; label: string }>>([]);
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
useEffect(() => {
if (!config.tableName || !config.statusColumn) {
setStatusCategoryValues([]);
return;
}
const loadCategoryValues = async () => {
setLoadingCategoryValues(true);
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(
`/table-categories/${config.tableName}/${config.statusColumn}/values`
);
if (response.data?.success && response.data?.data) {
const flatValues: Array<{ value: string; label: string }> = [];
const flatten = (items: any[]) => {
for (const item of items) {
flatValues.push({
value: item.valueCode || item.value_code,
label: item.valueLabel || item.value_label,
});
if (item.children?.length > 0) flatten(item.children);
}
};
flatten(response.data.data);
setStatusCategoryValues(flatValues);
}
} catch {
setStatusCategoryValues([]);
} finally {
setLoadingCategoryValues(false);
}
};
loadCategoryValues();
}, [config.tableName, config.statusColumn]);
const tableComboItems = tables.map((t) => ({
value: t.tableName,
label: t.displayName,
@ -411,52 +370,15 @@ export const StatusCountConfigPanel: React.FC<StatusCountConfigPanelProps> = ({
</Button>
</div>
{loadingCategoryValues && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
)}
{items.map((item: StatusCountItem, i: number) => (
<div key={i} className="space-y-1 rounded-md border p-2">
<div className="flex items-center gap-1">
{statusCategoryValues.length > 0 ? (
<Select
value={item.value || ""}
onValueChange={(v) => {
handleItemChange(i, "value", v);
if (v === "__ALL__" && !item.label) {
handleItemChange(i, "label", "전체");
} else {
const catVal = statusCategoryValues.find((cv) => cv.value === v);
if (catVal && !item.label) {
handleItemChange(i, "label", catVal.label);
}
}
}}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="카테고리 값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__ALL__" className="text-xs font-medium">
</SelectItem>
{statusCategoryValues.map((cv) => (
<SelectItem key={cv.value} value={cv.value} className="text-xs">
{cv.label} ({cv.value})
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={item.value}
onChange={(e) => handleItemChange(i, "value", e.target.value)}
placeholder="상태값 (예: IN_USE)"
className="h-7 text-xs"
/>
)}
<Input
value={item.value}
onChange={(e) => handleItemChange(i, "value", e.target.value)}
placeholder="상태값 (예: IN_USE)"
className="h-7 text-xs"
/>
<Button
variant="ghost"
size="sm"
@ -496,12 +418,6 @@ export const StatusCountConfigPanel: React.FC<StatusCountConfigPanelProps> = ({
</div>
</div>
))}
{!loadingCategoryValues && statusCategoryValues.length === 0 && config.tableName && config.statusColumn && (
<p className="text-[10px] text-amber-600">
. &gt; .
</p>
)}
</div>
</div>
);

View File

@ -7,12 +7,11 @@
* (5)
*/
export type CodePartType =
| "sequence" // 순번 (자동 증가 숫자)
| "number" // 숫자 (고정 자릿수)
| "date" // 날짜 (다양한 날짜 형식)
| "text" // 문자 (텍스트)
| "category" // 카테고리 (카테고리 값에 따른 형식)
| "reference"; // 참조 (다른 컬럼의 값을 가져옴, 마스터-디테일 분번용)
| "sequence" // 순번 (자동 증가 숫자)
| "number" // 숫자 (고정 자릿수)
| "date" // 날짜 (다양한 날짜 형식)
| "text" // 문자 (텍스트)
| "category"; // 카테고리 (카테고리 값에 따른 형식)
/**
*
@ -78,9 +77,6 @@ export interface NumberingRulePart {
// 카테고리용
categoryKey?: string; // 카테고리 키 (테이블.컬럼 형식, 예: "item_info.type")
categoryMappings?: CategoryFormatMapping[]; // 카테고리 값별 형식 매핑
// 참조용 (마스터-디테일 분번)
referenceColumnName?: string; // 참조할 컬럼명 (FK 컬럼 등, 해당 컬럼의 값을 코드에 포함)
};
// 직접 입력 설정
@ -136,7 +132,6 @@ export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string;
{ value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" },
{ value: "text", label: "문자", description: "텍스트 또는 코드" },
{ value: "category", label: "카테고리", description: "카테고리 값에 따른 형식" },
{ value: "reference", label: "참조", description: "다른 컬럼 값 참조 (마스터 키 분번)" },
];
export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [