ERP-node/frontend/components/admin/AddColumnModal.tsx

373 lines
12 KiB
TypeScript
Raw Normal View History

/**
*
*
*/
"use client";
import { useState, useEffect } from "react";
2025-11-05 16:36:32 +09:00
import {
2025-12-05 10:46:10 +09:00
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";
2025-09-23 10:40:21 +09:00
import { INPUT_TYPE_OPTIONS } from "../../types/input-types";
export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddColumnModalProps) {
const [column, setColumn] = useState<CreateColumnDefinition>({
name: "",
label: "",
2025-09-23 10:40:21 +09:00
inputType: "text",
nullable: true,
order: 0,
});
const [loading, setLoading] = useState(false);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
/**
*
*/
const resetModal = () => {
setColumn({
name: "",
label: "",
2025-09-23 10:40:21 +09:00
inputType: "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("컬럼명에 연속된 언더스코어는 사용할 수 없습니다.");
}
}
2025-09-23 10:40:21 +09:00
// 입력타입 검증
if (!columnData.inputType) {
errors.push("입력타입을 선택해주세요.");
}
// 길이 검증 (길이를 지원하는 타입인 경우)
2025-09-23 10:40:21 +09:00
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === columnData.inputType);
if (inputTypeOption?.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;
};
/**
2025-09-23 10:40:21 +09:00
*
*/
2025-09-23 10:40:21 +09:00
const handleInputTypeChange = (inputType: string) => {
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === inputType);
const updates: Partial<CreateColumnDefinition> = { inputType: inputType as any };
// 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정
2025-09-23 10:40:21 +09:00
if (inputTypeOption?.supportsLength && !column.length && inputTypeOption.defaultLength) {
updates.length = inputTypeOption.defaultLength;
}
// 길이를 지원하지 않는 타입이면 길이 제거
2025-09-23 10:40:21 +09:00
if (!inputTypeOption?.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);
}
};
/**
*
*/
2025-09-23 10:40:21 +09:00
const isFormValid = validationErrors.length === 0 && column.name && column.inputType;
2025-09-23 10:40:21 +09:00
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
return (
2025-12-05 10:46:10 +09:00
<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}
2025-12-05 10:46:10 +09:00
</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>
2025-09-23 10:40:21 +09:00
<span className="text-red-500">*</span>
</Label>
2025-09-23 10:40:21 +09:00
<Select value={column.inputType} onValueChange={handleInputTypeChange} disabled={loading}>
<SelectTrigger>
2025-09-23 10:40:21 +09:00
<SelectValue placeholder="입력타입 선택" />
</SelectTrigger>
<SelectContent>
2025-09-23 10:40:21 +09:00
{INPUT_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,
})
}
2025-09-23 10:40:21 +09:00
placeholder={inputTypeOption?.defaultLength?.toString() || ""}
disabled={loading || !inputTypeOption?.supportsLength}
min={1}
max={65535}
/>
<p className="text-muted-foreground text-xs">
2025-09-23 10:40:21 +09:00
{inputTypeOption?.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>
2025-12-05 10:46:10 +09:00
<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>
2025-12-05 10:46:10 +09:00
</DialogFooter>
</DialogContent>
</Dialog>
);
}