ERP-node/frontend/components/report/ReportCreateModal.tsx

409 lines
16 KiB
TypeScript
Raw Normal View History

2025-10-01 11:41:03 +09:00
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
2025-10-01 11:41:03 +09:00
import {
Dialog,
DialogContent,
DialogDescription,
2025-10-01 11:41:03 +09:00
DialogHeader,
2025-12-05 10:46:10 +09:00
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
2025-10-01 11:41:03 +09:00
import { Button } from "@/components/ui/button";
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
2025-10-01 11:41:03 +09:00
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";
2025-10-01 11:41:03 +09:00
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";
2025-10-01 11:41:03 +09:00
interface ReportCreateModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
const TEMPLATE_NONE = "__none__";
2025-10-01 11:41:03 +09:00
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);
2025-10-01 11:41:03 +09:00
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[]>([]);
2025-10-01 11:41:03 +09:00
const { toast } = useToast();
useEffect(() => {
if (!isOpen) return;
2025-10-01 11:41:03 +09:00
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);
2025-10-01 11:41:03 +09:00
}
};
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);
2025-10-01 11:41:03 +09:00
}
};
const handleSubmit = async () => {
const trimmed = reportName.trim();
if (!trimmed) {
2025-10-01 11:41:03 +09:00
toast({
title: "입력 오류",
description: "리포트명을 입력해주세요.",
2025-10-01 11:41:03 +09:00
variant: "destructive",
});
return;
}
const finalCategory = effectiveCategory;
if (!finalCategory) {
2025-10-01 11:41:03 +09:00
toast({
title: "입력 오류",
description: "카테고리를 선택하거나 입력해주세요.",
2025-10-01 11:41:03 +09:00
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();
2025-10-01 11:41:03 +09:00
onSuccess();
router.push(`/admin/screenMng/reportList/designer/${response.data.reportId}`);
2025-10-01 11:41:03 +09:00
}
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "리포트 생성에 실패했습니다.";
toast({ title: "오류", description: msg, variant: "destructive" });
2025-10-01 11:41:03 +09:00
} 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>
2025-10-01 11:41:03 +09:00
<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>
&quot;<span className="font-medium">{customCategory.trim()}</span>&quot;
</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>
2025-10-01 11:41:03 +09:00
<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>
2025-10-01 11:41:03 +09:00
</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>
2025-10-01 11:41:03 +09:00
<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>
2025-10-01 11:41:03 +09:00
</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} />
</>
2025-10-01 11:41:03 +09:00
);
}