ERP-node/frontend/lib/registry/components/v2-approval-step/ApprovalStepComponent.tsx

531 lines
18 KiB
TypeScript
Raw Normal View History

"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<ApprovalStepComponentProps> = ({
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<ApprovalStepData | null>(null);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div style={componentStyle} onClick={handleClick} {...domProps}>
<div className="flex items-center justify-center rounded-md border border-dashed border-border p-4 text-xs text-muted-foreground">
.
</div>
</div>
);
}
if (loading) {
return (
<div style={componentStyle} onClick={handleClick} {...domProps}>
<div className="flex items-center justify-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
...
</div>
</div>
);
}
if (error) {
return (
<div style={componentStyle} onClick={handleClick} {...domProps}>
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
{error}
</div>
</div>
);
}
if (!stepData) {
if (!isDesignMode && !targetRecordId) {
return (
<div style={componentStyle} onClick={handleClick} {...domProps}>
<div className="flex items-center gap-2 rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
<FileCheck className="h-3.5 w-3.5" />
.
</div>
</div>
);
}
return (
<div style={componentStyle} onClick={handleClick} {...domProps}>
<div className="flex items-center gap-2 rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
<FileCheck className="h-3.5 w-3.5" />
.
</div>
</div>
);
}
const { request, lines, approvalMode } = stepData;
const reqStatus = REQUEST_STATUS_CONFIG[request.status] || REQUEST_STATUS_CONFIG.requested;
return (
<div style={componentStyle} onClick={handleClick} {...domProps}>
<div className="rounded-md border border-border bg-card">
{/* 헤더 - 요약 */}
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50"
onClick={(e) => {
e.stopPropagation();
if (!isDesignMode) setExpanded((prev) => !prev);
}}
>
<div className="flex items-center gap-2">
<FileCheck className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">{request.title}</span>
<span className={cn("rounded-full px-2 py-0.5 text-[10px] font-medium", reqStatus.bg, reqStatus.color)}>
{reqStatus.label}
</span>
{approvalMode === "parallel" && (
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-medium text-blue-600">
</span>
)}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-[10px]">
{request.current_step}/{request.total_steps}
</span>
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</div>
</button>
{/* 스테퍼 영역 */}
<div className={cn("border-t border-border", compact ? "px-2 py-1.5" : "px-3 py-2.5")}>
{displayMode === "horizontal" ? (
<HorizontalStepper
lines={lines}
approvalMode={approvalMode}
compact={compact}
showDept={showDept}
/>
) : (
<VerticalStepper
lines={lines}
approvalMode={approvalMode}
compact={compact}
showDept={showDept}
showComment={showComment}
showTimestamp={showTimestamp}
formatDate={formatDate}
/>
)}
</div>
{/* 확장 영역 - 상세 정보 */}
{expanded && (
<div className="border-t border-border px-3 py-2">
<div className="space-y-2">
<div className="flex items-center gap-4 text-[11px] text-muted-foreground">
<span>: {request.requester_name || request.requester_id}</span>
{request.requester_dept && <span>: {request.requester_dept}</span>}
<span>: {formatDate(request.created_at)}</span>
</div>
{displayMode === "horizontal" && lines.length > 0 && (
<div className="mt-1.5 space-y-1">
{lines.map((line) => {
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
return (
<div key={line.line_id} className="flex items-start gap-2 text-[11px]">
<span className={cn("mt-0.5 inline-block h-2 w-2 shrink-0 rounded-full", sc.dotColor)} />
<span className="font-medium">{line.approver_name || line.approver_id}</span>
<span className={cn("font-medium", sc.textColor)}>{sc.label}</span>
{showTimestamp && line.processed_at && (
<span className="text-muted-foreground">{formatDate(line.processed_at)}</span>
)}
{showComment && line.comment && (
<span className="text-muted-foreground">- {line.comment}</span>
)}
</div>
);
})}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
};
/* ========== 가로형 스테퍼 ========== */
interface StepperProps {
lines: ApprovalLine[];
approvalMode: "sequential" | "parallel";
compact: boolean;
showDept: boolean;
showComment?: boolean;
showTimestamp?: boolean;
formatDate?: (d?: string | null) => string;
}
const HorizontalStepper: React.FC<StepperProps> = ({ lines, approvalMode, compact, showDept }) => {
if (lines.length === 0) {
return <div className="py-1 text-center text-[11px] text-muted-foreground"> </div>;
}
return (
<div className="flex items-center gap-0 overflow-x-auto">
{lines.map((line, idx) => {
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
const StatusIcon = sc.icon;
const isLast = idx === lines.length - 1;
return (
<React.Fragment key={line.line_id}>
<div className="flex shrink-0 flex-col items-center gap-0.5">
{/* 아이콘 원 */}
<div
className={cn(
"flex items-center justify-center rounded-full border-2 transition-all",
sc.bgColor,
sc.borderColor,
compact ? "h-6 w-6" : "h-8 w-8"
)}
>
<StatusIcon className={cn(sc.iconColor, compact ? "h-3 w-3" : "h-4 w-4")} />
</div>
{/* 결재자 이름 */}
<span className={cn("max-w-[60px] truncate text-center font-medium", compact ? "text-[9px]" : "text-[11px]")}>
{line.approver_name || line.approver_id}
</span>
{/* 직급/부서 */}
{showDept && !compact && (line.approver_position || line.approver_dept) && (
<span className="max-w-[70px] truncate text-center text-[9px] text-muted-foreground">
{line.approver_position || line.approver_dept}
</span>
)}
</div>
{/* 연결선 */}
{!isLast && (
<div className="mx-1 flex shrink-0 items-center">
{approvalMode === "parallel" ? (
<div className="flex h-[1px] w-4 items-center border-t border-dashed border-muted-foreground/40" />
) : (
<ArrowRight className="h-3 w-3 text-muted-foreground/40" />
)}
</div>
)}
</React.Fragment>
);
})}
</div>
);
};
/* ========== 세로형 스테퍼 ========== */
const VerticalStepper: React.FC<StepperProps> = ({
lines,
approvalMode,
compact,
showDept,
showComment,
showTimestamp,
formatDate,
}) => {
if (lines.length === 0) {
return <div className="py-1 text-center text-[11px] text-muted-foreground"> </div>;
}
return (
<div className="space-y-0">
{lines.map((line, idx) => {
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
const StatusIcon = sc.icon;
const isLast = idx === lines.length - 1;
return (
<div key={line.line_id} className="flex gap-3">
{/* 타임라인 바 */}
<div className="flex flex-col items-center">
<div
className={cn(
"flex shrink-0 items-center justify-center rounded-full border-2",
sc.bgColor,
sc.borderColor,
compact ? "h-5 w-5" : "h-7 w-7"
)}
>
<StatusIcon className={cn(sc.iconColor, compact ? "h-2.5 w-2.5" : "h-3.5 w-3.5")} />
</div>
{!isLast && (
<div
className={cn(
"w-[2px] flex-1",
approvalMode === "parallel"
? "border-l border-dashed border-muted-foreground/30"
: "bg-muted-foreground/20",
compact ? "min-h-[12px]" : "min-h-[20px]"
)}
/>
)}
</div>
{/* 결재자 정보 */}
<div className={cn("pb-2", compact ? "pb-1" : "pb-3")}>
<div className="flex items-center gap-2">
<span className={cn("font-medium", compact ? "text-[10px]" : "text-xs")}>
{line.approver_name || line.approver_id}
</span>
{showDept && (line.approver_position || line.approver_dept) && (
<span className="text-[10px] text-muted-foreground">
{[line.approver_position, line.approver_dept].filter(Boolean).join(" / ")}
</span>
)}
<span className={cn("rounded px-1.5 py-0.5 text-[9px] font-medium", sc.bgColor, sc.textColor)}>
{sc.label}
</span>
</div>
{showTimestamp && line.processed_at && formatDate && (
<div className="mt-0.5 text-[10px] text-muted-foreground">
{formatDate(line.processed_at)}
</div>
)}
{showComment && line.comment && (
<div className="mt-0.5 text-[10px] text-muted-foreground">
&quot;{line.comment}&quot;
</div>
)}
</div>
</div>
);
})}
</div>
);
};
export const ApprovalStepWrapper: React.FC<ApprovalStepComponentProps> = (props) => {
return <ApprovalStepComponent {...props} />;
};