2025-09-22 17:00:59 +09:00
|
|
|
/**
|
|
|
|
|
* 컬럼 정의 테이블 컴포넌트
|
|
|
|
|
* 테이블 생성 시 컬럼 정의를 위한 편집 가능한 테이블
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState } from "react";
|
|
|
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
import { X, AlertCircle } from "lucide-react";
|
|
|
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
|
|
|
import {
|
|
|
|
|
CreateColumnDefinition,
|
|
|
|
|
ColumnDefinitionTableProps,
|
|
|
|
|
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";
|
2025-09-22 17:00:59 +09:00
|
|
|
|
|
|
|
|
export function ColumnDefinitionTable({ columns, onChange, disabled = false }: ColumnDefinitionTableProps) {
|
|
|
|
|
const [validationErrors, setValidationErrors] = useState<Record<number, string[]>>({});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 컬럼 정보 업데이트
|
|
|
|
|
*/
|
|
|
|
|
const updateColumn = (index: number, updates: Partial<CreateColumnDefinition>) => {
|
|
|
|
|
const newColumns = [...columns];
|
|
|
|
|
newColumns[index] = { ...newColumns[index], ...updates };
|
|
|
|
|
onChange(newColumns);
|
|
|
|
|
|
|
|
|
|
// 업데이트 후 해당 컬럼 검증
|
|
|
|
|
validateColumn(index, newColumns[index]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 컬럼 제거
|
|
|
|
|
*/
|
|
|
|
|
const removeColumn = (index: number) => {
|
|
|
|
|
if (columns.length <= 1) return; // 최소 1개 컬럼 유지
|
|
|
|
|
|
|
|
|
|
const newColumns = columns.filter((_, i) => i !== index);
|
|
|
|
|
onChange(newColumns);
|
|
|
|
|
|
|
|
|
|
// 검증 오류도 함께 제거
|
|
|
|
|
const newErrors = { ...validationErrors };
|
|
|
|
|
delete newErrors[index];
|
|
|
|
|
|
|
|
|
|
// 인덱스 재조정
|
|
|
|
|
const adjustedErrors: Record<number, string[]> = {};
|
|
|
|
|
Object.entries(newErrors).forEach(([key, value]) => {
|
|
|
|
|
const idx = parseInt(key);
|
|
|
|
|
if (idx > index) {
|
|
|
|
|
adjustedErrors[idx - 1] = value;
|
|
|
|
|
} else if (idx < index) {
|
|
|
|
|
adjustedErrors[idx] = value;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setValidationErrors(adjustedErrors);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 개별 컬럼 검증
|
|
|
|
|
*/
|
|
|
|
|
const validateColumn = (index: number, column: CreateColumnDefinition) => {
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
|
|
|
|
|
// 컬럼명 검증
|
|
|
|
|
if (!column.name) {
|
|
|
|
|
errors.push("컬럼명은 필수입니다");
|
|
|
|
|
} else {
|
|
|
|
|
if (!VALIDATION_RULES.columnName.pattern.test(column.name)) {
|
|
|
|
|
errors.push(VALIDATION_RULES.columnName.errorMessage);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
column.name.length < VALIDATION_RULES.columnName.minLength ||
|
|
|
|
|
column.name.length > VALIDATION_RULES.columnName.maxLength
|
|
|
|
|
) {
|
|
|
|
|
errors.push(
|
|
|
|
|
`컬럼명은 ${VALIDATION_RULES.columnName.minLength}-${VALIDATION_RULES.columnName.maxLength}자여야 합니다`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 예약어 검증
|
|
|
|
|
if (RESERVED_WORDS.includes(column.name.toLowerCase() as any)) {
|
|
|
|
|
errors.push("예약어는 컬럼명으로 사용할 수 없습니다");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 예약된 컬럼명 검증
|
|
|
|
|
if (RESERVED_COLUMNS.includes(column.name.toLowerCase() as any)) {
|
|
|
|
|
errors.push("이미 자동 추가되는 기본 컬럼명입니다");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 중복 검증
|
|
|
|
|
const duplicateCount = columns.filter((col) => col.name.toLowerCase() === column.name.toLowerCase()).length;
|
|
|
|
|
if (duplicateCount > 1) {
|
|
|
|
|
errors.push("중복된 컬럼명입니다");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-23 10:40:21 +09:00
|
|
|
// 입력타입 검증
|
|
|
|
|
if (!column.inputType) {
|
|
|
|
|
errors.push("입력타입을 선택해주세요");
|
2025-09-22 17:00:59 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 길이 검증 (길이를 지원하는 타입인 경우)
|
2025-09-23 10:40:21 +09:00
|
|
|
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
|
|
|
|
|
if (inputTypeOption?.supportsLength && column.length !== undefined) {
|
2025-09-22 17:00:59 +09:00
|
|
|
if (column.length < VALIDATION_RULES.columnLength.min || column.length > VALIDATION_RULES.columnLength.max) {
|
|
|
|
|
errors.push(VALIDATION_RULES.columnLength.errorMessage);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 검증 오류 상태 업데이트
|
|
|
|
|
setValidationErrors((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[index]: errors,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
return errors.length === 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-23 10:40:21 +09:00
|
|
|
* 입력타입 변경 시 길이 기본값 설정
|
2025-09-22 17:00:59 +09:00
|
|
|
*/
|
2025-09-23 10:40:21 +09:00
|
|
|
const handleInputTypeChange = (index: number, inputType: string) => {
|
|
|
|
|
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === inputType);
|
|
|
|
|
const updates: Partial<CreateColumnDefinition> = { inputType: inputType as any };
|
2025-09-22 17:00:59 +09:00
|
|
|
|
|
|
|
|
// 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정
|
2025-09-23 10:40:21 +09:00
|
|
|
if (inputTypeOption?.supportsLength && !columns[index].length && inputTypeOption.defaultLength) {
|
|
|
|
|
updates.length = inputTypeOption.defaultLength;
|
2025-09-22 17:00:59 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 길이를 지원하지 않는 타입이면 길이 제거
|
2025-09-23 10:40:21 +09:00
|
|
|
if (!inputTypeOption?.supportsLength) {
|
2025-09-22 17:00:59 +09:00
|
|
|
updates.length = undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateColumn(index, updates);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 전체 검증 상태 확인
|
|
|
|
|
*/
|
|
|
|
|
const hasValidationErrors = Object.values(validationErrors).some((errors) => errors.length > 0);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{/* 검증 오류 요약 */}
|
|
|
|
|
{hasValidationErrors && (
|
|
|
|
|
<Alert variant="destructive">
|
|
|
|
|
<AlertCircle className="h-4 w-4" />
|
|
|
|
|
<AlertDescription>컬럼 정의에 오류가 있습니다. 각 컬럼의 오류를 확인해주세요.</AlertDescription>
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 컬럼 정의 테이블 */}
|
|
|
|
|
<div className="overflow-hidden rounded-lg border">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead className="w-[150px]">
|
|
|
|
|
컬럼명 <span className="text-red-500">*</span>
|
|
|
|
|
</TableHead>
|
|
|
|
|
<TableHead className="w-[150px]">라벨</TableHead>
|
|
|
|
|
<TableHead className="w-[120px]">
|
2025-09-23 10:40:21 +09:00
|
|
|
입력타입 <span className="text-red-500">*</span>
|
2025-09-22 17:00:59 +09:00
|
|
|
</TableHead>
|
|
|
|
|
<TableHead className="w-[80px]">필수</TableHead>
|
|
|
|
|
<TableHead className="w-[100px]">길이</TableHead>
|
|
|
|
|
<TableHead className="w-[120px]">기본값</TableHead>
|
|
|
|
|
<TableHead>설명</TableHead>
|
|
|
|
|
<TableHead className="w-[50px]"></TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{columns.map((column, index) => {
|
2025-09-23 10:40:21 +09:00
|
|
|
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
|
2025-09-22 17:00:59 +09:00
|
|
|
const rowErrors = validationErrors[index] || [];
|
|
|
|
|
const hasRowError = rowErrors.length > 0;
|
|
|
|
|
|
|
|
|
|
return (
|
2025-10-02 14:34:15 +09:00
|
|
|
<TableRow key={index} className={hasRowError ? "bg-destructive/10" : ""}>
|
2025-09-22 17:00:59 +09:00
|
|
|
<TableCell>
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Input
|
|
|
|
|
value={column.name}
|
|
|
|
|
onChange={(e) => updateColumn(index, { name: e.target.value })}
|
|
|
|
|
placeholder="column_name"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
className={hasRowError ? "border-red-300" : ""}
|
|
|
|
|
/>
|
|
|
|
|
{rowErrors.length > 0 && (
|
2025-10-02 14:34:15 +09:00
|
|
|
<div className="space-y-1 text-xs text-destructive">
|
2025-09-22 17:00:59 +09:00
|
|
|
{rowErrors.map((error, i) => (
|
|
|
|
|
<div key={i}>{error}</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
<Input
|
|
|
|
|
value={column.label || ""}
|
|
|
|
|
onChange={(e) => updateColumn(index, { label: e.target.value })}
|
|
|
|
|
placeholder="컬럼 라벨"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
/>
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
<Select
|
2025-09-23 10:40:21 +09:00
|
|
|
value={column.inputType}
|
|
|
|
|
onValueChange={(value) => handleInputTypeChange(index, value)}
|
2025-09-22 17:00:59 +09:00
|
|
|
disabled={disabled}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger>
|
2025-09-23 10:40:21 +09:00
|
|
|
<SelectValue placeholder="입력 타입 선택" />
|
2025-09-22 17:00:59 +09:00
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2025-09-23 10:40:21 +09:00
|
|
|
{INPUT_TYPE_OPTIONS.map((option) => (
|
2025-09-22 17:00:59 +09:00
|
|
|
<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>
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
<div className="flex items-center justify-center">
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={!column.nullable}
|
|
|
|
|
onCheckedChange={(checked) => updateColumn(index, { nullable: !checked })}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
value={column.length || ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
updateColumn(index, {
|
|
|
|
|
length: e.target.value ? parseInt(e.target.value) : undefined,
|
|
|
|
|
})
|
|
|
|
|
}
|
2025-09-23 10:40:21 +09:00
|
|
|
placeholder={inputTypeOption?.defaultLength?.toString() || ""}
|
|
|
|
|
disabled={disabled || !inputTypeOption?.supportsLength}
|
2025-09-22 17:00:59 +09:00
|
|
|
min={1}
|
|
|
|
|
max={65535}
|
|
|
|
|
/>
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
<Input
|
|
|
|
|
value={column.defaultValue || ""}
|
|
|
|
|
onChange={(e) => updateColumn(index, { defaultValue: e.target.value })}
|
|
|
|
|
placeholder="기본값"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
/>
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
<Textarea
|
|
|
|
|
value={column.description || ""}
|
|
|
|
|
onChange={(e) => updateColumn(index, { description: e.target.value })}
|
|
|
|
|
placeholder="컬럼 설명"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
rows={1}
|
|
|
|
|
className="min-h-[36px] resize-none"
|
|
|
|
|
/>
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => removeColumn(index)}
|
|
|
|
|
disabled={disabled || columns.length === 1}
|
|
|
|
|
className="h-8 w-8 p-0"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 도움말 */}
|
|
|
|
|
<div className="text-muted-foreground space-y-1 text-sm">
|
|
|
|
|
<div>• 컬럼명은 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다.</div>
|
|
|
|
|
<div>• 다음 컬럼들은 자동으로 추가됩니다: id (PK), created_date, updated_date, company_code</div>
|
|
|
|
|
<div>• 필수 컬럼은 NOT NULL 제약조건이 추가됩니다.</div>
|
|
|
|
|
<div>• 길이는 text, code, email, tel 등의 타입에서만 설정 가능합니다.</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|