프론트엔드 구현

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,111 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ReportListTable } from "@/components/report/ReportListTable";
import { ReportCreateModal } from "@/components/report/ReportCreateModal";
import { Plus, Search, RotateCcw } from "lucide-react";
import { useReportList } from "@/hooks/useReportList";
export default function ReportManagementPage() {
const [searchText, setSearchText] = useState("");
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { reports, total, page, limit, isLoading, refetch, setPage, handleSearch } = useReportList();
const handleSearchClick = () => {
handleSearch(searchText);
};
const handleReset = () => {
setSearchText("");
handleSearch("");
};
const handleCreateSuccess = () => {
setIsCreateModalOpen(false);
refetch();
};
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<Button onClick={() => setIsCreateModalOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 검색 영역 */}
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="flex gap-2">
<Input
placeholder="리포트명으로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearchClick();
}
}}
className="flex-1"
/>
<Button onClick={handleSearchClick} className="gap-2">
<Search className="h-4 w-4" />
</Button>
<Button onClick={handleReset} variant="outline" className="gap-2">
<RotateCcw className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 리포트 목록 */}
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
📋
<span className="text-muted-foreground text-sm font-normal">( {total})</span>
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ReportListTable
reports={reports}
total={total}
page={page}
limit={limit}
isLoading={isLoading}
onPageChange={setPage}
onRefresh={refetch}
/>
</CardContent>
</Card>
</div>
{/* 리포트 생성 모달 */}
<ReportCreateModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={handleCreateSuccess}
/>
</div>
);
}

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>
</>
);
}

View File

@ -0,0 +1,63 @@
import { useState, useEffect, useCallback } from "react";
import { ReportMaster, GetReportsParams } from "@/types/report";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
export function useReportList() {
const [reports, setReports] = useState<ReportMaster[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [limit] = useState(20);
const [isLoading, setIsLoading] = useState(false);
const [searchText, setSearchText] = useState("");
const { toast } = useToast();
const fetchReports = useCallback(async () => {
setIsLoading(true);
try {
const params: GetReportsParams = {
page,
limit,
searchText,
useYn: "Y",
sortBy: "created_at",
sortOrder: "DESC",
};
const response = await reportApi.getReports(params);
if (response.success && response.data) {
setReports(response.data.items);
setTotal(response.data.total);
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트 목록을 불러오는데 실패했습니다.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
}, [page, limit, searchText, toast]);
useEffect(() => {
fetchReports();
}, [fetchReports]);
const handleSearch = useCallback((text: string) => {
setSearchText(text);
setPage(1);
}, []);
return {
reports,
total,
page,
limit,
isLoading,
refetch: fetchReports,
setPage,
handleSearch,
};
}

View File

@ -0,0 +1,119 @@
import { apiClient } from "./client";
import {
ReportMaster,
ReportDetail,
GetReportsParams,
GetReportsResponse,
CreateReportRequest,
UpdateReportRequest,
SaveLayoutRequest,
GetTemplatesResponse,
CreateTemplateRequest,
ReportLayout,
} from "@/types/report";
const BASE_URL = "/admin/reports";
export const reportApi = {
// 리포트 목록 조회
getReports: async (params: GetReportsParams) => {
const response = await apiClient.get<{
success: boolean;
data: GetReportsResponse;
}>(BASE_URL, { params });
return response.data;
},
// 리포트 상세 조회
getReportById: async (reportId: string) => {
const response = await apiClient.get<{
success: boolean;
data: ReportDetail;
}>(`${BASE_URL}/${reportId}`);
return response.data;
},
// 리포트 생성
createReport: async (data: CreateReportRequest) => {
const response = await apiClient.post<{
success: boolean;
data: { reportId: string };
message: string;
}>(BASE_URL, data);
return response.data;
},
// 리포트 수정
updateReport: async (reportId: string, data: UpdateReportRequest) => {
const response = await apiClient.put<{
success: boolean;
message: string;
}>(`${BASE_URL}/${reportId}`, data);
return response.data;
},
// 리포트 삭제
deleteReport: async (reportId: string) => {
const response = await apiClient.delete<{
success: boolean;
message: string;
}>(`${BASE_URL}/${reportId}`);
return response.data;
},
// 리포트 복사
copyReport: async (reportId: string) => {
const response = await apiClient.post<{
success: boolean;
data: { reportId: string };
message: string;
}>(`${BASE_URL}/${reportId}/copy`);
return response.data;
},
// 레이아웃 조회
getLayout: async (reportId: string) => {
const response = await apiClient.get<{
success: boolean;
data: ReportLayout;
}>(`${BASE_URL}/${reportId}/layout`);
return response.data;
},
// 레이아웃 저장
saveLayout: async (reportId: string, data: SaveLayoutRequest) => {
const response = await apiClient.put<{
success: boolean;
message: string;
}>(`${BASE_URL}/${reportId}/layout`, data);
return response.data;
},
// 템플릿 목록 조회
getTemplates: async () => {
const response = await apiClient.get<{
success: boolean;
data: GetTemplatesResponse;
}>(`${BASE_URL}/templates`);
return response.data;
},
// 템플릿 생성
createTemplate: async (data: CreateTemplateRequest) => {
const response = await apiClient.post<{
success: boolean;
data: { templateId: string };
message: string;
}>(`${BASE_URL}/templates`, data);
return response.data;
},
// 템플릿 삭제
deleteTemplate: async (templateId: string) => {
const response = await apiClient.delete<{
success: boolean;
message: string;
}>(`${BASE_URL}/templates/${templateId}`);
return response.data;
},
};

156
frontend/types/report.ts Normal file
View File

@ -0,0 +1,156 @@
/**
*
*/
// 리포트 템플릿
export interface ReportTemplate {
template_id: string;
template_name_kor: string;
template_name_eng: string | null;
template_type: string;
is_system: string;
thumbnail_url: string | null;
description: string | null;
layout_config: string | null;
default_queries: string | null;
use_yn: string;
sort_order: number;
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
}
// 리포트 마스터
export interface ReportMaster {
report_id: string;
report_name_kor: string;
report_name_eng: string | null;
template_id: string | null;
report_type: string;
company_code: string | null;
description: string | null;
use_yn: string;
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
}
// 리포트 레이아웃
export interface ReportLayout {
layout_id: string;
report_id: string;
canvas_width: number;
canvas_height: number;
page_orientation: string;
margin_top: number;
margin_bottom: number;
margin_left: number;
margin_right: number;
components: ComponentConfig[];
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
}
// 컴포넌트 설정
export interface ComponentConfig {
id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
zIndex: number;
fontSize?: number;
fontFamily?: string;
fontWeight?: string;
fontColor?: string;
backgroundColor?: string;
borderWidth?: number;
borderColor?: string;
borderRadius?: number;
textAlign?: string;
padding?: number;
queryId?: string;
fieldName?: string;
defaultValue?: string;
format?: string;
visible?: boolean;
printable?: boolean;
conditional?: string;
}
// 리포트 상세
export interface ReportDetail {
report: ReportMaster;
layout: ReportLayout | null;
}
// 리포트 목록 응답
export interface GetReportsResponse {
items: ReportMaster[];
total: number;
page: number;
limit: number;
}
// 리포트 목록 조회 파라미터
export interface GetReportsParams {
page?: number;
limit?: number;
searchText?: string;
reportType?: string;
useYn?: string;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
// 리포트 생성 요청
export interface CreateReportRequest {
reportNameKor: string;
reportNameEng?: string;
templateId?: string;
reportType: string;
description?: string;
companyCode?: string;
}
// 리포트 수정 요청
export interface UpdateReportRequest {
reportNameKor?: string;
reportNameEng?: string;
reportType?: string;
description?: string;
useYn?: string;
}
// 레이아웃 저장 요청
export interface SaveLayoutRequest {
canvasWidth: number;
canvasHeight: number;
pageOrientation: string;
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
components: ComponentConfig[];
}
// 템플릿 목록 응답
export interface GetTemplatesResponse {
system: ReportTemplate[];
custom: ReportTemplate[];
}
// 템플릿 생성 요청
export interface CreateTemplateRequest {
templateNameKor: string;
templateNameEng?: string;
templateType: string;
description?: string;
layoutConfig?: any;
defaultQueries?: any;
}