- Added validation to prevent duplicate approval requests for the same target, ensuring that only one active or completed approval exists at a time. - Implemented a check to disallow self-approval in the approval line unless the approval type is 'self'. - Integrated the ApprovalDetailModal component into the main layout for improved user experience. - Updated the SalesOrderPage to include approval status in the data structure, enhancing visibility of approval states. - Enhanced BOM management modals across multiple company implementations to accommodate new UI requirements.
208 lines
8.9 KiB
TypeScript
208 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
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 {
|
|
getApprovalRequest,
|
|
processApprovalLine,
|
|
cancelApprovalRequest,
|
|
type ApprovalRequest,
|
|
} from "@/lib/api/approval";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
|
|
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" },
|
|
post_pending: { 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-emerald-600" /> },
|
|
rejected: { label: "반려", icon: <XCircle className="h-3 w-3 text-destructive" /> },
|
|
skipped: { label: "건너뜀", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
|
};
|
|
|
|
export interface ApprovalDetailEventDetail {
|
|
requestId: number;
|
|
}
|
|
|
|
export const ApprovalDetailModal: React.FC = () => {
|
|
const { user } = useAuth();
|
|
const [open, setOpen] = useState(false);
|
|
const [request, setRequest] = useState<ApprovalRequest | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [comment, setComment] = useState("");
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
|
|
// 글로벌 이벤트 수신
|
|
useEffect(() => {
|
|
const handler = async (e: Event) => {
|
|
const detail = (e as CustomEvent).detail as ApprovalDetailEventDetail;
|
|
if (!detail?.requestId) return;
|
|
setOpen(true);
|
|
setLoading(true);
|
|
const res = await getApprovalRequest(detail.requestId);
|
|
setLoading(false);
|
|
if (res.success && res.data) setRequest(res.data);
|
|
else setRequest(null);
|
|
};
|
|
window.addEventListener("open-approval-detail-modal", handler);
|
|
return () => window.removeEventListener("open-approval-detail-modal", handler);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setComment("");
|
|
setRequest(null);
|
|
}
|
|
}, [open]);
|
|
|
|
// 내가 처리할 결재 라인 ID 찾기
|
|
const pendingLineId = request?.lines?.find(
|
|
(l) => l.approver_id === user?.userId && l.status === "pending"
|
|
)?.line_id;
|
|
|
|
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) {
|
|
setOpen(false);
|
|
// 호출한 페이지에 새로고침 알림
|
|
window.dispatchEvent(new CustomEvent("approval-processed", { detail: { requestId: request?.request_id, action } }));
|
|
}
|
|
};
|
|
|
|
const handleCancel = async () => {
|
|
if (!request) return;
|
|
setIsCancelling(true);
|
|
const res = await cancelApprovalRequest(request.request_id);
|
|
setIsCancelling(false);
|
|
if (res.success) {
|
|
setOpen(false);
|
|
window.dispatchEvent(new CustomEvent("approval-processed", { detail: { requestId: request.request_id, action: "cancelled" } }));
|
|
}
|
|
};
|
|
|
|
if (!open) return null;
|
|
|
|
const statusInfo = request ? (statusConfig[request.status] || { label: request.status, variant: "secondary" as const }) : null;
|
|
const isRequester = request?.requester_id === user?.userId;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
|
{loading || !request ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<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">
|
|
{statusInfo && (
|
|
<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 whitespace-pre-line">{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">
|
|
{isRequester && (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={() => setOpen(false)} 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>
|
|
);
|
|
};
|
|
|
|
export default ApprovalDetailModal;
|