"use client"; import React, { useEffect, useState, useCallback } from "react"; import { ComponentRendererProps } from "@/types/component"; import { ApprovalStepConfig } from "./types"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { getApprovalRequests, getApprovalRequest, type ApprovalRequest, type ApprovalLine, } from "@/lib/api/approval"; import { Check, X, Clock, SkipForward, Loader2, FileCheck, ChevronDown, ChevronUp, ArrowRight, } from "lucide-react"; import { cn } from "@/lib/utils"; export interface ApprovalStepComponentProps extends ComponentRendererProps {} interface ApprovalStepData { request: ApprovalRequest; lines: ApprovalLine[]; approvalMode: "sequential" | "parallel"; } const STATUS_CONFIG = { approved: { label: "승인", icon: Check, bgColor: "bg-emerald-100", borderColor: "border-emerald-500", textColor: "text-emerald-700", iconColor: "text-emerald-600", dotColor: "bg-emerald-500", }, rejected: { label: "반려", icon: X, bgColor: "bg-rose-100", borderColor: "border-rose-500", textColor: "text-rose-700", iconColor: "text-rose-600", dotColor: "bg-rose-500", }, pending: { label: "결재 대기", icon: Clock, bgColor: "bg-amber-50", borderColor: "border-amber-400", textColor: "text-amber-700", iconColor: "text-amber-500", dotColor: "bg-amber-400", }, waiting: { label: "대기", icon: Clock, bgColor: "bg-muted", borderColor: "border-border", textColor: "text-muted-foreground", iconColor: "text-muted-foreground", dotColor: "bg-muted-foreground/40", }, skipped: { label: "건너뜀", icon: SkipForward, bgColor: "bg-muted/50", borderColor: "border-border/50", textColor: "text-muted-foreground/70", iconColor: "text-muted-foreground/50", dotColor: "bg-muted-foreground/30", }, } as const; const REQUEST_STATUS_CONFIG = { requested: { label: "요청됨", color: "text-blue-600", bg: "bg-blue-50" }, in_progress: { label: "진행 중", color: "text-amber-600", bg: "bg-amber-50" }, approved: { label: "승인 완료", color: "text-emerald-600", bg: "bg-emerald-50" }, rejected: { label: "반려", color: "text-rose-600", bg: "bg-rose-50" }, cancelled: { label: "취소", color: "text-muted-foreground", bg: "bg-muted" }, } as const; /** * 결재 단계 시각화 컴포넌트 * 결재 요청의 각 단계별 상태를 스테퍼 형태로 표시 */ export const ApprovalStepComponent: React.FC = ({ component, isDesignMode = false, isSelected = false, onClick, onDragStart, onDragEnd, formData, ...props }) => { const componentConfig = (component.componentConfig || {}) as ApprovalStepConfig; const { targetTable, targetRecordIdField, displayMode = "horizontal", showComment = true, showTimestamp = true, showDept = true, compact = false, } = componentConfig; const [stepData, setStepData] = useState(null); const [loading, setLoading] = useState(false); const [expanded, setExpanded] = useState(false); const [error, setError] = useState(null); const targetRecordId = targetRecordIdField && formData ? String(formData[targetRecordIdField] || "") : ""; const fetchApprovalData = useCallback(async () => { if (isDesignMode || !targetTable || !targetRecordId) return; setLoading(true); setError(null); try { const res = await getApprovalRequests({ target_table: targetTable, target_record_id: targetRecordId, limit: 1, }); if (res.success && res.data && res.data.length > 0) { const latestRequest = res.data[0]; const detailRes = await getApprovalRequest(latestRequest.request_id); if (detailRes.success && detailRes.data) { const request = detailRes.data; const lines = request.lines || []; const approvalMode = (request.target_record_data?.approval_mode as "sequential" | "parallel") || "sequential"; setStepData({ request, lines, approvalMode }); } } else { setStepData(null); } } catch (err) { setError("결재 정보를 불러올 수 없습니다."); } finally { setLoading(false); } }, [isDesignMode, targetTable, targetRecordId]); useEffect(() => { fetchApprovalData(); }, [fetchApprovalData]); // 디자인 모드용 샘플 데이터 useEffect(() => { if (isDesignMode) { setStepData({ request: { request_id: 0, title: "결재 요청 샘플", target_table: "sample_table", target_record_id: "1", status: "in_progress", current_step: 2, total_steps: 3, requester_id: "admin", requester_name: "홍길동", requester_dept: "개발팀", company_code: "SAMPLE", created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }, lines: [ { line_id: 1, request_id: 0, step_order: 1, approver_id: "user1", approver_name: "김부장", approver_position: "부장", approver_dept: "경영지원팀", status: "approved", comment: "확인했습니다.", processed_at: new Date(Date.now() - 86400000).toISOString(), company_code: "SAMPLE", created_at: new Date().toISOString(), }, { line_id: 2, request_id: 0, step_order: 2, approver_id: "user2", approver_name: "이과장", approver_position: "과장", approver_dept: "기획팀", status: "pending", company_code: "SAMPLE", created_at: new Date().toISOString(), }, { line_id: 3, request_id: 0, step_order: 3, approver_id: "user3", approver_name: "박대리", approver_position: "대리", approver_dept: "개발팀", status: "waiting", company_code: "SAMPLE", created_at: new Date().toISOString(), }, ], approvalMode: "sequential", }); } }, [isDesignMode]); const componentStyle: React.CSSProperties = { position: "absolute", left: `${component.style?.positionX || 0}px`, top: `${component.style?.positionY || 0}px`, width: `${component.style?.width || 500}px`, height: "auto", minHeight: `${component.style?.height || 80}px`, zIndex: component.style?.positionZ || 1, cursor: isDesignMode ? "pointer" : "default", border: isSelected ? "2px solid #3b82f6" : "none", }; const handleClick = (e: React.MouseEvent) => { if (isDesignMode) { e.stopPropagation(); onClick?.(e); } }; const domProps = filterDOMProps(props); const formatDate = (dateStr?: string | null) => { if (!dateStr) return ""; const d = new Date(dateStr); return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; }; if (!isDesignMode && !targetTable) { return (
대상 테이블이 설정되지 않았습니다.
); } if (loading) { return (
결재 정보 로딩 중...
); } if (error) { return (
{error}
); } if (!stepData) { if (!isDesignMode && !targetRecordId) { return (
레코드를 선택하면 결재 현황이 표시됩니다.
); } return (
결재 요청 내역이 없습니다.
); } const { request, lines, approvalMode } = stepData; const reqStatus = REQUEST_STATUS_CONFIG[request.status] || REQUEST_STATUS_CONFIG.requested; return (
{/* 헤더 - 요약 */} {/* 스테퍼 영역 */}
{displayMode === "horizontal" ? ( ) : ( )}
{/* 확장 영역 - 상세 정보 */} {expanded && (
상신자: {request.requester_name || request.requester_id} {request.requester_dept && 부서: {request.requester_dept}} 상신일: {formatDate(request.created_at)}
{displayMode === "horizontal" && lines.length > 0 && (
{lines.map((line) => { const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting; return (
{line.approver_name || line.approver_id} {sc.label} {showTimestamp && line.processed_at && ( {formatDate(line.processed_at)} )} {showComment && line.comment && ( - {line.comment} )}
); })}
)}
)}
); }; /* ========== 가로형 스테퍼 ========== */ interface StepperProps { lines: ApprovalLine[]; approvalMode: "sequential" | "parallel"; compact: boolean; showDept: boolean; showComment?: boolean; showTimestamp?: boolean; formatDate?: (d?: string | null) => string; } const HorizontalStepper: React.FC = ({ lines, approvalMode, compact, showDept }) => { if (lines.length === 0) { return
결재선 없음
; } return (
{lines.map((line, idx) => { const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting; const StatusIcon = sc.icon; const isLast = idx === lines.length - 1; return (
{/* 아이콘 원 */}
{/* 결재자 이름 */} {line.approver_name || line.approver_id} {/* 직급/부서 */} {showDept && !compact && (line.approver_position || line.approver_dept) && ( {line.approver_position || line.approver_dept} )}
{/* 연결선 */} {!isLast && (
{approvalMode === "parallel" ? (
) : ( )}
)} ); })}
); }; /* ========== 세로형 스테퍼 ========== */ const VerticalStepper: React.FC = ({ lines, approvalMode, compact, showDept, showComment, showTimestamp, formatDate, }) => { if (lines.length === 0) { return
결재선 없음
; } return (
{lines.map((line, idx) => { const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting; const StatusIcon = sc.icon; const isLast = idx === lines.length - 1; return (
{/* 타임라인 바 */}
{!isLast && (
)}
{/* 결재자 정보 */}
{line.approver_name || line.approver_id} {showDept && (line.approver_position || line.approver_dept) && ( {[line.approver_position, line.approver_dept].filter(Boolean).join(" / ")} )} {sc.label}
{showTimestamp && line.processed_at && formatDate && (
{formatDate(line.processed_at)}
)} {showComment && line.comment && (
"{line.comment}"
)}
); })}
); }; export const ApprovalStepWrapper: React.FC = (props) => { return ; };