366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
|
|
/**
|
||
|
|
* 컬럼 추가 모달 컴포넌트
|
||
|
|
* 기존 테이블에 새로운 컬럼을 추가하기 위한 모달
|
||
|
|
*/
|
||
|
|
|
||
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState, useEffect } from "react";
|
||
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, 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,
|
||
|
|
WEB_TYPE_OPTIONS,
|
||
|
|
VALIDATION_RULES,
|
||
|
|
RESERVED_WORDS,
|
||
|
|
RESERVED_COLUMNS,
|
||
|
|
} from "../../types/ddl";
|
||
|
|
|
||
|
|
export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddColumnModalProps) {
|
||
|
|
const [column, setColumn] = useState<CreateColumnDefinition>({
|
||
|
|
name: "",
|
||
|
|
label: "",
|
||
|
|
webType: "text",
|
||
|
|
nullable: true,
|
||
|
|
order: 0,
|
||
|
|
});
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 모달 리셋
|
||
|
|
*/
|
||
|
|
const resetModal = () => {
|
||
|
|
setColumn({
|
||
|
|
name: "",
|
||
|
|
label: "",
|
||
|
|
webType: "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.webType) {
|
||
|
|
errors.push("웹타입을 선택해주세요.");
|
||
|
|
}
|
||
|
|
|
||
|
|
// 길이 검증 (길이를 지원하는 타입인 경우)
|
||
|
|
const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === columnData.webType);
|
||
|
|
if (webTypeOption?.supportsLength && 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 handleWebTypeChange = (webType: string) => {
|
||
|
|
const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === webType);
|
||
|
|
const updates: Partial<CreateColumnDefinition> = { webType: webType as any };
|
||
|
|
|
||
|
|
// 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정
|
||
|
|
if (webTypeOption?.supportsLength && !column.length && webTypeOption.defaultLength) {
|
||
|
|
updates.length = webTypeOption.defaultLength;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 길이를 지원하지 않는 타입이면 길이 제거
|
||
|
|
if (!webTypeOption?.supportsLength) {
|
||
|
|
updates.length = undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
updateColumn(updates);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 컬럼 추가 실행
|
||
|
|
*/
|
||
|
|
const handleAddColumn = async () => {
|
||
|
|
if (!validateColumn(column)) {
|
||
|
|
toast.error("입력값을 확인해주세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const result = await ddlApi.addColumn(tableName, { 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.webType;
|
||
|
|
|
||
|
|
const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === column.webType);
|
||
|
|
|
||
|
|
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" />
|
||
|
|
컬럼 추가 - {tableName}
|
||
|
|
</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-red-500">*</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-red-300" : ""}
|
||
|
|
/>
|
||
|
|
<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-red-500">*</span>
|
||
|
|
</Label>
|
||
|
|
<Select value={column.webType} onValueChange={handleWebTypeChange} disabled={loading}>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder="웹타입 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{WEB_TYPE_OPTIONS.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={webTypeOption?.defaultLength?.toString() || ""}
|
||
|
|
disabled={loading || !webTypeOption?.supportsLength}
|
||
|
|
min={1}
|
||
|
|
max={65535}
|
||
|
|
/>
|
||
|
|
<p className="text-muted-foreground text-xs">
|
||
|
|
{webTypeOption?.supportsLength ? "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.defaultValue || ""}
|
||
|
|
onChange={(e) => updateColumn({ defaultValue: 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-blue-600 hover:bg-blue-700"
|
||
|
|
>
|
||
|
|
{loading ? (
|
||
|
|
<>
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
추가 중...
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
"컬럼 추가"
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|