[agent-pipeline] pipe-20260305133525-uca5 round-4

This commit is contained in:
DDD1542
2026-03-05 23:06:36 +09:00
parent 7d6ca6403a
commit e662de1da4
6 changed files with 2354 additions and 268 deletions

View File

@@ -1,14 +1,17 @@
"use client";
import React, { useEffect, useState, useCallback } from "react";
import React, { useEffect, useState, useCallback, useMemo } from "react";
import { ComponentRendererProps } from "@/types/component";
import { ApprovalStepConfig } from "./types";
import {
ApprovalStepConfig,
ExtendedApprovalLine,
ExtendedApprovalRequest,
StepGroup,
} from "./types";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import {
getApprovalRequests,
getApprovalRequest,
type ApprovalRequest,
type ApprovalLine,
} from "@/lib/api/approval";
import {
Check,
@@ -20,14 +23,18 @@ import {
ChevronDown,
ChevronUp,
ArrowRight,
CheckCircle,
Users,
Bell,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export interface ApprovalStepComponentProps extends ComponentRendererProps {}
interface ApprovalStepData {
request: ApprovalRequest;
lines: ApprovalLine[];
request: ExtendedApprovalRequest;
lines: ExtendedApprovalLine[];
approvalMode: "sequential" | "parallel";
}
@@ -87,6 +94,75 @@ const REQUEST_STATUS_CONFIG = {
cancelled: { label: "취소", color: "text-muted-foreground", bg: "bg-muted" },
} as const;
/** 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;
}
/**
* 결재 단계 시각화 컴포넌트
* 결재 요청의 각 단계별 상태를 스테퍼 형태로 표시
@@ -139,8 +215,8 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
const detailRes = await getApprovalRequest(latestRequest.request_id);
if (detailRes.success && detailRes.data) {
const request = detailRes.data;
const lines = request.lines || [];
const request = detailRes.data as ExtendedApprovalRequest;
const lines = (request.lines || []) as ExtendedApprovalLine[];
const approvalMode =
(request.target_record_data?.approval_mode as "sequential" | "parallel") || "sequential";
@@ -160,7 +236,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
fetchApprovalData();
}, [fetchApprovalData]);
// 디자인 모드용 샘플 데이터
// 디자인 모드용 샘플 데이터 (합의/대결/통보 포함)
useEffect(() => {
if (isDesignMode) {
setStepData({
@@ -171,13 +247,15 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
target_record_id: "1",
status: "in_progress",
current_step: 2,
total_steps: 3,
total_steps: 4,
requester_id: "admin",
requester_name: "홍길동",
requester_dept: "개발팀",
company_code: "SAMPLE",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
approval_type: "escalation",
urgency: "urgent",
},
lines: [
{
@@ -186,18 +264,37 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
status: "approved", comment: "확인했습니다.",
processed_at: new Date(Date.now() - 86400000).toISOString(),
company_code: "SAMPLE", created_at: new Date().toISOString(),
step_type: "approval",
},
{
line_id: 2, request_id: 0, step_order: 2,
approver_id: "user2", approver_name: "이과장", approver_position: "과장", approver_dept: "기획팀",
status: "pending",
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: 3,
approver_id: "user3", approver_name: "대리", approver_position: "대리", approver_dept: "개발팀",
line_id: 3, request_id: 0, step_order: 2,
approver_id: "user3", approver_name: "대리", approver_position: "대리", approver_dept: "기획팀",
status: "pending",
company_code: "SAMPLE", created_at: new Date().toISOString(),
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: "정팀장",
},
{
line_id: 5, request_id: 0, step_order: 4,
approver_id: "user5", approver_name: "한사원", approver_position: "사원", approver_dept: "총무팀",
status: "waiting",
company_code: "SAMPLE", created_at: new Date().toISOString(),
step_type: "notification",
},
],
approvalMode: "sequential",
@@ -286,6 +383,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
const { request, lines, approvalMode } = stepData;
const reqStatus = REQUEST_STATUS_CONFIG[request.status] || REQUEST_STATUS_CONFIG.requested;
const urgency = request.urgency;
return (
<div style={componentStyle} onClick={handleClick} {...domProps}>
@@ -293,7 +391,10 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
{/* 헤더 - 요약 */}
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50"
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",
)}
onClick={(e) => {
e.stopPropagation();
if (!isDesignMode) setExpanded((prev) => !prev);
@@ -302,9 +403,26 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
<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>
{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" />
)}
<span className={cn("rounded-full px-2 py-0.5 text-[10px] font-medium", reqStatus.bg, reqStatus.color)}>
{reqStatus.label}
</span>
{/* 결재 유형 뱃지 */}
{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>
)}
{approvalMode === "parallel" && (
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-medium text-blue-600">
@@ -324,6 +442,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
{displayMode === "horizontal" ? (
<HorizontalStepper
lines={lines}
request={request}
approvalMode={approvalMode}
compact={compact}
showDept={showDept}
@@ -331,6 +450,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
) : (
<VerticalStepper
lines={lines}
request={request}
approvalMode={approvalMode}
compact={compact}
showDept={showDept}
@@ -349,16 +469,35 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
<span>: {request.requester_name || request.requester_id}</span>
{request.requester_dept && <span>: {request.requester_dept}</span>}
<span>: {formatDate(request.created_at)}</span>
{request.approval_type && request.approval_type !== "escalation" && (
<span>
: {request.approval_type === "self" ? "전결" : request.approval_type === "post" ? "후결" : request.approval_type === "consensus" ? "합의" : request.approval_type}
</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;
const lineBadges = getLineBadges(line, request);
const StepIcon = STEP_TYPE_ICON[line.step_type || "approval"];
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)} />
<StepIcon className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
<span className="font-medium">{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("font-medium", sc.textColor)}>{sc.label}</span>
{line.step_type === "notification" && (
<span className="text-muted-foreground">( )</span>
)}
{line.proxy_for && (
<span className="text-orange-600">({line.proxy_for} )</span>
)}
{showTimestamp && line.processed_at && (
<span className="text-muted-foreground">{formatDate(line.processed_at)}</span>
)}
@@ -378,9 +517,10 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
);
};
/* ========== 가로형 스테퍼 ========== */
/* ========== 공통 Props ========== */
interface StepperProps {
lines: ApprovalLine[];
lines: ExtendedApprovalLine[];
request: ExtendedApprovalRequest;
approvalMode: "sequential" | "parallel";
compact: boolean;
showDept: boolean;
@@ -389,43 +529,124 @@ interface StepperProps {
formatDate?: (d?: string | null) => string;
}
const HorizontalStepper: React.FC<StepperProps> = ({ lines, approvalMode, compact, showDept }) => {
/* ========== 결재자 카드 (가로형/세로형 공용) ========== */
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]);
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;
{stepGroups.map((group, groupIdx) => {
const StepTypeIcon = STEP_TYPE_ICON[group.stepType];
const isLast = groupIdx === stepGroups.length - 1;
const isConsensus = group.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"
<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>
)}
>
<StatusIcon className={cn(sc.iconColor, compact ? "h-3 w-3" : "h-4 w-4")} />
{/* 같은 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>
</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>
) : (
<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>
)}
{/* 연결선 */}
{!isLast && (
@@ -447,6 +668,7 @@ const HorizontalStepper: React.FC<StepperProps> = ({ lines, approvalMode, compac
/* ========== 세로형 스테퍼 ========== */
const VerticalStepper: React.FC<StepperProps> = ({
lines,
request,
approvalMode,
compact,
showDept,
@@ -454,30 +676,29 @@ const VerticalStepper: React.FC<StepperProps> = ({
showTimestamp,
formatDate,
}) => {
const stepGroups = useMemo(() => groupLinesByStepOrder(lines), [lines]);
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;
{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";
return (
<div key={line.line_id} className="flex gap-3">
<div key={group.stepOrder} 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 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",
)} />
</div>
{!isLast && (
<div
@@ -493,29 +714,112 @@ const VerticalStepper: React.FC<StepperProps> = ({
</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 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">&quot;{line.comment}&quot;</div>
)}
</div>
</div>
);
})}
</div>
</div>
) : (
(() => {
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">
&quot;{line.comment}&quot;
</div>
)}
</>
);
})()
)}
</div>
</div>