; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
leeheejin
2025-11-05 13:10:25 +09:00
21 changed files with 874 additions and 196 deletions

View File

@@ -21,6 +21,25 @@ export async function getNumberingRules(): Promise<ApiResponse<NumberingRuleConf
}
}
/**
* 메뉴별 사용 가능한 채번 규칙 조회
* @param menuObjid 현재 메뉴의 objid (선택)
* @returns 사용 가능한 채번 규칙 목록
*/
export async function getAvailableNumberingRules(
menuObjid?: number
): Promise<ApiResponse<NumberingRuleConfig[]>> {
try {
const url = menuObjid
? `/numbering-rules/available/${menuObjid}`
: "/numbering-rules/available";
const response = await apiClient.get(url);
return response.data;
} catch (error: any) {
return { success: false, error: error.message || "사용 가능한 규칙 조회 실패" };
}
}
export async function getNumberingRuleById(ruleId: string): Promise<ApiResponse<NumberingRuleConfig>> {
try {
const response = await apiClient.get(`/numbering-rules/${ruleId}`);
@@ -62,15 +81,49 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
}
}
export async function generateCode(ruleId: string): Promise<ApiResponse<{ code: string }>> {
/**
* 코드 미리보기 (순번 증가 없음)
* 화면 표시용으로 사용
*/
export async function previewNumberingCode(
ruleId: string
): Promise<ApiResponse<{ generatedCode: string }>> {
try {
const response = await apiClient.post(`/numbering-rules/${ruleId}/generate`);
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message || "코드 생성 실패" };
return { success: false, error: error.message || "코드 미리보기 실패" };
}
}
/**
* 코드 할당 (저장 시점에 실제 순번 증가)
* 실제 저장할 때만 호출
*/
export async function allocateNumberingCode(
ruleId: string
): Promise<ApiResponse<{ generatedCode: string }>> {
try {
const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message || "코드 할당 실패" };
}
}
/**
* @deprecated 기존 generateNumberingCode는 previewNumberingCode를 사용하세요
*/
export async function generateNumberingCode(
ruleId: string
): Promise<ApiResponse<{ generatedCode: string }>> {
console.warn("generateNumberingCode는 deprecated. previewNumberingCode 사용 권장");
return previewNumberingCode(ruleId);
}
// 하위 호환성을 위한 별칭
export const generateCode = generateNumberingCode;
export async function resetSequence(ruleId: string): Promise<ApiResponse<void>> {
try {
const response = await apiClient.post(`/numbering-rules/${ruleId}/reset`);
@@ -79,3 +132,4 @@ export async function resetSequence(ruleId: string): Promise<ApiResponse<void>>
return { success: false, error: error.message || "시퀀스 초기화 실패" };
}
}

View File

@@ -1497,7 +1497,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
)}
<div className="mt-10" style={{ flex: 1, overflow: "hidden" }}>
<div style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 40}px`, flex: 1, overflow: "hidden" }}>
<SingleTableWithSticky
data={data}
columns={visibleColumns}
@@ -1596,7 +1596,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)}
{/* 테이블 컨테이너 */}
<div className="flex-1 flex flex-col overflow-hidden w-full max-w-full mt-10">
<div
className="flex-1 flex flex-col overflow-hidden w-full max-w-full"
style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 40}px` }}
>
{/* 스크롤 영역 */}
<div
className="w-full max-w-full h-[400px] overflow-y-scroll overflow-x-auto bg-background sm:h-[500px]"

View File

@@ -1105,6 +1105,41 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div>
</div>
)}
{/* 필터 간격 설정 */}
{config.filter?.enabled && (
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
</div>
<hr className="border-border" />
<div className="space-y-1">
<Label htmlFor="filterBottomSpacing" className="text-xs">
(px)
</Label>
<Input
id="filterBottomSpacing"
type="number"
value={config.filter?.bottomSpacing ?? 40}
onChange={(e) => {
const value = Math.max(0, Math.min(200, parseInt(e.target.value) || 40));
handleChange("filter", {
...config.filter,
bottomSpacing: value,
});
}}
min={0}
max={200}
step={10}
placeholder="40"
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
기본값: 40px (0-200px , 10px )
</p>
</div>
</div>
)}
</div>
</div>
);

View File

@@ -72,6 +72,7 @@ export const TableListDefinition = createComponentDefinition({
filter: {
enabled: true,
filters: [], // 사용자가 설정할 필터 목록
bottomSpacing: 40, // 필터와 리스트 사이 간격 (px)
},
// 액션 설정

View File

@@ -19,6 +19,7 @@ export type AutoGenerationType =
| "current_user" // 현재 사용자 ID
| "current_time" // 현재 시간
| "sequence" // 시퀀스 번호
| "numbering_rule" // 채번 규칙
| "random_string" // 랜덤 문자열
| "random_number" // 랜덤 숫자
| "company_code" // 회사 코드
@@ -37,6 +38,7 @@ export interface AutoGenerationConfig {
suffix?: string; // 접미사
format?: string; // 시간 형식 (current_time용)
startValue?: number; // 시퀀스 시작값
numberingRuleId?: string; // 채번 규칙 ID (numbering_rule 타입용)
};
}
@@ -114,6 +116,8 @@ export interface FilterConfig {
referenceColumn?: string;
displayColumn?: string;
}>;
// 필터와 리스트 사이 간격 (px 단위, 기본: 40)
bottomSpacing?: number;
}
/**

View File

@@ -53,6 +53,10 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// 자동생성된 값 상태
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
// API 호출 중복 방지를 위한 ref
const isGeneratingRef = React.useRef(false);
const hasGeneratedRef = React.useRef(false);
// 테스트용: 컴포넌트 라벨에 "test"가 포함되면 강제로 UUID 자동생성 활성화
const testAutoGeneration = component.label?.toLowerCase().includes("test")
@@ -79,32 +83,96 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// autoGeneratedValue,
// });
// 자동생성 값 생성 (컴포넌트 마운트 시 또는 폼 데이터 변경 시)
// 자동생성 값 생성 (컴포넌트 마운트 시 한 번만 실행)
useEffect(() => {
if (testAutoGeneration.enabled && testAutoGeneration.type !== "none") {
// 폼 데이터에 이미 값이 있으면 자동생성하지 않음
const currentFormValue = formData?.[component.columnName];
const currentComponentValue = component.value;
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
const generatedValue = AutoGenerationUtils.generateValue(testAutoGeneration, component.columnName);
if (generatedValue) {
setAutoGeneratedValue(generatedValue);
// 폼 데이터에 자동생성된 값 설정 (인터랙티브 모드에서만)
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, generatedValue);
}
}
} else if (!autoGeneratedValue && testAutoGeneration.type !== "none") {
// 디자인 모드에서도 미리보기용 자동생성 값 표시
const previewValue = AutoGenerationUtils.generatePreviewValue(testAutoGeneration);
setAutoGeneratedValue(previewValue);
const generateAutoValue = async () => {
// 이미 생성 중이거나 생성 완료된 경우 중복 실행 방지
if (isGeneratingRef.current || hasGeneratedRef.current) {
console.log("⏭️ 중복 실행 방지:", {
isGenerating: isGeneratingRef.current,
hasGenerated: hasGeneratedRef.current,
});
return;
}
}
}, [testAutoGeneration, isInteractive, component.columnName, component.value, formData, onFormDataChange]);
if (testAutoGeneration.enabled && testAutoGeneration.type !== "none") {
// 폼 데이터에 이미 값이 있으면 자동생성하지 않음
const currentFormValue = formData?.[component.columnName];
const currentComponentValue = component.value;
console.log("🔧 TextInput 자동생성 체크:", {
componentId: component.id,
columnName: component.columnName,
autoGenType: testAutoGeneration.type,
ruleId: testAutoGeneration.options?.numberingRuleId,
currentFormValue,
currentComponentValue,
autoGeneratedValue,
isInteractive,
});
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
isGeneratingRef.current = true; // 생성 시작 플래그
let generatedValue: string | null = null;
// 채번 규칙은 비동기로 처리
if (testAutoGeneration.type === "numbering_rule") {
const ruleId = testAutoGeneration.options?.numberingRuleId;
if (ruleId) {
try {
console.log("🚀 채번 규칙 API 호출 시작:", ruleId);
const { generateNumberingCode } = await import("@/lib/api/numberingRule");
const response = await generateNumberingCode(ruleId);
console.log("✅ 채번 규칙 API 응답:", response);
if (response.success && response.data) {
generatedValue = response.data.generatedCode;
}
} catch (error) {
console.error("❌ 채번 규칙 코드 생성 실패:", error);
} finally {
isGeneratingRef.current = false; // 생성 완료
}
} else {
console.warn("⚠️ 채번 규칙 ID가 없습니다");
isGeneratingRef.current = false;
}
} else {
// 기타 타입은 동기로 처리
generatedValue = AutoGenerationUtils.generateValue(testAutoGeneration, component.columnName);
isGeneratingRef.current = false;
}
if (generatedValue) {
console.log("✅ 자동생성 값 설정:", generatedValue);
setAutoGeneratedValue(generatedValue);
hasGeneratedRef.current = true; // 생성 완료 플래그
// 폼 데이터에 자동생성된 값 설정 (인터랙티브 모드에서만)
if (isInteractive && onFormDataChange && component.columnName) {
console.log("📝 formData 업데이트:", component.columnName, generatedValue);
onFormDataChange(component.columnName, generatedValue);
// 채번 규칙 ID도 함께 저장 (저장 시점에 실제 할당하기 위함)
if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) {
const ruleIdKey = `${component.columnName}_numberingRuleId`;
onFormDataChange(ruleIdKey, testAutoGeneration.options.numberingRuleId);
console.log("📝 채번 규칙 ID 저장:", ruleIdKey, testAutoGeneration.options.numberingRuleId);
}
}
}
} else if (!autoGeneratedValue && testAutoGeneration.type !== "none") {
// 디자인 모드에서도 미리보기용 자동생성 값 표시
const previewValue = AutoGenerationUtils.generatePreviewValue(testAutoGeneration);
console.log("👁️ 미리보기 값 설정:", previewValue);
setAutoGeneratedValue(previewValue);
hasGeneratedRef.current = true;
}
}
};
generateAutoValue();
}, [testAutoGeneration.enabled, testAutoGeneration.type, component.columnName, isInteractive]);
// 실제 화면에서 숨김 처리된 컴포넌트는 렌더링하지 않음
if (isHidden && !isDesignMode) {

View File

@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
@@ -8,6 +8,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { TextInputConfig } from "./types";
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
export interface TextInputConfigPanelProps {
config: TextInputConfig;
@@ -19,6 +21,32 @@ export interface TextInputConfigPanelProps {
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
*/
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange }) => {
// 채번 규칙 목록 상태
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
// 채번 규칙 목록 로드
useEffect(() => {
const loadRules = async () => {
setLoadingRules(true);
try {
const response = await getAvailableNumberingRules();
if (response.success && response.data) {
setNumberingRules(response.data);
}
} catch (error) {
console.error("채번 규칙 목록 로드 실패:", error);
} finally {
setLoadingRules(false);
}
};
// autoGeneration.type이 numbering_rule일 때만 로드
if (config.autoGeneration?.type === "numbering_rule") {
loadRules();
}
}, [config.autoGeneration?.type]);
const handleChange = (key: keyof TextInputConfig, value: any) => {
onChange({ [key]: value });
};
@@ -100,6 +128,7 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
<SelectItem value="current_user"> ID</SelectItem>
<SelectItem value="current_time"> </SelectItem>
<SelectItem value="sequence"> </SelectItem>
<SelectItem value="numbering_rule"> </SelectItem>
<SelectItem value="random_string"> </SelectItem>
<SelectItem value="random_number"> </SelectItem>
<SelectItem value="company_code"> </SelectItem>
@@ -113,6 +142,49 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}
</div>
)}
{/* 채번 규칙 선택 */}
{config.autoGeneration?.type === "numbering_rule" && (
<div className="space-y-2">
<Label htmlFor="numberingRuleId">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.autoGeneration?.options?.numberingRuleId || ""}
onValueChange={(value) => {
const currentConfig = config.autoGeneration!;
handleChange("autoGeneration", {
...currentConfig,
options: {
...currentConfig.options,
numberingRuleId: value,
},
});
}}
disabled={loadingRules}
>
<SelectTrigger>
<SelectValue placeholder={loadingRules ? "규칙 로딩 중..." : "채번 규칙 선택"} />
</SelectTrigger>
<SelectContent>
{numberingRules.length === 0 ? (
<SelectItem value="no-rules" disabled>
</SelectItem>
) : (
numberingRules.map((rule) => (
<SelectItem key={rule.ruleId} value={rule.ruleId}>
{rule.ruleName} ({rule.ruleId})
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
</p>
</div>
)}
</div>
)}

View File

@@ -207,11 +207,12 @@ export class AutoGenerationUtils {
* 자동생성 타입별 설명 가져오기
*/
static getTypeDescription(type: AutoGenerationType): string {
const descriptions: Record<AutoGenerationType, string> = {
const descriptions: Record<string, string> = {
uuid: "고유 식별자 (UUID) 생성",
current_user: "현재 로그인한 사용자 ID",
current_time: "현재 날짜/시간",
sequence: "순차적 번호 생성",
numbering_rule: "채번 규칙 기반 코드 생성",
random_string: "랜덤 문자열 생성",
random_number: "랜덤 숫자 생성",
company_code: "현재 회사 코드",
@@ -246,6 +247,9 @@ export class AutoGenerationUtils {
case "sequence":
return `${options.prefix || ""}1${options.suffix || ""}`;
case "numbering_rule":
return "CODE-20251104-001"; // 채번 규칙 미리보기
case "random_string":
return `${options.prefix || ""}ABC123${options.suffix || ""}`;

View File

@@ -257,6 +257,50 @@ export class ButtonActionExecutor {
companyCodeValue, // ✅ 최종 회사 코드 값
});
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
console.log("🔍 채번 규칙 할당 체크 시작");
console.log("📦 현재 formData:", JSON.stringify(formData, null, 2));
const fieldsWithNumbering: Record<string, string> = {};
// formData에서 채번 규칙이 설정된 필드 찾기
for (const [key, value] of Object.entries(formData)) {
if (key.endsWith("_numberingRuleId") && value) {
const fieldName = key.replace("_numberingRuleId", "");
fieldsWithNumbering[fieldName] = value as string;
console.log(`🎯 발견: ${fieldName} → 규칙 ${value}`);
}
}
console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering);
console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length);
// 각 필드에 대해 실제 코드 할당
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
console.log(`🎫 ${fieldName} 필드에 채번 규칙 ${ruleId} 할당 시작`);
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
const response = await allocateNumberingCode(ruleId);
console.log(`📡 API 응답 (${fieldName}):`, response);
if (response.success && response.data) {
const generatedCode = response.data.generatedCode;
formData[fieldName] = generatedCode;
console.log(`${fieldName} = ${generatedCode} (할당 완료)`);
} else {
console.error(`❌ 채번 규칙 할당 실패 (${fieldName}):`, response.error);
toast.error(`${fieldName} 채번 규칙 할당 실패: ${response.error}`);
}
} catch (error) {
console.error(`❌ 채번 규칙 할당 오류 (${fieldName}):`, error);
toast.error(`${fieldName} 채번 규칙 할당 오류`);
}
}
console.log("✅ 채번 규칙 할당 완료");
console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
const dataWithUserInfo = {
...formData,
writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
@@ -265,6 +309,13 @@ export class ButtonActionExecutor {
company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
};
// _numberingRuleId 필드 제거 (실제 저장하지 않음)
for (const key of Object.keys(dataWithUserInfo)) {
if (key.endsWith("_numberingRuleId")) {
delete dataWithUserInfo[key];
}
}
saveResult = await DynamicFormApi.saveFormData({
screenId,
tableName,

View File

@@ -44,7 +44,7 @@ export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings:
// 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산)
const cellWidth = columnWidth + gap;
const cellHeight = Math.max(40, gap * 2); // 행 높이를 더 크게 설
const cellHeight = 10; // 행 높이 10px 단위로 고
// 패딩을 제외한 상대 위치
const relativeX = position.x - padding;
@@ -92,9 +92,9 @@ export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: Gri
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
// 높이는 동적 행 높이 단위로 스냅
const rowHeight = Math.max(20, gap);
const snappedHeight = Math.max(40, Math.round(size.height / rowHeight) * rowHeight);
// 높이는 10px 단위로 스냅
const rowHeight = 10;
const snappedHeight = Math.max(10, Math.round(size.height / rowHeight) * rowHeight);
console.log(
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
@@ -175,7 +175,7 @@ export function generateGridLines(
// 격자 셀 크기 (스냅 로직과 동일하게)
const cellWidth = columnWidth + gap;
const cellHeight = Math.max(40, gap * 2);
const cellHeight = 10; // 행 높이 10px 단위로 고정
// 세로 격자선
const verticalLines: number[] = [];
@@ -254,8 +254,8 @@ export function alignGroupChildrenToGrid(
const columnIndex = Math.round(effectiveX / (columnWidth + gap));
const snappedX = padding + columnIndex * (columnWidth + gap);
// Y 좌표는 동적 행 높이 단위로 스냅
const rowHeight = Math.max(20, gap);
// Y 좌표는 10px 단위로 스냅
const rowHeight = 10;
const effectiveY = child.position.y - padding;
const rowIndex = Math.round(effectiveY / rowHeight);
const snappedY = padding + rowIndex * rowHeight;
@@ -264,7 +264,7 @@ export function alignGroupChildrenToGrid(
const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기
const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth));
const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기
const snappedHeight = Math.max(40, Math.round(child.size.height / rowHeight) * rowHeight);
const snappedHeight = Math.max(10, Math.round(child.size.height / rowHeight) * rowHeight);
const snappedChild = {
...child,
@@ -310,7 +310,7 @@ export function calculateOptimalGroupSize(
gridSettings: GridSettings,
): Size {
if (children.length === 0) {
return { width: gridInfo.columnWidth * 2, height: 40 * 2 };
return { width: gridInfo.columnWidth * 2, height: 10 * 4 };
}
console.log("📏 calculateOptimalGroupSize 시작:", {