409 lines
16 KiB
TypeScript
409 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import { Loader2, LayoutTemplate, Check, ChevronsUpDown, Plus, Tag } from "lucide-react";
|
|
import { reportApi } from "@/lib/api/reportApi";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { REPORT_TYPE_OPTIONS, getTypeIcon, getTypeLabel } from "@/lib/reportTypeColors";
|
|
import { ReportTemplate } from "@/types/report";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface ReportCreateModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSuccess: () => void;
|
|
}
|
|
|
|
const TEMPLATE_NONE = "__none__";
|
|
|
|
export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateModalProps) {
|
|
const router = useRouter();
|
|
const [reportName, setReportName] = useState("");
|
|
const [reportType, setReportType] = useState("");
|
|
const [customCategory, setCustomCategory] = useState("");
|
|
const [categoryOpen, setCategoryOpen] = useState(false);
|
|
const [description, setDescription] = useState("");
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState(TEMPLATE_NONE);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
|
|
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
|
const [systemTemplates, setSystemTemplates] = useState<ReportTemplate[]>([]);
|
|
const [customTemplates, setCustomTemplates] = useState<ReportTemplate[]>([]);
|
|
const [existingCategories, setExistingCategories] = useState<string[]>([]);
|
|
const { toast } = useToast();
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const fetchTemplates = async () => {
|
|
setIsLoadingTemplates(true);
|
|
try {
|
|
const response = await reportApi.getTemplates();
|
|
if (response.success && response.data) {
|
|
setSystemTemplates(response.data.system || []);
|
|
setCustomTemplates(response.data.custom || []);
|
|
}
|
|
} catch {
|
|
// 템플릿 로딩 실패 시 빈 목록으로 진행
|
|
} finally {
|
|
setIsLoadingTemplates(false);
|
|
}
|
|
};
|
|
|
|
const fetchCategories = async () => {
|
|
setIsLoadingCategories(true);
|
|
try {
|
|
const response = await reportApi.getCategories();
|
|
if (response.success && response.data) {
|
|
setExistingCategories(response.data);
|
|
}
|
|
} catch {
|
|
// 카테고리 로딩 실패 시 빈 목록으로 진행
|
|
} finally {
|
|
setIsLoadingCategories(false);
|
|
}
|
|
};
|
|
|
|
fetchTemplates();
|
|
fetchCategories();
|
|
}, [isOpen]);
|
|
|
|
const hasTemplates = useMemo(
|
|
() => systemTemplates.length > 0 || customTemplates.length > 0,
|
|
[systemTemplates, customTemplates],
|
|
);
|
|
|
|
const allCategories = useMemo(() => {
|
|
const defaultTypes = REPORT_TYPE_OPTIONS.map((opt) => opt.value);
|
|
const merged = new Set([...defaultTypes, ...existingCategories]);
|
|
return Array.from(merged).sort();
|
|
}, [existingCategories]);
|
|
|
|
const effectiveCategory = useMemo(() => {
|
|
return customCategory.trim() || reportType;
|
|
}, [customCategory, reportType]);
|
|
|
|
const categoryDisplayLabel = useMemo(() => {
|
|
if (customCategory.trim()) return customCategory.trim();
|
|
if (reportType) return getTypeLabel(reportType);
|
|
return "";
|
|
}, [customCategory, reportType]);
|
|
|
|
const hasInputData = useCallback(() => {
|
|
return reportName.trim() !== "" ||
|
|
reportType !== "" ||
|
|
customCategory.trim() !== "" ||
|
|
description.trim() !== "" ||
|
|
selectedTemplateId !== TEMPLATE_NONE;
|
|
}, [reportName, reportType, customCategory, description, selectedTemplateId]);
|
|
|
|
const resetForm = useCallback(() => {
|
|
setReportName("");
|
|
setReportType("");
|
|
setCustomCategory("");
|
|
setDescription("");
|
|
setSelectedTemplateId(TEMPLATE_NONE);
|
|
}, []);
|
|
|
|
const guard = useUnsavedChangesGuard({
|
|
hasChanges: () => !isLoading && hasInputData(),
|
|
onClose: () => {
|
|
resetForm();
|
|
onClose();
|
|
},
|
|
title: "입력된 내용이 있습니다",
|
|
description: "입력된 내용이 저장되지 않습니다. 정말 닫으시겠습니까?",
|
|
});
|
|
|
|
const handleCategorySelect = (value: string) => {
|
|
setReportType(value);
|
|
setCustomCategory("");
|
|
setCategoryOpen(false);
|
|
};
|
|
|
|
const handleCustomCategoryAdd = () => {
|
|
const trimmed = customCategory.trim();
|
|
if (trimmed) {
|
|
setReportType("");
|
|
setCategoryOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
const trimmed = reportName.trim();
|
|
if (!trimmed) {
|
|
toast({
|
|
title: "입력 오류",
|
|
description: "리포트명을 입력해주세요.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const finalCategory = effectiveCategory;
|
|
if (!finalCategory) {
|
|
toast({
|
|
title: "입력 오류",
|
|
description: "카테고리를 선택하거나 입력해주세요.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await reportApi.createReport({
|
|
reportNameKor: trimmed,
|
|
reportType: finalCategory,
|
|
description: description.trim() || undefined,
|
|
templateId: selectedTemplateId !== TEMPLATE_NONE ? selectedTemplateId : undefined,
|
|
});
|
|
|
|
if (response.success && response.data) {
|
|
toast({ title: "성공", description: "리포트가 생성되었습니다." });
|
|
guard.doClose();
|
|
onSuccess();
|
|
router.push(`/admin/screenMng/reportList/designer/${response.data.reportId}`);
|
|
}
|
|
} catch (error: unknown) {
|
|
const msg = error instanceof Error ? error.message : "리포트 생성에 실패했습니다.";
|
|
toast({ title: "오류", description: msg, variant: "destructive" });
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={isOpen} onOpenChange={guard.handleOpenChange}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-lg">새 리포트 생성</DialogTitle>
|
|
<DialogDescription className="text-base">
|
|
리포트명과 카테고리를 입력한 후 디자이너에서 상세 설계를 진행합니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-5 py-3">
|
|
<div className="space-y-2.5">
|
|
<Label htmlFor="create-report-name" className="text-base">
|
|
리포트명 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="create-report-name"
|
|
placeholder="예: 발주서"
|
|
value={reportName}
|
|
onChange={(e) => setReportName(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && !isLoading && handleSubmit()}
|
|
className="h-11 text-base"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2.5">
|
|
<Label htmlFor="create-report-type" className="text-base">
|
|
카테고리 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Popover open={categoryOpen} onOpenChange={setCategoryOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={categoryOpen}
|
|
className="h-11 w-full justify-between text-base font-normal"
|
|
disabled={isLoadingCategories}
|
|
>
|
|
{isLoadingCategories ? (
|
|
<span className="text-muted-foreground">카테고리 불러오는 중...</span>
|
|
) : categoryDisplayLabel ? (
|
|
<div className="flex items-center gap-2.5">
|
|
<Tag className="h-4 w-4 shrink-0 text-gray-500" />
|
|
<span>{categoryDisplayLabel}</span>
|
|
</div>
|
|
) : (
|
|
<span className="text-muted-foreground">카테고리 선택 또는 입력</span>
|
|
)}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
|
<Command>
|
|
<CommandInput
|
|
placeholder="카테고리 검색 또는 새로 입력..."
|
|
value={customCategory}
|
|
onValueChange={setCustomCategory}
|
|
className="text-base"
|
|
/>
|
|
<CommandList>
|
|
{customCategory.trim() && !allCategories.includes(customCategory.trim()) && (
|
|
<CommandGroup heading="새 카테고리 추가">
|
|
<CommandItem
|
|
value={`__new__${customCategory.trim()}`}
|
|
onSelect={handleCustomCategoryAdd}
|
|
className="text-base"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4 text-green-600" />
|
|
<span>
|
|
"<span className="font-medium">{customCategory.trim()}</span>" 새로 추가
|
|
</span>
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
)}
|
|
<CommandEmpty className="py-3 text-center text-sm text-muted-foreground">
|
|
일치하는 카테고리가 없습니다.
|
|
<br />
|
|
위에 입력한 값으로 새 카테고리를 추가할 수 있습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup heading="기존 카테고리">
|
|
{allCategories.map((cat) => {
|
|
const Icon = getTypeIcon(cat);
|
|
const label = getTypeLabel(cat);
|
|
return (
|
|
<CommandItem
|
|
key={cat}
|
|
value={cat}
|
|
onSelect={() => handleCategorySelect(cat)}
|
|
className="text-base"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
reportType === cat && !customCategory.trim() ? "opacity-100" : "opacity-0",
|
|
)}
|
|
/>
|
|
<Icon className="mr-2 h-4 w-4 shrink-0 text-gray-500" />
|
|
<span>{label}</span>
|
|
{cat !== label && (
|
|
<span className="ml-1 text-xs text-muted-foreground">({cat})</span>
|
|
)}
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<p className="text-muted-foreground text-xs">
|
|
기존 카테고리를 선택하거나 새로운 카테고리를 직접 입력할 수 있습니다.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2.5">
|
|
<Label htmlFor="create-report-template" className="text-base">
|
|
템플릿
|
|
</Label>
|
|
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId} disabled={isLoadingTemplates}>
|
|
<SelectTrigger className="h-11 text-base">
|
|
<SelectValue placeholder={isLoadingTemplates ? "템플릿 불러오는 중..." : "템플릿 선택 (선택사항)"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={TEMPLATE_NONE} className="text-base">
|
|
<span className="text-gray-500">템플릿 없이 시작</span>
|
|
</SelectItem>
|
|
{systemTemplates.length > 0 && (
|
|
<>
|
|
<div className="px-2 py-1.5 text-xs font-semibold text-gray-400">시스템 템플릿</div>
|
|
{systemTemplates.map((t) => (
|
|
<SelectItem key={t.template_id} value={t.template_id} className="text-base">
|
|
<div className="flex items-center gap-2.5">
|
|
<LayoutTemplate className="h-4 w-4 shrink-0 text-blue-500" />
|
|
<span>{t.template_name_kor}</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</>
|
|
)}
|
|
{customTemplates.length > 0 && (
|
|
<>
|
|
<div className="px-2 py-1.5 text-xs font-semibold text-gray-400">사용자 템플릿</div>
|
|
{customTemplates.map((t) => (
|
|
<SelectItem key={t.template_id} value={t.template_id} className="text-base">
|
|
<div className="flex items-center gap-2.5">
|
|
<LayoutTemplate className="h-4 w-4 shrink-0 text-green-500" />
|
|
<span>{t.template_name_kor}</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</>
|
|
)}
|
|
{!isLoadingTemplates && !hasTemplates && (
|
|
<div className="px-2 py-2 text-sm text-gray-400">등록된 템플릿이 없습니다</div>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-muted-foreground text-xs">템플릿을 선택하면 레이아웃이 자동으로 적용됩니다.</p>
|
|
</div>
|
|
|
|
<div className="space-y-2.5">
|
|
<Label htmlFor="create-report-desc" className="text-base">
|
|
설명
|
|
</Label>
|
|
<Textarea
|
|
id="create-report-desc"
|
|
placeholder="리포트에 대한 간단한 설명을 입력하세요"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
className="min-h-[80px] resize-none text-base"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={guard.tryClose} disabled={isLoading} className="text-base">
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleSubmit} disabled={isLoading} className="text-base">
|
|
{isLoading ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
생성 중...
|
|
</>
|
|
) : (
|
|
"생성"
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<UnsavedChangesDialog guard={guard} />
|
|
</>
|
|
);
|
|
}
|