2026-03-04 18:26:16 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2026-03-05 23:06:36 +09:00
|
|
|
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
2026-03-04 18:26:16 +09:00
|
|
|
import { ComponentRendererProps } from "@/types/component";
|
2026-03-05 23:06:36 +09:00
|
|
|
import {
|
|
|
|
|
ApprovalStepConfig,
|
|
|
|
|
ExtendedApprovalLine,
|
|
|
|
|
ExtendedApprovalRequest,
|
|
|
|
|
StepGroup,
|
|
|
|
|
} from "./types";
|
2026-03-04 18:26:16 +09:00
|
|
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
|
|
|
|
import {
|
|
|
|
|
getApprovalRequests,
|
|
|
|
|
getApprovalRequest,
|
|
|
|
|
} from "@/lib/api/approval";
|
|
|
|
|
import {
|
|
|
|
|
Check,
|
|
|
|
|
X,
|
|
|
|
|
Clock,
|
|
|
|
|
SkipForward,
|
|
|
|
|
Loader2,
|
|
|
|
|
FileCheck,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
ChevronUp,
|
|
|
|
|
ArrowRight,
|
2026-03-05 23:06:36 +09:00
|
|
|
CheckCircle,
|
|
|
|
|
Users,
|
|
|
|
|
Bell,
|
2026-03-04 18:26:16 +09:00
|
|
|
} from "lucide-react";
|
2026-03-05 23:06:36 +09:00
|
|
|
import { Badge } from "@/components/ui/badge";
|
2026-03-04 18:26:16 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
export interface ApprovalStepComponentProps extends ComponentRendererProps {}
|
|
|
|
|
|
|
|
|
|
interface ApprovalStepData {
|
2026-03-05 23:06:36 +09:00
|
|
|
request: ExtendedApprovalRequest;
|
|
|
|
|
lines: ExtendedApprovalLine[];
|
2026-03-04 18:26:16 +09:00
|
|
|
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;
|
|
|
|
|
|
2026-03-05 23:06:36 +09:00
|
|
|
/** step_type에 대응하는 아이콘 */
|
|
|
|
|
const STEP_TYPE_ICON = {
|
|
|
|
|
approval: CheckCircle,
|
|
|
|
|
consensus: Users,
|
|
|
|
|
notification: Bell,
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 결재 라인을 step_order 기준으로 그룹핑
|
|
|
|
|
* 합의결재 시 같은 step_order에 여러 line이 존재할 수 있음
|
|
|
|
|
*/
|
|
|
|
|
function groupLinesByStepOrder(lines: ExtendedApprovalLine[]): StepGroup[] {
|
|
|
|
|
const groupMap = new Map<number, ExtendedApprovalLine[]>();
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
const order = line.step_order;
|
|
|
|
|
if (!groupMap.has(order)) {
|
|
|
|
|
groupMap.set(order, []);
|
|
|
|
|
}
|
|
|
|
|
groupMap.get(order)!.push(line);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const groups: StepGroup[] = [];
|
|
|
|
|
const sortedOrders = Array.from(groupMap.keys()).sort((a, b) => a - b);
|
|
|
|
|
|
|
|
|
|
for (const order of sortedOrders) {
|
|
|
|
|
const groupLines = groupMap.get(order)!;
|
|
|
|
|
const stepType = groupLines[0]?.step_type || "approval";
|
|
|
|
|
groups.push({
|
|
|
|
|
stepOrder: order,
|
|
|
|
|
lines: groupLines,
|
|
|
|
|
stepType,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return groups;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 결재 라인에 표시할 뱃지 목록 반환 */
|
|
|
|
|
function getLineBadges(
|
|
|
|
|
line: ExtendedApprovalLine,
|
|
|
|
|
request: ExtendedApprovalRequest,
|
|
|
|
|
): Array<{ label: string; className: string }> {
|
|
|
|
|
const badges: Array<{ label: string; className: string }> = [];
|
|
|
|
|
|
|
|
|
|
if (line.proxy_for) {
|
|
|
|
|
badges.push({
|
|
|
|
|
label: "대결",
|
|
|
|
|
className: "border-orange-300 text-orange-600",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (request.approval_type === "post") {
|
|
|
|
|
badges.push({
|
|
|
|
|
label: "후결",
|
|
|
|
|
className: "border-amber-300 text-amber-600",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (request.approval_type === "self") {
|
|
|
|
|
badges.push({
|
|
|
|
|
label: "전결",
|
|
|
|
|
className: "border-blue-300 text-blue-600",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return badges;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 18:26:16 +09:00
|
|
|
/**
|
|
|
|
|
* 결재 단계 시각화 컴포넌트
|
|
|
|
|
* 결재 요청의 각 단계별 상태를 스테퍼 형태로 표시
|
|
|
|
|
*/
|
|
|
|
|
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) {
|
2026-03-05 23:06:36 +09:00
|
|
|
const request = detailRes.data as ExtendedApprovalRequest;
|
|
|
|
|
const lines = (request.lines || []) as ExtendedApprovalLine[];
|
2026-03-04 18:26:16 +09:00
|
|
|
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]);
|
|
|
|
|
|
2026-03-05 23:06:36 +09:00
|
|
|
// 디자인 모드용 샘플 데이터 (합의/대결/통보 포함)
|
2026-03-04 18:26:16 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (isDesignMode) {
|
|
|
|
|
setStepData({
|
|
|
|
|
request: {
|
|
|
|
|
request_id: 0,
|
|
|
|
|
title: "결재 요청 샘플",
|
|
|
|
|
target_table: "sample_table",
|
|
|
|
|
target_record_id: "1",
|
|
|
|
|
status: "in_progress",
|
|
|
|
|
current_step: 2,
|
2026-03-05 23:06:36 +09:00
|
|
|
total_steps: 4,
|
2026-03-04 18:26:16 +09:00
|
|
|
requester_id: "admin",
|
|
|
|
|
requester_name: "홍길동",
|
|
|
|
|
requester_dept: "개발팀",
|
|
|
|
|
company_code: "SAMPLE",
|
|
|
|
|
created_at: new Date().toISOString(),
|
|
|
|
|
updated_at: new Date().toISOString(),
|
2026-03-05 23:06:36 +09:00
|
|
|
approval_type: "escalation",
|
|
|
|
|
urgency: "urgent",
|
2026-03-04 18:26:16 +09:00
|
|
|
},
|
|
|
|
|
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(),
|
2026-03-05 23:06:36 +09:00
|
|
|
step_type: "approval",
|
2026-03-04 18:26:16 +09:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
line_id: 2, request_id: 0, step_order: 2,
|
|
|
|
|
approver_id: "user2", approver_name: "이과장", approver_position: "과장", approver_dept: "기획팀",
|
2026-03-05 23:06:36 +09:00
|
|
|
status: "approved",
|
|
|
|
|
processed_at: new Date(Date.now() - 43200000).toISOString(),
|
|
|
|
|
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
|
|
|
|
step_type: "consensus",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
line_id: 3, request_id: 0, step_order: 2,
|
|
|
|
|
approver_id: "user3", approver_name: "최대리", approver_position: "대리", approver_dept: "기획팀",
|
2026-03-04 18:26:16 +09:00
|
|
|
status: "pending",
|
|
|
|
|
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
2026-03-05 23:06:36 +09:00
|
|
|
step_type: "consensus",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
line_id: 4, request_id: 0, step_order: 3,
|
|
|
|
|
approver_id: "user4", approver_name: "박대리", approver_position: "대리", approver_dept: "개발팀",
|
|
|
|
|
status: "waiting",
|
|
|
|
|
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
|
|
|
|
step_type: "approval",
|
|
|
|
|
proxy_for: "정팀장",
|
2026-03-04 18:26:16 +09:00
|
|
|
},
|
|
|
|
|
{
|
2026-03-05 23:06:36 +09:00
|
|
|
line_id: 5, request_id: 0, step_order: 4,
|
|
|
|
|
approver_id: "user5", approver_name: "한사원", approver_position: "사원", approver_dept: "총무팀",
|
2026-03-04 18:26:16 +09:00
|
|
|
status: "waiting",
|
|
|
|
|
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
2026-03-05 23:06:36 +09:00
|
|
|
step_type: "notification",
|
2026-03-04 18:26:16 +09:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
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;
|
2026-03-05 23:06:36 +09:00
|
|
|
const urgency = request.urgency;
|
2026-03-04 18:26:16 +09:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
|
|
|
|
<div className="rounded-md border border-border bg-card">
|
|
|
|
|
{/* 헤더 - 요약 */}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-03-05 23:06:36 +09:00
|
|
|
className={cn(
|
|
|
|
|
"flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50",
|
|
|
|
|
urgency === "critical" && "bg-destructive/10",
|
|
|
|
|
)}
|
2026-03-04 18:26:16 +09:00
|
|
|
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>
|
2026-03-05 23:06:36 +09:00
|
|
|
{urgency === "urgent" && (
|
|
|
|
|
<span className="inline-block h-2 w-2 rounded-full bg-orange-500" />
|
|
|
|
|
)}
|
|
|
|
|
{urgency === "critical" && (
|
|
|
|
|
<span className="inline-block h-2 w-2 rounded-full bg-destructive" />
|
|
|
|
|
)}
|
2026-03-04 18:26:16 +09:00
|
|
|
<span className={cn("rounded-full px-2 py-0.5 text-[10px] font-medium", reqStatus.bg, reqStatus.color)}>
|
|
|
|
|
{reqStatus.label}
|
|
|
|
|
</span>
|
2026-03-05 23:06:36 +09:00
|
|
|
{/* 결재 유형 뱃지 */}
|
|
|
|
|
{request.approval_type === "post" && (
|
|
|
|
|
<Badge variant="outline" className="h-4 border-amber-300 px-1.5 text-[9px] text-amber-600">
|
|
|
|
|
후결
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{request.approval_type === "self" && (
|
|
|
|
|
<Badge variant="outline" className="h-4 border-blue-300 px-1.5 text-[9px] text-blue-600">
|
|
|
|
|
전결
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
2026-03-04 18:26:16 +09:00
|
|
|
{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}
|
2026-03-05 23:06:36 +09:00
|
|
|
request={request}
|
2026-03-04 18:26:16 +09:00
|
|
|
approvalMode={approvalMode}
|
|
|
|
|
compact={compact}
|
|
|
|
|
showDept={showDept}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<VerticalStepper
|
|
|
|
|
lines={lines}
|
2026-03-05 23:06:36 +09:00
|
|
|
request={request}
|
2026-03-04 18:26:16 +09:00
|
|
|
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>
|
2026-03-05 23:06:36 +09:00
|
|
|
{request.approval_type && request.approval_type !== "escalation" && (
|
|
|
|
|
<span>
|
|
|
|
|
유형: {request.approval_type === "self" ? "전결" : request.approval_type === "post" ? "후결" : request.approval_type === "consensus" ? "합의" : request.approval_type}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-03-04 18:26:16 +09:00
|
|
|
</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;
|
2026-03-05 23:06:36 +09:00
|
|
|
const lineBadges = getLineBadges(line, request);
|
|
|
|
|
const StepIcon = STEP_TYPE_ICON[line.step_type || "approval"];
|
2026-03-04 18:26:16 +09:00
|
|
|
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)} />
|
2026-03-05 23:06:36 +09:00
|
|
|
<StepIcon className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
|
2026-03-04 18:26:16 +09:00
|
|
|
<span className="font-medium">{line.approver_name || line.approver_id}</span>
|
2026-03-05 23:06:36 +09:00
|
|
|
{lineBadges.map((b) => (
|
|
|
|
|
<Badge key={b.label} variant="outline" className={cn("h-3.5 px-1 text-[8px]", b.className)}>
|
|
|
|
|
{b.label}
|
|
|
|
|
</Badge>
|
|
|
|
|
))}
|
2026-03-04 18:26:16 +09:00
|
|
|
<span className={cn("font-medium", sc.textColor)}>{sc.label}</span>
|
2026-03-05 23:06:36 +09:00
|
|
|
{line.step_type === "notification" && (
|
|
|
|
|
<span className="text-muted-foreground">(자동 통보)</span>
|
|
|
|
|
)}
|
|
|
|
|
{line.proxy_for && (
|
|
|
|
|
<span className="text-orange-600">({line.proxy_for} 대결)</span>
|
|
|
|
|
)}
|
2026-03-04 18:26:16 +09:00
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-05 23:06:36 +09:00
|
|
|
/* ========== 공통 Props ========== */
|
2026-03-04 18:26:16 +09:00
|
|
|
interface StepperProps {
|
2026-03-05 23:06:36 +09:00
|
|
|
lines: ExtendedApprovalLine[];
|
|
|
|
|
request: ExtendedApprovalRequest;
|
2026-03-04 18:26:16 +09:00
|
|
|
approvalMode: "sequential" | "parallel";
|
|
|
|
|
compact: boolean;
|
|
|
|
|
showDept: boolean;
|
|
|
|
|
showComment?: boolean;
|
|
|
|
|
showTimestamp?: boolean;
|
|
|
|
|
formatDate?: (d?: string | null) => string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 23:06:36 +09:00
|
|
|
/* ========== 결재자 카드 (가로형/세로형 공용) ========== */
|
|
|
|
|
interface ApproverCardProps {
|
|
|
|
|
line: ExtendedApprovalLine;
|
|
|
|
|
request: ExtendedApprovalRequest;
|
|
|
|
|
compact: boolean;
|
|
|
|
|
showDept: boolean;
|
|
|
|
|
variant: "horizontal" | "vertical";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ApproverCard: React.FC<ApproverCardProps> = ({ line, request, compact, showDept, variant }) => {
|
|
|
|
|
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
|
|
|
|
const StatusIcon = sc.icon;
|
|
|
|
|
const lineBadges = getLineBadges(line, request);
|
|
|
|
|
const isNotification = line.step_type === "notification";
|
|
|
|
|
|
|
|
|
|
if (variant === "horizontal") {
|
|
|
|
|
return (
|
|
|
|
|
<div className={cn(
|
|
|
|
|
"flex shrink-0 flex-col items-center gap-0.5",
|
|
|
|
|
isNotification && "rounded px-1 py-0.5 bg-muted/50",
|
|
|
|
|
)}>
|
|
|
|
|
<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>
|
|
|
|
|
<div className="flex items-center gap-0.5">
|
|
|
|
|
<span className={cn("max-w-[60px] truncate text-center font-medium", compact ? "text-[9px]" : "text-[11px]")}>
|
|
|
|
|
{line.approver_name || line.approver_id}
|
|
|
|
|
</span>
|
|
|
|
|
{lineBadges.map((b) => (
|
|
|
|
|
<Badge key={b.label} variant="outline" className={cn("h-3 px-0.5 text-[7px] leading-none", b.className)}>
|
|
|
|
|
{b.label}
|
|
|
|
|
</Badge>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
{isNotification && !compact && (
|
|
|
|
|
<span className="text-[8px] text-muted-foreground">(자동 통보)</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ========== 가로형 스테퍼 ========== */
|
|
|
|
|
const HorizontalStepper: React.FC<StepperProps> = ({ lines, request, approvalMode, compact, showDept }) => {
|
|
|
|
|
const stepGroups = useMemo(() => groupLinesByStepOrder(lines), [lines]);
|
|
|
|
|
|
2026-03-04 18:26:16 +09:00
|
|
|
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">
|
2026-03-05 23:06:36 +09:00
|
|
|
{stepGroups.map((group, groupIdx) => {
|
|
|
|
|
const StepTypeIcon = STEP_TYPE_ICON[group.stepType];
|
|
|
|
|
const isLast = groupIdx === stepGroups.length - 1;
|
|
|
|
|
const isConsensus = group.lines.length > 1;
|
2026-03-04 18:26:16 +09:00
|
|
|
|
|
|
|
|
return (
|
2026-03-05 23:06:36 +09:00
|
|
|
<React.Fragment key={group.stepOrder}>
|
|
|
|
|
{isConsensus ? (
|
|
|
|
|
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
|
|
|
|
{/* 합의결재: step_type 아이콘 표시 */}
|
|
|
|
|
{!compact && (
|
|
|
|
|
<div className="flex items-center gap-0.5 text-[8px] text-muted-foreground">
|
|
|
|
|
<StepTypeIcon className="h-3 w-3" />
|
|
|
|
|
<span>합의</span>
|
|
|
|
|
</div>
|
2026-03-04 18:26:16 +09:00
|
|
|
)}
|
2026-03-05 23:06:36 +09:00
|
|
|
{/* 같은 step_order의 결재자들을 가로로 나열 */}
|
|
|
|
|
<div className="flex items-start gap-2 rounded-md border border-dashed border-muted-foreground/30 px-1.5 py-1">
|
|
|
|
|
{group.lines.map((line, lineIdx) => (
|
|
|
|
|
<React.Fragment key={line.line_id}>
|
|
|
|
|
<ApproverCard
|
|
|
|
|
line={line}
|
|
|
|
|
request={request}
|
|
|
|
|
compact={compact}
|
|
|
|
|
showDept={showDept}
|
|
|
|
|
variant="horizontal"
|
|
|
|
|
/>
|
|
|
|
|
{lineIdx < group.lines.length - 1 && (
|
|
|
|
|
<div className="flex items-center self-center text-[9px] text-muted-foreground/50">+</div>
|
|
|
|
|
)}
|
|
|
|
|
</React.Fragment>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-03-04 18:26:16 +09:00
|
|
|
</div>
|
2026-03-05 23:06:36 +09:00
|
|
|
) : (
|
|
|
|
|
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
|
|
|
|
{/* 단일 결재자: step_type 아이콘 (통보/합의만 표시) */}
|
|
|
|
|
{!compact && group.stepType !== "approval" && (
|
|
|
|
|
<div className="flex items-center gap-0.5 text-[8px] text-muted-foreground">
|
|
|
|
|
<StepTypeIcon className="h-3 w-3" />
|
|
|
|
|
<span>{group.stepType === "notification" ? "통보" : "합의"}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<ApproverCard
|
|
|
|
|
line={group.lines[0]}
|
|
|
|
|
request={request}
|
|
|
|
|
compact={compact}
|
|
|
|
|
showDept={showDept}
|
|
|
|
|
variant="horizontal"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-04 18:26:16 +09:00
|
|
|
|
|
|
|
|
{/* 연결선 */}
|
|
|
|
|
{!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,
|
2026-03-05 23:06:36 +09:00
|
|
|
request,
|
2026-03-04 18:26:16 +09:00
|
|
|
approvalMode,
|
|
|
|
|
compact,
|
|
|
|
|
showDept,
|
|
|
|
|
showComment,
|
|
|
|
|
showTimestamp,
|
|
|
|
|
formatDate,
|
|
|
|
|
}) => {
|
2026-03-05 23:06:36 +09:00
|
|
|
const stepGroups = useMemo(() => groupLinesByStepOrder(lines), [lines]);
|
|
|
|
|
|
2026-03-04 18:26:16 +09:00
|
|
|
if (lines.length === 0) {
|
|
|
|
|
return <div className="py-1 text-center text-[11px] text-muted-foreground">결재선 없음</div>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-0">
|
2026-03-05 23:06:36 +09:00
|
|
|
{stepGroups.map((group, groupIdx) => {
|
|
|
|
|
const StepTypeIcon = STEP_TYPE_ICON[group.stepType];
|
|
|
|
|
const isLast = groupIdx === stepGroups.length - 1;
|
|
|
|
|
const isConsensus = group.lines.length > 1;
|
|
|
|
|
const isNotificationGroup = group.stepType === "notification";
|
2026-03-04 18:26:16 +09:00
|
|
|
|
|
|
|
|
return (
|
2026-03-05 23:06:36 +09:00
|
|
|
<div key={group.stepOrder} className="flex gap-3">
|
2026-03-04 18:26:16 +09:00
|
|
|
{/* 타임라인 바 */}
|
|
|
|
|
<div className="flex flex-col items-center">
|
2026-03-05 23:06:36 +09:00
|
|
|
<div className="flex shrink-0 items-center justify-center">
|
|
|
|
|
<StepTypeIcon className={cn(
|
|
|
|
|
"text-muted-foreground",
|
|
|
|
|
compact ? "h-3.5 w-3.5" : "h-4 w-4",
|
|
|
|
|
)} />
|
2026-03-04 18:26:16 +09:00
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
{/* 결재자 정보 */}
|
2026-03-05 23:06:36 +09:00
|
|
|
<div className={cn(
|
|
|
|
|
"flex-1",
|
|
|
|
|
compact ? "pb-1" : "pb-3",
|
|
|
|
|
isNotificationGroup && "rounded bg-muted/50 px-2 py-1",
|
|
|
|
|
)}>
|
|
|
|
|
{isConsensus ? (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="mb-1 flex items-center gap-1 text-[9px] text-muted-foreground">
|
|
|
|
|
<Users className="h-3 w-3" />
|
|
|
|
|
<span>합의결재 ({group.lines.length}명)</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-wrap items-start gap-2">
|
|
|
|
|
{group.lines.map((line) => {
|
|
|
|
|
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
|
|
|
|
const StatusIcon = sc.icon;
|
|
|
|
|
const lineBadges = getLineBadges(line, request);
|
|
|
|
|
return (
|
|
|
|
|
<div key={line.line_id} className="flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1">
|
|
|
|
|
<div className={cn(
|
|
|
|
|
"flex shrink-0 items-center justify-center rounded-full border",
|
|
|
|
|
sc.bgColor, sc.borderColor,
|
|
|
|
|
"h-5 w-5",
|
|
|
|
|
)}>
|
|
|
|
|
<StatusIcon className={cn(sc.iconColor, "h-2.5 w-2.5")} />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<span className={cn("font-medium", compact ? "text-[10px]" : "text-xs")}>
|
|
|
|
|
{line.approver_name || line.approver_id}
|
|
|
|
|
</span>
|
|
|
|
|
{lineBadges.map((b) => (
|
|
|
|
|
<Badge key={b.label} variant="outline" className={cn("h-3.5 px-1 text-[8px]", b.className)}>
|
|
|
|
|
{b.label}
|
|
|
|
|
</Badge>
|
|
|
|
|
))}
|
|
|
|
|
<span className={cn("text-[9px] font-medium", sc.textColor)}>{sc.label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{showDept && (line.approver_position || line.approver_dept) && (
|
|
|
|
|
<span className="text-[9px] text-muted-foreground">
|
|
|
|
|
{[line.approver_position, line.approver_dept].filter(Boolean).join(" / ")}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{showTimestamp && line.processed_at && formatDate && (
|
|
|
|
|
<div className="text-[9px] text-muted-foreground">{formatDate(line.processed_at)}</div>
|
|
|
|
|
)}
|
|
|
|
|
{showComment && line.comment && (
|
|
|
|
|
<div className="text-[9px] text-muted-foreground">"{line.comment}"</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2026-03-04 18:26:16 +09:00
|
|
|
</div>
|
2026-03-05 23:06:36 +09:00
|
|
|
) : (
|
|
|
|
|
(() => {
|
|
|
|
|
const line = group.lines[0];
|
|
|
|
|
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
|
|
|
|
const StatusIcon = sc.icon;
|
|
|
|
|
const lineBadges = getLineBadges(line, request);
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className={cn(
|
|
|
|
|
"flex shrink-0 items-center justify-center rounded-full border-2",
|
|
|
|
|
sc.bgColor, sc.borderColor,
|
|
|
|
|
compact ? "h-5 w-5" : "h-6 w-6",
|
|
|
|
|
)}>
|
|
|
|
|
<StatusIcon className={cn(sc.iconColor, compact ? "h-2.5 w-2.5" : "h-3 w-3")} />
|
|
|
|
|
</div>
|
|
|
|
|
<span className={cn("font-medium", compact ? "text-[10px]" : "text-xs")}>
|
|
|
|
|
{line.approver_name || line.approver_id}
|
|
|
|
|
</span>
|
|
|
|
|
{lineBadges.map((b) => (
|
|
|
|
|
<Badge key={b.label} variant="outline" className={cn("h-3.5 px-1 text-[8px]", b.className)}>
|
|
|
|
|
{b.label}
|
|
|
|
|
</Badge>
|
|
|
|
|
))}
|
|
|
|
|
{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>
|
|
|
|
|
{isNotificationGroup && (
|
|
|
|
|
<span className="text-[9px] text-muted-foreground">(자동 통보)</span>
|
|
|
|
|
)}
|
|
|
|
|
{line.proxy_for && (
|
|
|
|
|
<span className="text-[9px] text-orange-600">({line.proxy_for} 대결)</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{showTimestamp && line.processed_at && formatDate && (
|
|
|
|
|
<div className="mt-0.5 pl-8 text-[10px] text-muted-foreground">
|
|
|
|
|
{formatDate(line.processed_at)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{showComment && line.comment && (
|
|
|
|
|
<div className="mt-0.5 pl-8 text-[10px] text-muted-foreground">
|
|
|
|
|
"{line.comment}"
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
})()
|
2026-03-04 18:26:16 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const ApprovalStepWrapper: React.FC<ApprovalStepComponentProps> = (props) => {
|
|
|
|
|
return <ApprovalStepComponent {...props} />;
|
|
|
|
|
};
|