- claudedocs 폴더 구조 재정리: archive/sessions, guides/migration·mobile·universal-list, refactoring 분류 - 오래된 세션 컨텍스트/체크리스트 문서 정리 (아카이브 이동 또는 삭제) - AuthContext → authStore(Zustand) 전환 시작, RootProvider 간소화 - GenericCRUDDialog 공통 다이얼로그 컴포넌트 추가 - PermissionDialog 삭제 → GenericCRUDDialog로 대체 - RankDialog/TitleDialog GenericCRUDDialog 기반으로 리팩토링 - toast-utils.ts 삭제 (미사용) - fileDownload.ts 개선, excel-download.ts 정리 - menuStore/themeStore Zustand 셀렉터 최적화 - useColumnSettings/useTableColumnStore 기능 보강 - 세금계산서/견적/작업자화면/결재 등 소규모 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
172 lines
4.9 KiB
TypeScript
172 lines
4.9 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Loader2 } from 'lucide-react';
|
|
|
|
/**
|
|
* 필드 정의
|
|
*/
|
|
export interface CRUDFieldDefinition {
|
|
key: string;
|
|
label: string;
|
|
type: 'text' | 'select';
|
|
placeholder?: string;
|
|
options?: { value: string; label: string }[];
|
|
defaultValue?: string;
|
|
}
|
|
|
|
export interface GenericCRUDDialogProps {
|
|
isOpen: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
mode: 'add' | 'edit';
|
|
entityName: string;
|
|
fields: CRUDFieldDefinition[];
|
|
initialData?: Record<string, string>;
|
|
onSubmit: (data: Record<string, string>) => void;
|
|
isLoading?: boolean;
|
|
addLabel?: string;
|
|
editLabel?: string;
|
|
}
|
|
|
|
/**
|
|
* 단순 CRUD 다이얼로그 공통 컴포넌트
|
|
*
|
|
* 텍스트 입력 + Select 조합의 단순 폼 다이얼로그를 생성합니다.
|
|
* RankDialog, TitleDialog 등 동일 패턴의 다이얼로그를 대체합니다.
|
|
*/
|
|
export function GenericCRUDDialog({
|
|
isOpen,
|
|
onOpenChange,
|
|
mode,
|
|
entityName,
|
|
fields,
|
|
initialData,
|
|
onSubmit,
|
|
isLoading = false,
|
|
addLabel = '등록',
|
|
editLabel = '수정',
|
|
}: GenericCRUDDialogProps) {
|
|
const [formData, setFormData] = useState<Record<string, string>>({});
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
if (mode === 'edit' && initialData) {
|
|
setFormData({ ...initialData });
|
|
} else {
|
|
const defaults: Record<string, string> = {};
|
|
fields.forEach((f) => {
|
|
defaults[f.key] = f.defaultValue ?? '';
|
|
});
|
|
setFormData(defaults);
|
|
}
|
|
}
|
|
}, [isOpen, mode, initialData, fields]);
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const firstTextField = fields.find((f) => f.type === 'text');
|
|
if (firstTextField && !formData[firstTextField.key]?.trim()) return;
|
|
|
|
const trimmed: Record<string, string> = {};
|
|
Object.entries(formData).forEach(([k, v]) => {
|
|
trimmed[k] = v.trim();
|
|
});
|
|
onSubmit(trimmed);
|
|
|
|
const defaults: Record<string, string> = {};
|
|
fields.forEach((f) => {
|
|
defaults[f.key] = f.defaultValue ?? '';
|
|
});
|
|
setFormData(defaults);
|
|
};
|
|
|
|
const title = mode === 'add' ? `${entityName} 추가` : `${entityName} 수정`;
|
|
const submitText = mode === 'add' ? addLabel : editLabel;
|
|
|
|
const firstTextField = fields.find((f) => f.type === 'text');
|
|
const isSubmitDisabled =
|
|
isLoading || (firstTextField ? !formData[firstTextField.key]?.trim() : false);
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{title}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="space-y-4 py-4">
|
|
{fields.map((field, idx) => (
|
|
<div key={field.key} className="space-y-2">
|
|
<Label htmlFor={`crud-${field.key}`}>{field.label}</Label>
|
|
{field.type === 'text' ? (
|
|
<Input
|
|
id={`crud-${field.key}`}
|
|
value={formData[field.key] ?? ''}
|
|
onChange={(e) =>
|
|
setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))
|
|
}
|
|
placeholder={field.placeholder}
|
|
autoFocus={idx === 0}
|
|
disabled={isLoading}
|
|
/>
|
|
) : (
|
|
<Select
|
|
value={formData[field.key] ?? ''}
|
|
onValueChange={(value) =>
|
|
setFormData((prev) => ({ ...prev, [field.key]: value }))
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{field.options?.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={isLoading}
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button type="submit" disabled={isSubmitDisabled}>
|
|
{isLoading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
|
|
{submitText}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|