ERP-node/frontend/app/(main)/admin/approvalBox/page.tsx

420 lines
17 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import {
Loader2, Send, Inbox, CheckCircle, XCircle, Clock, Eye,
} from "lucide-react";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import {
getApprovalRequests,
getApprovalRequest,
getMyPendingApprovals,
processApprovalLine,
cancelApprovalRequest,
type ApprovalRequest,
type ApprovalLine,
} from "@/lib/api/approval";
const STATUS_MAP: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
requested: { label: "요청", variant: "outline" },
in_progress: { label: "진행중", variant: "default" },
approved: { label: "승인", variant: "default" },
rejected: { label: "반려", variant: "destructive" },
cancelled: { label: "회수", variant: "secondary" },
waiting: { label: "대기", variant: "outline" },
pending: { label: "결재대기", variant: "default" },
skipped: { label: "건너뜀", variant: "secondary" },
};
function StatusBadge({ status }: { status: string }) {
const info = STATUS_MAP[status] || { label: status, variant: "outline" as const };
return <Badge variant={info.variant}>{info.label}</Badge>;
}
function formatDate(dateStr?: string) {
if (!dateStr) return "-";
return new Date(dateStr).toLocaleDateString("ko-KR", {
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit",
});
}
// ============================================================
// 상신함 (내가 올린 결재)
// ============================================================
function SentTab() {
const [requests, setRequests] = useState<ApprovalRequest[]>([]);
const [loading, setLoading] = useState(true);
const [detailOpen, setDetailOpen] = useState(false);
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequest | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const fetchRequests = useCallback(async () => {
setLoading(true);
const res = await getApprovalRequests({ my_approvals: false });
if (res.success && res.data) setRequests(res.data);
setLoading(false);
}, []);
useEffect(() => { fetchRequests(); }, [fetchRequests]);
const openDetail = async (req: ApprovalRequest) => {
setDetailLoading(true);
setDetailOpen(true);
const res = await getApprovalRequest(req.request_id);
if (res.success && res.data) {
setSelectedRequest(res.data);
} else {
setSelectedRequest(req);
}
setDetailLoading(false);
};
const handleCancel = async () => {
if (!selectedRequest) return;
const res = await cancelApprovalRequest(selectedRequest.request_id);
if (res.success) {
toast.success("결재가 회수되었습니다.");
setDetailOpen(false);
fetchRequests();
} else {
toast.error(res.error || "회수 실패");
}
};
return (
<div className="space-y-4">
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : requests.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<Send className="text-muted-foreground mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm"> .</p>
</div>
) : (
<div className="bg-card rounded-lg border shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[140px] text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[60px] text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((req) => (
<TableRow key={req.request_id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-14 text-sm font-medium">{req.title}</TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{req.target_table}</TableCell>
<TableCell className="h-14 text-center text-sm">
{req.current_step}/{req.total_steps}
</TableCell>
<TableCell className="h-14 text-center"><StatusBadge status={req.status} /></TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(req.created_at)}</TableCell>
<TableCell className="h-14 text-center">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openDetail(req)}>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 상세 모달 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{selectedRequest?.title}
</DialogDescription>
</DialogHeader>
{detailLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : selectedRequest && (
<div className="max-h-[50vh] space-y-4 overflow-y-auto">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground text-xs"></span>
<div className="mt-1"><StatusBadge status={selectedRequest.status} /></div>
</div>
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="mt-1 font-medium">{selectedRequest.current_step}/{selectedRequest.total_steps}</p>
</div>
<div>
<span className="text-muted-foreground text-xs"> </span>
<p className="mt-1 font-medium">{selectedRequest.target_table}</p>
</div>
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="mt-1">{formatDate(selectedRequest.created_at)}</p>
</div>
</div>
{selectedRequest.description && (
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="mt-1 text-sm">{selectedRequest.description}</p>
</div>
)}
{/* 결재선 */}
{selectedRequest.lines && selectedRequest.lines.length > 0 && (
<div>
<span className="text-muted-foreground text-xs"></span>
<div className="mt-2 space-y-2">
{selectedRequest.lines
.sort((a, b) => a.step_order - b.step_order)
.map((line) => (
<div key={line.line_id} className="bg-muted/30 flex items-center justify-between rounded-md border p-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px]">{line.step_order}</Badge>
<span className="text-sm font-medium">{line.approver_name || line.approver_id}</span>
{line.approver_position && (
<span className="text-muted-foreground text-xs">({line.approver_position})</span>
)}
</div>
<div className="flex items-center gap-2">
<StatusBadge status={line.status} />
{line.processed_at && (
<span className="text-muted-foreground text-[10px]">{formatDate(line.processed_at)}</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
{selectedRequest?.status === "requested" && (
<Button variant="destructive" onClick={handleCancel} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
)}
<Button variant="outline" onClick={() => setDetailOpen(false)} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ============================================================
// 수신함 (내가 결재해야 할 것)
// ============================================================
function ReceivedTab() {
const [pendingLines, setPendingLines] = useState<ApprovalLine[]>([]);
const [loading, setLoading] = useState(true);
const [processOpen, setProcessOpen] = useState(false);
const [selectedLine, setSelectedLine] = useState<ApprovalLine | null>(null);
const [comment, setComment] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const fetchPending = useCallback(async () => {
setLoading(true);
const res = await getMyPendingApprovals();
if (res.success && res.data) setPendingLines(res.data);
setLoading(false);
}, []);
useEffect(() => { fetchPending(); }, [fetchPending]);
const openProcess = (line: ApprovalLine) => {
setSelectedLine(line);
setComment("");
setProcessOpen(true);
};
const handleProcess = async (action: "approved" | "rejected") => {
if (!selectedLine) return;
setIsProcessing(true);
const res = await processApprovalLine(selectedLine.line_id, {
action,
comment: comment.trim() || undefined,
});
setIsProcessing(false);
if (res.success) {
toast.success(action === "approved" ? "승인되었습니다." : "반려되었습니다.");
setProcessOpen(false);
fetchPending();
} else {
toast.error(res.error || "처리 실패");
}
};
return (
<div className="space-y-4">
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : pendingLines.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<Inbox className="text-muted-foreground mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm"> .</p>
</div>
) : (
<div className="bg-card rounded-lg border shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[140px] text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendingLines.map((line) => (
<TableRow key={line.line_id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="h-14 text-sm font-medium">{line.title || "-"}</TableCell>
<TableCell className="h-14 text-sm">
{line.requester_name || "-"}
{line.requester_dept && (
<span className="text-muted-foreground ml-1 text-xs">({line.requester_dept})</span>
)}
</TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{line.target_table || "-"}</TableCell>
<TableCell className="h-14 text-center text-sm">
<Badge variant="outline">{line.step_order}</Badge>
</TableCell>
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(line.request_created_at || line.created_at)}</TableCell>
<TableCell className="h-14 text-center">
<Button size="sm" className="h-8 text-xs" onClick={() => openProcess(line)}>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 결재 처리 모달 */}
<Dialog open={processOpen} onOpenChange={setProcessOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{selectedLine?.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="mt-1 font-medium">{selectedLine?.requester_name || "-"}</p>
</div>
<div>
<span className="text-muted-foreground text-xs"> </span>
<p className="mt-1 font-medium">{selectedLine?.step_order} </p>
</div>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="결재 의견을 입력하세요 (선택사항)"
className="min-h-[80px] text-xs sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="destructive"
onClick={() => handleProcess("rejected")}
disabled={isProcessing}
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<XCircle className="h-4 w-4" />
</Button>
<Button
onClick={() => handleProcess("approved")}
disabled={isProcessing}
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle className="h-4 w-4" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ============================================================
// 메인 페이지
// ============================================================
export default function ApprovalBoxPage() {
return (
<div className="bg-background flex min-h-screen flex-col">
<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-muted-foreground text-sm">
.
</p>
</div>
<Tabs defaultValue="received" className="space-y-4">
<TabsList>
<TabsTrigger value="received" className="gap-2">
<Inbox className="h-4 w-4" />
( )
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
<Send className="h-4 w-4" />
( )
</TabsTrigger>
</TabsList>
<TabsContent value="received">
<ReceivedTab />
</TabsContent>
<TabsContent value="sent">
<SentTab />
</TabsContent>
</Tabs>
</div>
<ScrollToTop />
</div>
);
}