feat(repeat-screen-modal): 테이블 영역 독립 저장 기능 구현

- TableCrudConfig에 allowSave, saveButtonLabel 속성 추가
- CRUD 설정 패널에 저장 스위치 추가
- saveTableAreaData 함수: editable 컬럼 + 조인키만 필터링하여 저장
- 날짜 필드 ISO 8601 -> YYYY-MM-DD 형식 변환
- 백엔드: company_code 자동 주입 로직 추가
- tableManagementService에 hasColumn 메서드 추가
This commit is contained in:
SeongHyun Kim 2025-12-02 14:02:47 +09:00
parent 2f78c83ef6
commit 4787a8b177
5 changed files with 250 additions and 32 deletions

View File

@ -870,6 +870,17 @@ export async function addTableData(
const tableManagementService = new TableManagementService();
// 🆕 멀티테넌시: company_code 자동 추가 (테이블에 company_code 컬럼이 있는 경우)
const companyCode = req.user?.companyCode;
if (companyCode && !data.company_code) {
// 테이블에 company_code 컬럼이 있는지 확인
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
if (hasCompanyCodeColumn) {
data.company_code = companyCode;
logger.info(`🔒 멀티테넌시: company_code 자동 추가 - ${companyCode}`);
}
}
// 데이터 추가
await tableManagementService.addTableData(tableName, data);

View File

@ -4076,4 +4076,22 @@ export class TableManagementService {
throw error;
}
}
/**
*
*/
async hasColumn(tableName: string, columnName: string): Promise<boolean> {
try {
const result = await query<any>(
`SELECT column_name
FROM information_schema.columns
WHERE table_name = $1 AND column_name = $2`,
[tableName, columnName]
);
return result.length > 0;
} catch (error) {
logger.error(`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, error);
return false;
}
}
}

View File

@ -295,6 +295,8 @@ export function RepeatScreenModalComponent({
rowCount: tableData.length,
sampleRow: tableData[0] ? Object.keys(tableData[0]) : [],
firstRowData: tableData[0],
// 디버그: plan_date 필드 확인
plan_date_value: tableData[0]?.plan_date,
});
// 🆕 v3.3: 추가 조인 테이블 데이터 로드 및 병합
@ -344,6 +346,14 @@ export function RepeatScreenModalComponent({
_isNew: false,
...row,
}));
// 디버그: 저장된 외부 테이블 데이터 확인
console.log(`[RepeatScreenModal] 외부 테이블 데이터 저장:`, {
key,
rowCount: newExternalData[key].length,
firstRow: newExternalData[key][0],
plan_date_in_firstRow: newExternalData[key][0]?.plan_date,
});
}
} catch (error) {
console.error(`[RepeatScreenModal] 외부 테이블 데이터 로드 실패:`, error);
@ -599,6 +609,159 @@ export function RepeatScreenModalComponent({
});
};
// 🆕 v3.6: 테이블 영역 저장 기능
const saveTableAreaData = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
const key = `${cardId}-${contentRowId}`;
const rows = externalTableData[key] || [];
console.log("[RepeatScreenModal] saveTableAreaData 시작:", {
key,
rowsCount: rows.length,
contentRowId,
tableDataSource: contentRow?.tableDataSource,
tableCrud: contentRow?.tableCrud,
});
if (!contentRow?.tableDataSource?.enabled) {
console.warn("[RepeatScreenModal] 외부 테이블 데이터 소스가 설정되지 않음");
return { success: false, message: "데이터 소스가 설정되지 않았습니다." };
}
const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable;
const dirtyRows = rows.filter((row) => row._isDirty);
console.log("[RepeatScreenModal] 저장 대상:", {
targetTable,
dirtyRowsCount: dirtyRows.length,
dirtyRows: dirtyRows.map(r => ({ _isNew: r._isNew, _isDirty: r._isDirty, data: r })),
});
if (dirtyRows.length === 0) {
return { success: true, message: "저장할 변경사항이 없습니다.", savedCount: 0 };
}
const savePromises: Promise<any>[] = [];
const savedIds: number[] = [];
// 🆕 v3.6: editable한 컬럼 + 조인 키만 추출 (읽기 전용 컬럼은 제외)
const allowedFields = new Set<string>();
// tableColumns에서 editable: true인 필드만 추가 (읽기 전용 컬럼 제외)
if (contentRow.tableColumns) {
contentRow.tableColumns.forEach((col) => {
// editable이 명시적으로 true이거나, editable이 undefined가 아니고 false가 아닌 경우
// 또는 inputType이 있는 경우 (입력 가능한 컬럼)
if (col.field && (col.editable === true || col.inputType)) {
allowedFields.add(col.field);
}
});
}
// 조인 조건의 sourceKey 추가 (예: sales_order_id) - 이건 항상 필요
if (contentRow.tableDataSource?.joinConditions) {
contentRow.tableDataSource.joinConditions.forEach((cond) => {
if (cond.sourceKey) allowedFields.add(cond.sourceKey);
});
}
console.log("[RepeatScreenModal] 저장 허용 필드 (editable + 조인키):", Array.from(allowedFields));
console.log("[RepeatScreenModal] tableColumns 정보:", contentRow.tableColumns?.map(c => ({
field: c.field,
editable: c.editable,
inputType: c.inputType
})));
for (const row of dirtyRows) {
const { _rowId, _originalData, _isDirty, _isNew, ...allData } = row;
// 허용된 필드만 필터링
const dataToSave: Record<string, any> = {};
for (const field of allowedFields) {
if (allData[field] !== undefined) {
dataToSave[field] = allData[field];
}
}
console.log("[RepeatScreenModal] 저장할 데이터:", {
_isNew,
_originalData,
allData,
dataToSave,
});
if (_isNew) {
// INSERT - /add 엔드포인트 사용
console.log(`[RepeatScreenModal] INSERT 요청: /table-management/tables/${targetTable}/add`, dataToSave);
savePromises.push(
apiClient.post(`/table-management/tables/${targetTable}/add`, dataToSave).then((res) => {
console.log("[RepeatScreenModal] INSERT 응답:", res.data);
if (res.data?.data?.id) {
savedIds.push(res.data.data.id);
}
return res;
}).catch((err) => {
console.error("[RepeatScreenModal] INSERT 실패:", err.response?.data || err.message);
throw err;
})
);
} else if (_originalData?.id) {
// UPDATE - /edit 엔드포인트 사용 (id를 body에 포함)
const updateData = { ...dataToSave, id: _originalData.id };
console.log(`[RepeatScreenModal] UPDATE 요청: /table-management/tables/${targetTable}/edit`, updateData);
savePromises.push(
apiClient.put(`/table-management/tables/${targetTable}/edit`, updateData).then((res) => {
console.log("[RepeatScreenModal] UPDATE 응답:", res.data);
savedIds.push(_originalData.id);
return res;
}).catch((err) => {
console.error("[RepeatScreenModal] UPDATE 실패:", err.response?.data || err.message);
throw err;
})
);
}
}
try {
await Promise.all(savePromises);
// 저장 후 해당 키의 dirty 플래그만 초기화
setExternalTableData((prev) => {
const updated = { ...prev };
if (updated[key]) {
updated[key] = updated[key].map((row) => ({
...row,
_isDirty: false,
_isNew: false,
_originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined },
}));
}
return updated;
});
return { success: true, message: `${dirtyRows.length}건 저장 완료`, savedCount: dirtyRows.length, savedIds };
} catch (error: any) {
console.error("[RepeatScreenModal] 테이블 영역 저장 실패:", error);
return { success: false, message: error.message || "저장 중 오류가 발생했습니다." };
}
};
// 🆕 v3.6: 테이블 영역 저장 핸들러
const handleTableAreaSave = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
setIsSaving(true);
try {
const result = await saveTableAreaData(cardId, contentRowId, contentRow);
if (result.success) {
console.log("[RepeatScreenModal] 테이블 영역 저장 성공:", result);
// 성공 알림 (필요 시 toast 추가)
} else {
console.error("[RepeatScreenModal] 테이블 영역 저장 실패:", result.message);
// 실패 알림 (필요 시 toast 추가)
}
} finally {
setIsSaving(false);
}
};
// 🆕 v3.1: 외부 테이블 행 삭제 요청
const handleDeleteExternalRowRequest = (cardId: string, rowId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
if (contentRow.tableCrud?.deleteConfirm?.enabled !== false) {
@ -1467,33 +1630,41 @@ export function RepeatScreenModalComponent({
{contentRow.type === "table" && contentRow.tableDataSource?.enabled ? (
// 🆕 v3.1: 외부 테이블 데이터 소스 사용
<div className="border rounded-lg overflow-hidden">
{contentRow.tableTitle && (
{/* 테이블 헤더 영역: 제목 + 버튼들 */}
{(contentRow.tableTitle || contentRow.tableCrud?.allowSave || contentRow.tableCrud?.allowCreate) && (
<div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium flex items-center justify-between">
<span>{contentRow.tableTitle}</span>
{contentRow.tableCrud?.allowCreate && (
<Button
variant="outline"
size="sm"
onClick={() => handleAddExternalRow(card._cardId, contentRow.id, contentRow)}
className="h-7 text-xs gap-1"
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
)}
{!contentRow.tableTitle && contentRow.tableCrud?.allowCreate && (
<div className="px-4 py-2 border-b flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => handleAddExternalRow(card._cardId, contentRow.id, contentRow)}
className="h-7 text-xs gap-1"
>
<Plus className="h-3 w-3" />
</Button>
<span>{contentRow.tableTitle || ""}</span>
<div className="flex items-center gap-2">
{/* 저장 버튼 - allowSave가 true일 때만 표시 */}
{contentRow.tableCrud?.allowSave && (
<Button
variant="default"
size="sm"
onClick={() => handleTableAreaSave(card._cardId, contentRow.id, contentRow)}
disabled={isSaving || !(externalTableData[`${card._cardId}-${contentRow.id}`] || []).some((r: any) => r._isDirty)}
className="h-7 text-xs gap-1"
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
{contentRow.tableCrud?.saveButtonLabel || "저장"}
</Button>
)}
{/* 추가 버튼 */}
{contentRow.tableCrud?.allowCreate && (
<Button
variant="outline"
size="sm"
onClick={() => handleAddExternalRow(card._cardId, contentRow.id, contentRow)}
className="h-7 text-xs gap-1"
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
</div>
)}
<Table>
@ -2243,10 +2414,12 @@ function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (va
/>
);
case "date":
// ISO 8601 형식('2025-12-02T00:00:00.000Z')을 'YYYY-MM-DD' 형식으로 변환
const dateValue = value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : "";
return (
<Input
type="date"
value={value || ""}
value={dateValue}
onChange={(e) => onChange(e.target.value)}
className="h-8 text-sm"
/>
@ -2298,7 +2471,7 @@ function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: a
{col.type === "date" && (
<Input
type="date"
value={value || ""}
value={value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : ""}
onChange={(e) => onChange(e.target.value)}
className="h-10 text-sm"
/>

View File

@ -2568,13 +2568,13 @@ function ContentRowConfigSection({
{/* CRUD 설정 */}
<div className="space-y-2 p-2 bg-green-100/50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800">
<Label className="text-[9px] font-semibold">CRUD </Label>
<div className="flex gap-3">
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-1">
<Switch
checked={row.tableCrud?.allowCreate || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false },
tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, allowSave: row.tableCrud?.allowSave || false },
})
}
className="scale-[0.5]"
@ -2586,7 +2586,7 @@ function ContentRowConfigSection({
checked={row.tableCrud?.allowUpdate || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false },
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false, allowSave: row.tableCrud?.allowSave || false },
})
}
className="scale-[0.5]"
@ -2598,13 +2598,25 @@ function ContentRowConfigSection({
checked={row.tableCrud?.allowDelete || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked },
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked, allowSave: row.tableCrud?.allowSave || false },
})
}
className="scale-[0.5]"
/>
<Label className="text-[9px]"></Label>
</div>
<div className="flex items-center gap-1">
<Switch
checked={row.tableCrud?.allowSave || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, allowSave: checked },
})
}
className="scale-[0.5]"
/>
<Label className="text-[9px]"></Label>
</div>
</div>
{row.tableCrud?.allowDelete && (
<div className="flex items-center gap-1 pl-2">

View File

@ -188,6 +188,10 @@ export interface TableCrudConfig {
allowUpdate: boolean; // 행 수정 허용
allowDelete: boolean; // 행 삭제 허용
// 🆕 v3.5: 테이블 영역 저장 버튼
allowSave?: boolean; // 테이블 영역에 저장 버튼 표시
saveButtonLabel?: string; // 저장 버튼 라벨 (기본: "저장")
// 신규 행 기본값
newRowDefaults?: Record<string, string>; // 기본값 (예: { status: "READY", sales_order_id: "{id}" })