프론트엔드 구현

This commit is contained in:
dohyeons
2025-10-01 11:41:03 +09:00
parent bd72f7892b
commit 93e5331d6c
6 changed files with 927 additions and 0 deletions

View File

@@ -0,0 +1,228 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { CreateReportRequest, ReportTemplate } from "@/types/report";
interface ReportCreateModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateModalProps) {
const [formData, setFormData] = useState<CreateReportRequest>({
reportNameKor: "",
reportNameEng: "",
templateId: "",
reportType: "BASIC",
description: "",
});
const [templates, setTemplates] = useState<ReportTemplate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
const { toast } = useToast();
// 템플릿 목록 불러오기
useEffect(() => {
if (isOpen) {
fetchTemplates();
}
}, [isOpen]);
const fetchTemplates = async () => {
setIsLoadingTemplates(true);
try {
const response = await reportApi.getTemplates();
if (response.success && response.data) {
setTemplates([...response.data.system, ...response.data.custom]);
}
} catch (error: any) {
toast({
title: "오류",
description: "템플릿 목록을 불러오는데 실패했습니다.",
variant: "destructive",
});
} finally {
setIsLoadingTemplates(false);
}
};
const handleSubmit = async () => {
// 유효성 검증
if (!formData.reportNameKor.trim()) {
toast({
title: "입력 오류",
description: "리포트명(한글)을 입력해주세요.",
variant: "destructive",
});
return;
}
if (!formData.reportType) {
toast({
title: "입력 오류",
description: "리포트 타입을 선택해주세요.",
variant: "destructive",
});
return;
}
setIsLoading(true);
try {
const response = await reportApi.createReport(formData);
if (response.success) {
toast({
title: "성공",
description: "리포트가 생성되었습니다.",
});
handleClose();
onSuccess();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트 생성에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
setFormData({
reportNameKor: "",
reportNameEng: "",
templateId: "",
reportType: "BASIC",
description: "",
});
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> . .</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 리포트명 (한글) */}
<div className="space-y-2">
<Label htmlFor="reportNameKor">
() <span className="text-destructive">*</span>
</Label>
<Input
id="reportNameKor"
placeholder="예: 발주서"
value={formData.reportNameKor}
onChange={(e) => setFormData({ ...formData, reportNameKor: e.target.value })}
/>
</div>
{/* 리포트명 (영문) */}
<div className="space-y-2">
<Label htmlFor="reportNameEng"> ()</Label>
<Input
id="reportNameEng"
placeholder="예: Purchase Order"
value={formData.reportNameEng}
onChange={(e) => setFormData({ ...formData, reportNameEng: e.target.value })}
/>
</div>
{/* 템플릿 선택 */}
<div className="space-y-2">
<Label htmlFor="templateId">릿</Label>
<Select
value={formData.templateId}
onValueChange={(value) => setFormData({ ...formData, templateId: value })}
disabled={isLoadingTemplates}
>
<SelectTrigger>
<SelectValue placeholder="템플릿 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">릿 </SelectItem>
{templates.map((template) => (
<SelectItem key={template.template_id} value={template.template_id}>
{template.template_name_kor}
{template.is_system === "Y" && " (시스템)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 리포트 타입 */}
<div className="space-y-2">
<Label htmlFor="reportType">
<span className="text-destructive">*</span>
</Label>
<Select
value={formData.reportType}
onValueChange={(value) => setFormData({ ...formData, reportType: value })}
>
<SelectTrigger>
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ORDER"></SelectItem>
<SelectItem value="INVOICE"></SelectItem>
<SelectItem value="STATEMENT"></SelectItem>
<SelectItem value="RECEIPT"></SelectItem>
<SelectItem value="BASIC"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
placeholder="리포트에 대한 설명을 입력하세요"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
</Button>
<Button onClick={handleSubmit} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"생성"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,250 @@
"use client";
import { useState } from "react";
import { ReportMaster } from "@/types/report";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Pencil, Copy, Trash2, Loader2 } from "lucide-react";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
interface ReportListTableProps {
reports: ReportMaster[];
total: number;
page: number;
limit: number;
isLoading: boolean;
onPageChange: (page: number) => void;
onRefresh: () => void;
}
export function ReportListTable({
reports,
total,
page,
limit,
isLoading,
onPageChange,
onRefresh,
}: ReportListTableProps) {
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const { toast } = useToast();
const router = useRouter();
const totalPages = Math.ceil(total / limit);
// 수정
const handleEdit = (reportId: string) => {
router.push(`/admin/report/designer/${reportId}`);
};
// 복사
const handleCopy = async (reportId: string) => {
setIsCopying(true);
try {
const response = await reportApi.copyReport(reportId);
if (response.success) {
toast({
title: "성공",
description: "리포트가 복사되었습니다.",
});
onRefresh();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트 복사에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsCopying(false);
}
};
// 삭제 확인
const handleDeleteClick = (reportId: string) => {
setDeleteTarget(reportId);
};
// 삭제 실행
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
const response = await reportApi.deleteReport(deleteTarget);
if (response.success) {
toast({
title: "성공",
description: "리포트가 삭제되었습니다.",
});
setDeleteTarget(null);
onRefresh();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트 삭제에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsDeleting(false);
}
};
// 날짜 포맷
const formatDate = (dateString: string | null) => {
if (!dateString) return "-";
try {
return format(new Date(dateString), "yyyy-MM-dd");
} catch {
return dateString;
}
};
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
);
}
if (reports.length === 0) {
return (
<div className="text-muted-foreground flex h-64 flex-col items-center justify-center">
<p> .</p>
</div>
);
}
return (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px]">No</TableHead>
<TableHead></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reports.map((report, index) => {
const rowNumber = (page - 1) * limit + index + 1;
return (
<TableRow key={report.report_id}>
<TableCell className="font-medium">{rowNumber}</TableCell>
<TableCell>
<div>
<div className="font-medium">{report.report_name_kor}</div>
{report.report_name_eng && (
<div className="text-muted-foreground text-sm">{report.report_name_eng}</div>
)}
</div>
</TableCell>
<TableCell>{report.created_by || "-"}</TableCell>
<TableCell>{formatDate(report.updated_at || report.created_at)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(report.report_id)}
className="gap-1"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleCopy(report.report_id)}
disabled={isCopying}
className="gap-1"
>
<Copy className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick(report.report_id)}
className="gap-1"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-4">
<Button variant="outline" size="sm" onClick={() => onPageChange(page - 1)} disabled={page === 1}>
</Button>
<span className="text-muted-foreground text-sm">
{page} / {totalPages}
</span>
<Button variant="outline" size="sm" onClick={() => onPageChange(page + 1)} disabled={page === totalPages}>
</Button>
</div>
)}
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"삭제"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}