427 lines
16 KiB
TypeScript
427 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Loader2, CheckCircle2, XCircle, Clock, FileCheck2 } from "lucide-react";
|
|
import {
|
|
getApprovalRequests,
|
|
getApprovalRequest,
|
|
getMyPendingApprovals,
|
|
processApprovalLine,
|
|
cancelApprovalRequest,
|
|
type ApprovalRequest,
|
|
type ApprovalLine,
|
|
} from "@/lib/api/approval";
|
|
|
|
// 상태 배지 색상
|
|
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
|
requested: { label: "요청됨", variant: "secondary" },
|
|
in_progress: { label: "진행 중", variant: "default" },
|
|
approved: { label: "승인됨", variant: "outline" },
|
|
rejected: { label: "반려됨", variant: "destructive" },
|
|
cancelled: { label: "취소됨", variant: "secondary" },
|
|
};
|
|
|
|
const lineStatusConfig: Record<string, { label: string; icon: React.ReactNode }> = {
|
|
waiting: { label: "대기", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
|
pending: { label: "진행 중", icon: <Clock className="h-3 w-3 text-primary" /> },
|
|
approved: { label: "승인", icon: <CheckCircle2 className="h-3 w-3 text-green-600" /> },
|
|
rejected: { label: "반려", icon: <XCircle className="h-3 w-3 text-destructive" /> },
|
|
skipped: { label: "건너뜀", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
|
};
|
|
|
|
// 결재 상세 모달
|
|
interface ApprovalDetailModalProps {
|
|
request: ApprovalRequest | null;
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onRefresh: () => void;
|
|
pendingLineId?: number; // 내가 처리해야 할 결재 라인 ID
|
|
}
|
|
|
|
function ApprovalDetailModal({ request, open, onClose, onRefresh, pendingLineId }: ApprovalDetailModalProps) {
|
|
const [comment, setComment] = useState("");
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open) setComment("");
|
|
}, [open]);
|
|
|
|
const handleProcess = async (action: "approved" | "rejected") => {
|
|
if (!pendingLineId) return;
|
|
setIsProcessing(true);
|
|
const res = await processApprovalLine(pendingLineId, { action, comment: comment.trim() || undefined });
|
|
setIsProcessing(false);
|
|
if (res.success) {
|
|
onRefresh();
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const handleCancel = async () => {
|
|
if (!request) return;
|
|
setIsCancelling(true);
|
|
const res = await cancelApprovalRequest(request.request_id);
|
|
setIsCancelling(false);
|
|
if (res.success) {
|
|
onRefresh();
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
if (!request) return null;
|
|
|
|
const statusInfo = statusConfig[request.status] || { label: request.status, variant: "secondary" as const };
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
|
<FileCheck2 className="h-5 w-5" />
|
|
{request.title}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
<Badge variant={statusInfo.variant} className="mr-2">
|
|
{statusInfo.label}
|
|
</Badge>
|
|
요청자: {request.requester_name || request.requester_id}
|
|
{request.requester_dept ? ` (${request.requester_dept})` : ""}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{/* 결재 사유 */}
|
|
{request.description && (
|
|
<div>
|
|
<p className="text-muted-foreground mb-1 text-xs font-medium">결재 사유</p>
|
|
<p className="rounded-md bg-muted p-3 text-xs sm:text-sm">{request.description}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 결재선 */}
|
|
<div>
|
|
<p className="text-muted-foreground mb-2 text-xs font-medium">결재선</p>
|
|
<div className="space-y-2">
|
|
{(request.lines || []).map((line) => {
|
|
const lineStatus = lineStatusConfig[line.status] || { label: line.status, icon: null };
|
|
return (
|
|
<div
|
|
key={line.line_id}
|
|
className="flex items-start justify-between rounded-md border p-3"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{lineStatus.icon}
|
|
<div>
|
|
<p className="text-xs font-medium sm:text-sm">
|
|
{line.approver_label || `${line.step_order}차 결재`} — {line.approver_name || line.approver_id}
|
|
</p>
|
|
{line.approver_position && (
|
|
<p className="text-muted-foreground text-[10px] sm:text-xs">{line.approver_position}</p>
|
|
)}
|
|
{line.comment && (
|
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
|
의견: {line.comment}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<span className="text-muted-foreground text-[10px] sm:text-xs">{lineStatus.label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 승인/반려 입력 (대기 상태일 때만) */}
|
|
{pendingLineId && (
|
|
<div>
|
|
<p className="text-muted-foreground mb-1 text-xs font-medium">결재 의견 (선택사항)</p>
|
|
<Textarea
|
|
value={comment}
|
|
onChange={(e) => setComment(e.target.value)}
|
|
placeholder="결재 의견을 입력하세요"
|
|
className="min-h-[60px] text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="flex-wrap gap-2 sm:gap-1">
|
|
{/* 요청자만 취소 가능 (요청됨/진행 중 상태) */}
|
|
{(request.status === "requested" || request.status === "in_progress") && !pendingLineId && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleCancel}
|
|
disabled={isCancelling}
|
|
className="h-8 text-xs"
|
|
>
|
|
{isCancelling ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : null}
|
|
회수
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="outline"
|
|
onClick={onClose}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
닫기
|
|
</Button>
|
|
|
|
{pendingLineId && (
|
|
<>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => handleProcess("rejected")}
|
|
disabled={isProcessing}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <XCircle className="mr-1 h-3 w-3" />}
|
|
반려
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleProcess("approved")}
|
|
disabled={isProcessing}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <CheckCircle2 className="mr-1 h-3 w-3" />}
|
|
승인
|
|
</Button>
|
|
</>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// 결재 대기 행 (ApprovalLine 기반)
|
|
function ApprovalLineRow({ line, onClick }: { line: ApprovalLine; onClick: () => void }) {
|
|
const statusInfo = lineStatusConfig[line.status] || { label: line.status, icon: null };
|
|
const createdAt = line.request_created_at || line.created_at;
|
|
const formattedDate = createdAt
|
|
? new Date(createdAt).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" })
|
|
: "-";
|
|
|
|
return (
|
|
<button
|
|
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
|
|
onClick={onClick}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-xs font-medium sm:text-sm">{line.title || "제목 없음"}</p>
|
|
{line.requester_name && (
|
|
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
|
요청자: {line.requester_name}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
|
<span className="flex items-center gap-1 text-[10px] sm:text-xs">
|
|
{statusInfo.icon}
|
|
{statusInfo.label}
|
|
</span>
|
|
<span className="text-muted-foreground text-[10px]">{formattedDate}</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// 결재 요청 행 (ApprovalRequest 기반)
|
|
function ApprovalRequestRow({ request, onClick }: { request: ApprovalRequest; onClick: () => void }) {
|
|
const statusInfo = statusConfig[request.status] || { label: request.status, variant: "secondary" as const };
|
|
const formattedDate = request.created_at
|
|
? new Date(request.created_at).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" })
|
|
: "-";
|
|
|
|
return (
|
|
<button
|
|
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
|
|
onClick={onClick}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-xs font-medium sm:text-sm">{request.title}</p>
|
|
{request.requester_name && (
|
|
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
|
요청자: {request.requester_name}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
|
<Badge variant={statusInfo.variant} className="text-[10px]">
|
|
{statusInfo.label}
|
|
</Badge>
|
|
<span className="text-muted-foreground text-[10px]">{formattedDate}</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// 빈 상태 컴포넌트
|
|
function EmptyState({ message }: { message: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted">
|
|
<FileCheck2 className="h-7 w-7 text-muted-foreground" />
|
|
</div>
|
|
<p className="text-muted-foreground text-sm">{message}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 메인 결재함 페이지
|
|
export default function ApprovalPage() {
|
|
const [activeTab, setActiveTab] = useState("pending");
|
|
const [pendingLines, setPendingLines] = useState<ApprovalLine[]>([]);
|
|
const [myRequests, setMyRequests] = useState<ApprovalRequest[]>([]);
|
|
const [completedRequests, setCompletedRequests] = useState<ApprovalRequest[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
// 상세 모달
|
|
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequest | null>(null);
|
|
const [selectedPendingLineId, setSelectedPendingLineId] = useState<number | undefined>();
|
|
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
|
|
|
const loadData = useCallback(async () => {
|
|
setIsLoading(true);
|
|
|
|
const [pendingRes, myRes, completedRes] = await Promise.all([
|
|
getMyPendingApprovals(),
|
|
// my_approvals 없이 호출 → 백엔드에서 현재 사용자의 요청 건 반환
|
|
getApprovalRequests(),
|
|
getApprovalRequests({ status: "approved" }),
|
|
]);
|
|
|
|
if (pendingRes.success && pendingRes.data) setPendingLines(pendingRes.data);
|
|
if (myRes.success && myRes.data) setMyRequests(myRes.data);
|
|
if (completedRes.success && completedRes.data) setCompletedRequests(completedRes.data);
|
|
|
|
setIsLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
const handleOpenDetail = async (requestId: number, pendingLineId?: number) => {
|
|
const res = await getApprovalRequest(requestId);
|
|
if (res.success && res.data) {
|
|
setSelectedRequest(res.data);
|
|
setSelectedPendingLineId(pendingLineId);
|
|
setDetailModalOpen(true);
|
|
}
|
|
};
|
|
|
|
const handleOpenFromLine = async (line: ApprovalLine) => {
|
|
if (!line.request_id) return;
|
|
await handleOpenDetail(line.request_id, line.line_id);
|
|
};
|
|
|
|
return (
|
|
<div className="container mx-auto max-w-3xl p-4 sm:p-6">
|
|
<div className="mb-6">
|
|
<h1 className="text-xl font-bold sm:text-2xl">결재함</h1>
|
|
<p className="text-muted-foreground mt-1 text-sm">결재 요청을 확인하고 처리합니다.</p>
|
|
</div>
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList className="mb-4 grid w-full grid-cols-3">
|
|
<TabsTrigger value="pending" className="text-xs sm:text-sm">
|
|
대기함
|
|
{pendingLines.length > 0 && (
|
|
<Badge variant="destructive" className="ml-1 h-4 min-w-[16px] px-1 text-[10px]">
|
|
{pendingLines.length}
|
|
</Badge>
|
|
)}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="my-requests" className="text-xs sm:text-sm">
|
|
요청함
|
|
</TabsTrigger>
|
|
<TabsTrigger value="completed" className="text-xs sm:text-sm">
|
|
완료함
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 대기함: 내가 결재해야 할 건 */}
|
|
<TabsContent value="pending">
|
|
{pendingLines.length === 0 ? (
|
|
<EmptyState message="결재 대기 중인 건이 없습니다." />
|
|
) : (
|
|
<div className="space-y-2">
|
|
{pendingLines.map((line) => (
|
|
<ApprovalLineRow
|
|
key={line.line_id}
|
|
line={line}
|
|
onClick={() => handleOpenFromLine(line)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 요청함: 내가 요청한 건 */}
|
|
<TabsContent value="my-requests">
|
|
{myRequests.length === 0 ? (
|
|
<EmptyState message="요청한 결재 건이 없습니다." />
|
|
) : (
|
|
<div className="space-y-2">
|
|
{myRequests.map((req) => (
|
|
<ApprovalRequestRow
|
|
key={req.request_id}
|
|
request={req}
|
|
onClick={() => handleOpenDetail(req.request_id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 완료함 */}
|
|
<TabsContent value="completed">
|
|
{completedRequests.length === 0 ? (
|
|
<EmptyState message="완료된 결재 건이 없습니다." />
|
|
) : (
|
|
<div className="space-y-2">
|
|
{completedRequests.map((req) => (
|
|
<ApprovalRequestRow
|
|
key={req.request_id}
|
|
request={req}
|
|
onClick={() => handleOpenDetail(req.request_id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</>
|
|
)}
|
|
</Tabs>
|
|
|
|
{/* 결재 상세 모달 */}
|
|
<ApprovalDetailModal
|
|
request={selectedRequest}
|
|
open={detailModalOpen}
|
|
onClose={() => setDetailModalOpen(false)}
|
|
onRefresh={loadData}
|
|
pendingLineId={selectedPendingLineId}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|