[agent-pipeline] pipe-20260305094105-763v round-3

This commit is contained in:
DDD1542 2026-03-05 18:49:33 +09:00
parent 9a328ade78
commit 74d0e730cd
3 changed files with 642 additions and 0 deletions

View File

@ -0,0 +1,548 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Plus, Pencil, Trash2, Search, RefreshCw } from "lucide-react";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import {
SystemNotice,
CreateSystemNoticePayload,
getSystemNotices,
createSystemNotice,
updateSystemNotice,
deleteSystemNotice,
} from "@/lib/api/systemNotice";
// 우선순위 레이블 반환
function getPriorityLabel(priority: number): { label: string; variant: "default" | "secondary" | "destructive" | "outline" } {
if (priority >= 3) return { label: "높음", variant: "destructive" };
if (priority === 2) return { label: "보통", variant: "default" };
return { label: "낮음", variant: "secondary" };
}
// 날짜 포맷
function formatDate(dateStr: string): string {
if (!dateStr) return "-";
return new Date(dateStr).toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
// 폼 초기값
const EMPTY_FORM: CreateSystemNoticePayload = {
title: "",
content: "",
is_active: true,
priority: 1,
};
export default function SystemNoticesPage() {
const [notices, setNotices] = useState<SystemNotice[]>([]);
const [filteredNotices, setFilteredNotices] = useState<SystemNotice[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// 검색 필터
const [searchText, setSearchText] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
// 등록/수정 모달
const [isFormOpen, setIsFormOpen] = useState(false);
const [editTarget, setEditTarget] = useState<SystemNotice | null>(null);
const [formData, setFormData] = useState<CreateSystemNoticePayload>(EMPTY_FORM);
const [isSaving, setIsSaving] = useState(false);
// 삭제 확인 모달
const [deleteTarget, setDeleteTarget] = useState<SystemNotice | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// 공지사항 목록 로드
const loadNotices = useCallback(async () => {
setIsLoading(true);
setErrorMsg(null);
const result = await getSystemNotices();
if (result.success && result.data) {
setNotices(result.data);
} else {
setErrorMsg(result.message || "공지사항 목록을 불러오는 데 실패했습니다.");
}
setIsLoading(false);
}, []);
useEffect(() => {
loadNotices();
}, [loadNotices]);
// 검색/필터 적용
useEffect(() => {
let result = [...notices];
if (statusFilter !== "all") {
const isActive = statusFilter === "active";
result = result.filter((n) => n.is_active === isActive);
}
if (searchText.trim()) {
const keyword = searchText.toLowerCase();
result = result.filter(
(n) =>
n.title.toLowerCase().includes(keyword) ||
n.content.toLowerCase().includes(keyword)
);
}
setFilteredNotices(result);
}, [notices, searchText, statusFilter]);
// 등록 모달 열기
const handleOpenCreate = () => {
setEditTarget(null);
setFormData(EMPTY_FORM);
setIsFormOpen(true);
};
// 수정 모달 열기
const handleOpenEdit = (notice: SystemNotice) => {
setEditTarget(notice);
setFormData({
title: notice.title,
content: notice.content,
is_active: notice.is_active,
priority: notice.priority,
});
setIsFormOpen(true);
};
// 저장 처리
const handleSave = async () => {
if (!formData.title.trim()) {
alert("제목을 입력해주세요.");
return;
}
if (!formData.content.trim()) {
alert("내용을 입력해주세요.");
return;
}
setIsSaving(true);
let result;
if (editTarget) {
result = await updateSystemNotice(editTarget.id, formData);
} else {
result = await createSystemNotice(formData);
}
if (result.success) {
setIsFormOpen(false);
await loadNotices();
} else {
alert(result.message || "저장에 실패했습니다.");
}
setIsSaving(false);
};
// 삭제 처리
const handleDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
const result = await deleteSystemNotice(deleteTarget.id);
if (result.success) {
setDeleteTarget(null);
await loadNotices();
} else {
alert(result.message || "삭제에 실패했습니다.");
}
setIsDeleting(false);
};
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
{/* 에러 메시지 */}
{errorMsg && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-destructive"> </p>
<button
onClick={() => setErrorMsg(null)}
className="text-destructive transition-colors hover:text-destructive/80"
aria-label="에러 메시지 닫기"
>
</button>
</div>
<p className="mt-1.5 text-sm text-destructive/80">{errorMsg}</p>
</div>
)}
{/* 검색 툴바 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* 상태 필터 */}
<div className="w-full sm:w-[160px]">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-10">
<SelectValue placeholder="상태 필터" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="active"></SelectItem>
<SelectItem value="inactive"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 제목 검색 */}
<div className="relative w-full sm:w-[300px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="제목 또는 내용으로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground">{filteredNotices.length}</span>
</span>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={loadNotices}
aria-label="새로고침"
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button className="h-10 gap-2 text-sm font-medium" onClick={handleOpenCreate}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* 데스크톱 테이블 */}
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[100px] text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[100px] text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[120px] text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[120px] text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[120px] text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i} className="border-b">
{Array.from({ length: 6 }).map((_, j) => (
<TableCell key={j} className="h-16">
<div className="h-4 animate-pulse rounded bg-muted" />
</TableCell>
))}
</TableRow>
))
) : filteredNotices.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center text-sm text-muted-foreground">
.
</TableCell>
</TableRow>
) : (
filteredNotices.map((notice) => {
const priority = getPriorityLabel(notice.priority);
return (
<TableRow key={notice.id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-16 text-sm font-medium">{notice.title}</TableCell>
<TableCell className="h-16">
<Badge variant={notice.is_active ? "default" : "secondary"}>
{notice.is_active ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="h-16">
<Badge variant={priority.variant}>{priority.label}</Badge>
</TableCell>
<TableCell className="h-16 text-sm text-muted-foreground">
{notice.created_by || "-"}
</TableCell>
<TableCell className="h-16 text-sm text-muted-foreground">
{formatDate(notice.created_at)}
</TableCell>
<TableCell className="h-16">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleOpenEdit(notice)}
aria-label="수정"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(notice)}
aria-label="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* 모바일 카드 뷰 */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-card p-4 shadow-sm">
<div className="space-y-2">
<div className="h-5 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-4 w-1/2 animate-pulse rounded bg-muted" />
</div>
</div>
))
) : filteredNotices.length === 0 ? (
<div className="col-span-2 flex h-32 items-center justify-center rounded-lg border bg-card text-sm text-muted-foreground">
.
</div>
) : (
filteredNotices.map((notice) => {
const priority = getPriorityLabel(notice.priority);
return (
<div key={notice.id} className="rounded-lg border bg-card p-4 shadow-sm">
<div className="mb-3 flex items-start justify-between">
<h3 className="flex-1 text-base font-semibold">{notice.title}</h3>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleOpenEdit(notice)}
aria-label="수정"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(notice)}
aria-label="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2 border-t pt-3">
<Badge variant={notice.is_active ? "default" : "secondary"}>
{notice.is_active ? "활성" : "비활성"}
</Badge>
<Badge variant={priority.variant}>{priority.label}</Badge>
</div>
<div className="mt-2 space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{notice.created_by || "-"}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{formatDate(notice.created_at)}</span>
</div>
</div>
</div>
);
})
)}
</div>
</div>
{/* 등록/수정 모달 */}
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[540px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{editTarget ? "공지사항 수정" : "공지사항 등록"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{editTarget ? "공지사항 내용을 수정합니다." : "새로운 공지사항을 등록합니다."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 제목 */}
<div>
<Label htmlFor="notice-title" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="notice-title"
value={formData.title}
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
placeholder="공지사항 제목을 입력하세요"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 내용 */}
<div>
<Label htmlFor="notice-content" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Textarea
id="notice-content"
value={formData.content}
onChange={(e) => setFormData((prev) => ({ ...prev, content: e.target.value }))}
placeholder="공지사항 내용을 입력하세요"
className="mt-1 min-h-[120px] text-xs sm:text-sm"
/>
</div>
{/* 우선순위 */}
<div>
<Label htmlFor="notice-priority" className="text-xs sm:text-sm">
</Label>
<Select
value={String(formData.priority)}
onValueChange={(val) =>
setFormData((prev) => ({ ...prev, priority: Number(val) }))
}
>
<SelectTrigger id="notice-priority" className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="우선순위 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem>
<SelectItem value="3"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 활성 여부 */}
<div className="flex items-center gap-2">
<Checkbox
id="notice-active"
checked={formData.is_active}
onCheckedChange={(checked) =>
setFormData((prev) => ({ ...prev, is_active: !!checked }))
}
/>
<Label htmlFor="notice-active" className="cursor-pointer text-xs sm:text-sm">
( )
</Label>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsFormOpen(false)}
disabled={isSaving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isSaving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 모달 */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent className="max-w-[95vw] sm:max-w-[440px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
?
<br /> .
<br />
<span className="mt-2 block font-medium text-foreground">
&quot;{deleteTarget?.title}&quot;
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setDeleteTarget(null)}
disabled={isDeleting}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isDeleting ? "삭제 중..." : "삭제"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ScrollToTop />
</div>
);
}

View File

@ -0,0 +1,90 @@
import { apiClient } from "@/lib/api/client";
export interface SystemNotice {
id: number;
company_code: string;
title: string;
content: string;
is_active: boolean;
priority: number;
created_by: string;
created_at: string;
updated_at: string;
}
export interface CreateSystemNoticePayload {
title: string;
content: string;
is_active: boolean;
priority: number;
}
export interface UpdateSystemNoticePayload {
title?: string;
content?: string;
is_active?: boolean;
priority?: number;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
}
// 공지사항 목록 조회
export async function getSystemNotices(): Promise<ApiResponse<SystemNotice[]>> {
try {
const response = await apiClient.get("/system-notices");
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || "공지사항 목록 조회 실패",
};
}
}
// 공지사항 등록
export async function createSystemNotice(
payload: CreateSystemNoticePayload
): Promise<ApiResponse<SystemNotice>> {
try {
const response = await apiClient.post("/system-notices", payload);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || "공지사항 등록 실패",
};
}
}
// 공지사항 수정
export async function updateSystemNotice(
id: number,
payload: UpdateSystemNoticePayload
): Promise<ApiResponse<SystemNotice>> {
try {
const response = await apiClient.put(`/system-notices/${id}`, payload);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || "공지사항 수정 실패",
};
}
}
// 공지사항 삭제
export async function deleteSystemNotice(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/system-notices/${id}`);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || "공지사항 삭제 실패",
};
}
}

View File

@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}