테이블 및 컬럼 생성기능 추가
This commit is contained in:
365
frontend/components/admin/AddColumnModal.tsx
Normal file
365
frontend/components/admin/AddColumnModal.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* 컬럼 추가 모달 컴포넌트
|
||||
* 기존 테이블에 새로운 컬럼을 추가하기 위한 모달
|
||||
*/
|
||||
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user