fix: 화면 복사 코드 생성 로직 개선 및 UniversalFormModal beforeFormSave 이벤트 연동

- screenManagementService: PostgreSQL regexp_replace로 정확한 최대 번호 조회
- CopyScreenModal: linkedScreens 의존성 추가로 모달 코드 생성 보장
- UniversalFormModal: beforeFormSave 이벤트 리스너로 ButtonPrimary 연동
- 설정된 필드만 병합하여 의도치 않은 덮어쓰기 방지
This commit is contained in:
SeongHyun Kim 2025-12-09 16:11:04 +09:00
parent d550959cb7
commit 5e97a3a5e9
7 changed files with 104 additions and 42 deletions

View File

@ -2360,30 +2360,33 @@ export class ScreenManagementService {
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
// 현재 최대 번호 조회
const existingScreens = await client.query<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions
WHERE company_code = $1 AND screen_code LIKE $2
ORDER BY screen_code DESC
LIMIT 10`,
[companyCode, `${companyCode}%`]
// 현재 최대 번호 조회 (숫자 추출 후 정렬)
// 패턴: COMPANY_CODE_XXX 또는 COMPANY_CODEXXX
const existingScreens = await client.query<{ screen_code: string; num: number }>(
`SELECT screen_code,
COALESCE(
NULLIF(
regexp_replace(screen_code, $2, '\\1'),
screen_code
)::integer,
0
) as num
FROM screen_definitions
WHERE company_code = $1
AND screen_code ~ $2
AND deleted_date IS NULL
ORDER BY num DESC
LIMIT 1`,
[companyCode, `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_]?(\\d+)$`]
);
let maxNumber = 0;
const pattern = new RegExp(
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
);
for (const screen of existingScreens.rows) {
const match = screen.screen_code.match(pattern);
if (match) {
const number = parseInt(match[1], 10);
if (number > maxNumber) {
maxNumber = number;
}
}
if (existingScreens.rows.length > 0 && existingScreens.rows[0].num) {
maxNumber = existingScreens.rows[0].num;
}
console.log(`🔢 현재 최대 화면 코드 번호: ${companyCode}${maxNumber}`);
// count개의 코드를 순차적으로 생성
const codes: string[] = [];
for (let i = 0; i < count; i++) {

View File

@ -166,18 +166,28 @@ export default function CopyScreenModal({
// linkedScreens 로딩이 완료되면 화면 코드 생성
useEffect(() => {
// 모달 화면들의 코드가 모두 설정되었는지 확인
const allModalCodesSet = linkedScreens.length === 0 ||
linkedScreens.every(screen => screen.newScreenCode);
console.log("🔍 코드 생성 조건 체크:", {
targetCompanyCode,
loadingLinkedScreens,
screenCode,
linkedScreensCount: linkedScreens.length,
allModalCodesSet,
});
if (targetCompanyCode && !loadingLinkedScreens && !screenCode) {
// 조건: 회사 코드가 있고, 로딩이 완료되고, (메인 코드가 없거나 모달 코드가 없을 때)
const needsCodeGeneration = targetCompanyCode &&
!loadingLinkedScreens &&
(!screenCode || (linkedScreens.length > 0 && !allModalCodesSet));
if (needsCodeGeneration) {
console.log("✅ 화면 코드 생성 시작 (linkedScreens 개수:", linkedScreens.length, ")");
generateScreenCodes();
}
}, [targetCompanyCode, loadingLinkedScreens, screenCode]);
}, [targetCompanyCode, loadingLinkedScreens, screenCode, linkedScreens]);
// 회사 목록 조회
const loadCompanies = async () => {

View File

@ -1438,7 +1438,7 @@ export function ModalRepeaterTableConfigPanel({
checked={col.dynamicDataSource?.enabled || false}
onCheckedChange={(checked) => toggleDynamicDataSource(index, checked)}
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
(: 거래처별 , )
</p>

View File

@ -161,11 +161,11 @@ export function RepeaterTable({
: null;
return (
<th
key={col.field}
className="px-4 py-2 text-left font-medium text-muted-foreground"
style={{ width: col.width }}
>
<th
key={col.field}
className="px-4 py-2 text-left font-medium text-muted-foreground"
style={{ width: col.width }}
>
{hasDynamicSource ? (
<Popover
open={openPopover === col.field}
@ -219,11 +219,11 @@ export function RepeaterTable({
</Popover>
) : (
<>
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
</>
)}
</th>
</th>
);
})}
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">

View File

@ -49,7 +49,7 @@ export interface RepeaterColumnConfig {
required?: boolean; // 필수 입력 여부
defaultValue?: string | number | boolean; // 기본값
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
// 컬럼 매핑 설정
mapping?: ColumnMapping; // 이 컬럼의 데이터를 어디서 가져올지 설정
@ -142,16 +142,16 @@ export interface MultiTableJoinStep {
export interface ColumnMapping {
/** 매핑 타입 */
type: "source" | "reference" | "manual";
/** 매핑 타입별 설정 */
// type: "source" - 소스 테이블 (모달에서 선택한 항목)의 컬럼에서 가져오기
sourceField?: string; // 소스 테이블의 컬럼명 (예: "item_name")
// type: "reference" - 외부 테이블 참조 (조인)
referenceTable?: string; // 참조 테이블명 (예: "customer_item_mapping")
referenceField?: string; // 참조 테이블에서 가져올 컬럼 (예: "basic_price")
joinCondition?: JoinCondition[]; // 조인 조건
// type: "manual" - 사용자가 직접 입력
}

View File

@ -144,6 +144,55 @@ export function UniversalFormModalComponent({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
// 🆕 beforeFormSave 이벤트 리스너 - ButtonPrimary 저장 시 formData를 전달
// 설정된 필드(columnName)만 병합하여 의도치 않은 덮어쓰기 방지
useEffect(() => {
const handleBeforeFormSave = (event: Event) => {
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
// 설정에 정의된 필드 columnName 목록 수집
const configuredFields = new Set<string>();
config.sections.forEach((section) => {
section.fields.forEach((field) => {
if (field.columnName) {
configuredFields.add(field.columnName);
}
});
});
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
// (UniversalFormModal이 해당 필드의 주인이므로)
for (const [key, value] of Object.entries(formData)) {
// 설정에 정의된 필드만 병합
if (configuredFields.has(key)) {
if (value !== undefined && value !== null && value !== "") {
event.detail.formData[key] = value;
console.log(`[UniversalFormModal] 필드 병합: ${key} =`, value);
}
}
}
// 반복 섹션 데이터도 병합 (필요한 경우)
if (Object.keys(repeatSections).length > 0) {
for (const [sectionId, items] of Object.entries(repeatSections)) {
const sectionKey = `_repeatSection_${sectionId}`;
event.detail.formData[sectionKey] = items;
console.log(`[UniversalFormModal] 반복 섹션 병합: ${sectionKey}`, items);
}
}
};
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
};
}, [formData, repeatSections, config.sections]);
// 필드 레벨 linkedFieldGroup 데이터 로드
useEffect(() => {
const loadData = async () => {

View File

@ -413,14 +413,14 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
<HelpText>ButtonPrimary </HelpText>
{config.modal.showSaveButton !== false && (
<div>
<Label className="text-[10px]"> </Label>
<Input
value={config.modal.saveButtonText || "저장"}
onChange={(e) => updateModalConfig({ saveButtonText: e.target.value })}
className="h-7 text-xs mt-1"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={config.modal.saveButtonText || "저장"}
onChange={(e) => updateModalConfig({ saveButtonText: e.target.value })}
className="h-7 text-xs mt-1"
/>
</div>
)}
</div>
</AccordionContent>