Files
johngreen 8f92fb2368 refactor(테이블타입): 3-layer 분리 — DB 12개 유지, UI 8개 한정, widget variant
- input-type-mapping.ts: BaseInputType 10개 → UserSelectableInputType 8개 (박창현 image 2). vexplor_rps INPUT_TYPE_DETAIL_TYPES 포팅, select/checkbox/radio variant 를 code base 로 흡수
- input-types.ts: USER_SELECTABLE_INPUT_TYPE_ORDER/LABELS re-export (InputType 12개는 그대로)
- getDetailType.ts (신규): getWidgetVariants / getDefaultWidgetVariant helper
- 드롭다운 호출처 7개 8개 제한: ColumnDetailPanel, AddColumnModal, ColumnDefinitionTable, tableMngList/page.tsx, TableSettingModal, TypeOverviewStrip, types.ts
- ColumnDetailPanel: Legacy row 드롭다운 disabled + v5-glow-sm Alert 배너
- backward shim: BaseInputType / BASE_INPUT_TYPE_OPTIONS / getBaseInputType 등 V2/Properties/DetailSettingsPanel 호환

운영 DB 96.6% 가 이미 8개 안 (V0, 35,316 row). DB zero touch, mapper 5곳 보호.

spec: .omc/specs/deep-dive-table-type-storage-ui-separation.md (v3.2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:43:26 +09:00

358 lines
11 KiB
TypeScript

/**
* 컬럼 추가 모달 컴포넌트
* 기존 테이블에 새로운 컬럼을 추가하기 위한 모달
*/
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, Plus, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { ddlApi } from "../../lib/api/ddl";
import {
AddColumnModalProps,
CreateColumnDefinition,
VALIDATION_RULES,
RESERVED_WORDS,
RESERVED_COLUMNS,
} from "../../types/ddl";
import { INPUT_TYPE_OPTIONS, USER_SELECTABLE_INPUT_TYPE_ORDER } from "../../types/input-types";
export function AddColumnModal({ isOpen, onClose, table_name, onSuccess }: AddColumnModalProps) {
const [column, setColumn] = useState<CreateColumnDefinition>({
name: "",
label: "",
input_type: "text",
nullable: true,
order: 0,
});
const [loading, setLoading] = useState(false);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
/**
* 모달 리셋
*/
const resetModal = () => {
setColumn({
name: "",
label: "",
input_type: "text",
nullable: true,
order: 0,
});
setValidationErrors([]);
};
/**
* 모달 열림/닫힘 시 리셋
*/
useEffect(() => {
if (isOpen) {
resetModal();
}
}, [isOpen]);
/**
* 컬럼 정보 업데이트
*/
const updateColumn = (updates: Partial<CreateColumnDefinition>) => {
const newColumn = { ...column, ...updates };
setColumn(newColumn);
// 업데이트 후 검증
validateColumn(newColumn);
};
/**
* 컬럼 검증
*/
const validateColumn = (columnData: CreateColumnDefinition) => {
const errors: string[] = [];
// 컬럼명 검증
if (!columnData.name) {
errors.push("컬럼명은 필수입니다.");
} else {
if (!VALIDATION_RULES.columnName.pattern.test(columnData.name)) {
errors.push(VALIDATION_RULES.columnName.errorMessage);
}
if (
columnData.name.length < VALIDATION_RULES.columnName.minLength ||
columnData.name.length > VALIDATION_RULES.columnName.maxLength
) {
errors.push(
`컬럼명은 ${VALIDATION_RULES.columnName.minLength}-${VALIDATION_RULES.columnName.maxLength}자여야 합니다.`,
);
}
// 예약어 검증
if (RESERVED_WORDS.includes(columnData.name.toLowerCase() as any)) {
errors.push("SQL 예약어는 컬럼명으로 사용할 수 없습니다.");
}
// 예약된 컬럼명 검증
if (RESERVED_COLUMNS.includes(columnData.name.toLowerCase() as any)) {
errors.push("이미 자동 추가되는 기본 컬럼명입니다.");
}
// 네이밍 컨벤션 검증
if (columnData.name.startsWith("_") || columnData.name.endsWith("_")) {
errors.push("컬럼명은 언더스코어로 시작하거나 끝날 수 없습니다.");
}
if (columnData.name.includes("__")) {
errors.push("컬럼명에 연속된 언더스코어는 사용할 수 없습니다.");
}
}
// 입력타입 검증
if (!columnData.input_type) {
errors.push("입력타입을 선택해주세요.");
}
// 길이 검증
if (columnData.length !== undefined) {
if (
columnData.length < VALIDATION_RULES.columnLength.min ||
columnData.length > VALIDATION_RULES.columnLength.max
) {
errors.push(VALIDATION_RULES.columnLength.errorMessage);
}
}
setValidationErrors(errors);
return errors.length === 0;
};
/**
* 입력타입 변경 처리
*/
const handleInputTypeChange = (inputType: string) => {
const updates: Partial<CreateColumnDefinition> = { input_type: inputType as any };
updateColumn(updates);
};
/**
* 컬럼 추가 실행
*/
const handleAddColumn = async () => {
if (!validateColumn(column)) {
toast.error("입력값을 확인해주세요.");
return;
}
setLoading(true);
try {
const result = await ddlApi.addColumn(table_name, { column });
if (result.success) {
toast.success(result.message);
onSuccess(result);
onClose();
} else {
toast.error(result.error?.details || result.message);
}
} catch (error: any) {
// console.error("컬럼 추가 실패:", error);
toast.error(error.response?.data?.error?.details || "컬럼 추가에 실패했습니다.");
} finally {
setLoading(false);
}
};
/**
* 폼 유효성 확인
*/
const isFormValid = validationErrors.length === 0 && column.name && column.input_type;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
- {table_name}
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 검증 오류 표시 */}
{validationErrors.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="space-y-1">
{validationErrors.map((error, index) => (
<div key={index}> {error}</div>
))}
</div>
</AlertDescription>
</Alert>
)}
{/* 기본 정보 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="columnName">
<span className="text-destructive">*</span>
</Label>
<Input
id="columnName"
value={column.name}
onChange={(e) => updateColumn({ name: e.target.value })}
placeholder="column_name"
disabled={loading}
className={validationErrors.some((e) => e.includes("컬럼명")) ? "border-destructive/30" : ""}
/>
<p className="text-muted-foreground text-xs"> , // </p>
</div>
<div className="space-y-2">
<Label htmlFor="columnLabel"></Label>
<Input
id="columnLabel"
value={column.label || ""}
onChange={(e) => updateColumn({ label: e.target.value })}
placeholder="컬럼 라벨"
disabled={loading}
/>
<p className="text-muted-foreground text-xs"> ()</p>
</div>
</div>
{/* 타입 및 속성 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>
<span className="text-destructive">*</span>
</Label>
<Select value={column.input_type} onValueChange={handleInputTypeChange} disabled={loading}>
<SelectTrigger>
<SelectValue placeholder="입력타입 선택" />
</SelectTrigger>
<SelectContent>
{INPUT_TYPE_OPTIONS
.filter((o) => USER_SELECTABLE_INPUT_TYPE_ORDER.includes(o.value as any))
.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div>
<div className="font-medium">{option.label}</div>
{option.description && (
<div className="text-muted-foreground text-xs">{option.description}</div>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="columnLength"></Label>
<Input
id="columnLength"
type="number"
value={column.length || ""}
onChange={(e) =>
updateColumn({
length: e.target.value ? parseInt(e.target.value) : undefined,
})
}
placeholder="길이 (선택사항)"
disabled={loading}
min={1}
max={65535}
/>
<p className="text-muted-foreground text-xs">1-65535 ()</p>
</div>
</div>
{/* 기본값 및 NULL 허용 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="defaultValue"></Label>
<Input
id="defaultValue"
value={column.default_value || ""}
onChange={(e) => updateColumn({ default_value: e.target.value })}
placeholder="기본값 (선택사항)"
disabled={loading}
/>
</div>
<div className="flex items-center space-x-2 pt-6">
<Checkbox
id="required"
checked={!column.nullable}
onCheckedChange={(checked) => updateColumn({ nullable: !checked })}
disabled={loading}
/>
<Label htmlFor="required" className="text-sm font-medium">
(NOT NULL)
</Label>
</div>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={column.description || ""}
onChange={(e) => updateColumn({ description: e.target.value })}
placeholder="컬럼에 대한 설명 (선택사항)"
disabled={loading}
rows={3}
/>
</div>
{/* 안내 사항 */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
. .
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={loading}>
</Button>
<Button
onClick={handleAddColumn}
disabled={!isFormValid || loading}
className="bg-primary hover:bg-primary/90"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"컬럼 추가"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}