feat(repeat-screen-modal): 테이블 영역 독립 저장 기능 구현
- TableCrudConfig에 allowSave, saveButtonLabel 속성 추가 - CRUD 설정 패널에 저장 스위치 추가 - saveTableAreaData 함수: editable 컬럼 + 조인키만 필터링하여 저장 - 날짜 필드 ISO 8601 -> YYYY-MM-DD 형식 변환 - 백엔드: company_code 자동 주입 로직 추가 - tableManagementService에 hasColumn 메서드 추가
This commit is contained in:
parent
2f78c83ef6
commit
4787a8b177
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}" })
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue