[agent-pipeline] pipe-20260305133525-uca5 round-4
This commit is contained in:
@@ -9,20 +9,40 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, X, Loader2, Search, GripVertical, Users, ArrowDown, Layers } from "lucide-react";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, X, Loader2, Search, GripVertical, Users, ArrowDown, Layers, FileText } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { createApprovalRequest } from "@/lib/api/approval";
|
||||
import {
|
||||
createApprovalRequest,
|
||||
getApprovalTemplates,
|
||||
getTemplateSteps,
|
||||
type ApprovalLineTemplate,
|
||||
} from "@/lib/api/approval";
|
||||
import { getUserList } from "@/lib/api/user";
|
||||
|
||||
// 결재 방식
|
||||
type ApprovalMode = "sequential" | "parallel";
|
||||
|
||||
// 결재 유형
|
||||
type ApprovalType = "self" | "escalation" | "consensus" | "post";
|
||||
|
||||
// step_type 라벨 매핑
|
||||
const STEP_TYPE_LABEL: Record<string, { label: string; variant: "default" | "secondary" | "outline" }> = {
|
||||
approval: { label: "결재", variant: "default" },
|
||||
consensus: { label: "합의", variant: "secondary" },
|
||||
notification: { label: "통보", variant: "outline" },
|
||||
};
|
||||
|
||||
interface ApproverRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
position_name: string;
|
||||
dept_name: string;
|
||||
step_type?: "approval" | "consensus" | "notification";
|
||||
step_order?: number;
|
||||
}
|
||||
|
||||
export interface ApprovalModalEventDetail {
|
||||
@@ -69,6 +89,15 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 결재 유형 상태
|
||||
const [approvalType, setApprovalType] = useState<ApprovalType>("escalation");
|
||||
|
||||
// 템플릿 상태
|
||||
const [templates, setTemplates] = useState<ApprovalLineTemplate[]>([]);
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(null);
|
||||
const [showTemplatePopover, setShowTemplatePopover] = useState(false);
|
||||
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
|
||||
|
||||
// 사용자 검색 상태
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -83,14 +112,74 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setApprovalMode("sequential");
|
||||
setApprovalType("escalation");
|
||||
setApprovers([]);
|
||||
setError(null);
|
||||
setSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
setSelectedTemplateId(null);
|
||||
setShowTemplatePopover(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 모달 열릴 때 템플릿 목록 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadTemplates();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
setIsLoadingTemplates(true);
|
||||
try {
|
||||
const res = await getApprovalTemplates({ is_active: "Y" });
|
||||
if (res.success && res.data) {
|
||||
setTemplates(res.data);
|
||||
}
|
||||
} catch {
|
||||
// 템플릿 로드 실패는 무시 (선택사항이므로)
|
||||
} finally {
|
||||
setIsLoadingTemplates(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 템플릿 선택 시 결재자 리스트에 반영
|
||||
const applyTemplate = async (templateId: number) => {
|
||||
try {
|
||||
const res = await getTemplateSteps(templateId);
|
||||
if (!res.success || !res.data || res.data.length === 0) {
|
||||
toast.error("템플릿에 설정된 결재 단계가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const steps = res.data;
|
||||
const hasMultipleTypes = new Set(steps.map((s) => s.step_type || "approval")).size > 1;
|
||||
|
||||
// step_type이 혼합이면 escalation으로 설정
|
||||
if (hasMultipleTypes) {
|
||||
setApprovalType("escalation");
|
||||
}
|
||||
|
||||
const newApprovers: ApproverRow[] = steps.map((step) => ({
|
||||
id: genId(),
|
||||
user_id: step.approver_user_id || "",
|
||||
user_name: step.approver_label || step.approver_user_id || "",
|
||||
position_name: step.approver_position || "",
|
||||
dept_name: step.approver_dept_code || "",
|
||||
step_type: step.step_type || "approval",
|
||||
step_order: step.step_order,
|
||||
}));
|
||||
|
||||
setApprovers(newApprovers);
|
||||
setSelectedTemplateId(templateId);
|
||||
setShowTemplatePopover(false);
|
||||
toast.success("템플릿이 적용되었습니다.");
|
||||
} catch {
|
||||
toast.error("템플릿 적용 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 검색 (디바운스)
|
||||
const searchUsers = useCallback(async (query: string) => {
|
||||
if (!query.trim() || query.trim().length < 1) {
|
||||
@@ -169,7 +258,8 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
setError("결재 제목을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (approvers.length === 0) {
|
||||
// 자기결재가 아닌 경우 결재자 필수
|
||||
if (approvalType !== "self" && approvers.length === 0) {
|
||||
setError("결재자를 1명 이상 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
@@ -181,25 +271,34 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
// 혼합형 여부: approvers에 step_type이 설정된 경우
|
||||
const hasMixedStepTypes = approvers.some((a) => a.step_type);
|
||||
|
||||
const res = await createApprovalRequest({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
target_table: eventDetail.targetTable,
|
||||
target_record_id: eventDetail.targetRecordId || undefined,
|
||||
target_record_data: eventDetail.targetRecordData,
|
||||
approval_mode: approvalMode,
|
||||
approval_mode: approvalType === "consensus" ? "parallel" : approvalMode,
|
||||
approval_type: approvalType,
|
||||
screen_id: eventDetail.screenId,
|
||||
button_component_id: eventDetail.buttonComponentId,
|
||||
approvers: approvers.map((a, idx) => ({
|
||||
approver_id: a.user_id,
|
||||
approver_name: a.user_name,
|
||||
approver_position: a.position_name || undefined,
|
||||
approver_dept: a.dept_name || undefined,
|
||||
approver_label:
|
||||
approvalMode === "sequential"
|
||||
? `${idx + 1}차 결재`
|
||||
: "동시 결재",
|
||||
})),
|
||||
approvers: approvalType === "self"
|
||||
? []
|
||||
: approvers.map((a, idx) => ({
|
||||
approver_id: a.user_id,
|
||||
approver_name: a.user_name,
|
||||
approver_position: a.position_name || undefined,
|
||||
approver_dept: a.dept_name || undefined,
|
||||
step_type: hasMixedStepTypes ? (a.step_type || "approval") : undefined,
|
||||
step_order: hasMixedStepTypes ? (a.step_order ?? idx + 1) : undefined,
|
||||
approver_label: hasMixedStepTypes
|
||||
? STEP_TYPE_LABEL[a.step_type || "approval"]?.label
|
||||
: approvalMode === "sequential"
|
||||
? `${idx + 1}차 결재`
|
||||
: "동시 결재",
|
||||
})),
|
||||
});
|
||||
|
||||
setIsSubmitting(false);
|
||||
@@ -251,198 +350,307 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 결재 방식 */}
|
||||
{/* 결재 유형 + 템플릿 불러오기 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">결재 방식</Label>
|
||||
<div className="mt-1.5 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApprovalMode("sequential")}
|
||||
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
|
||||
approvalMode === "sequential"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium sm:text-sm">다단 결재</p>
|
||||
<p className="text-muted-foreground text-[10px]">순차적으로 결재</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApprovalMode("parallel")}
|
||||
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
|
||||
approvalMode === "parallel"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Layers className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium sm:text-sm">동시 결재</p>
|
||||
<p className="text-muted-foreground text-[10px]">모든 결재자 동시 진행</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결재자 추가 (사용자 검색) */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">
|
||||
결재자 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{approvers.length}명 선택됨
|
||||
</span>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs sm:text-sm">결재 유형</Label>
|
||||
<Select
|
||||
value={approvalType}
|
||||
onValueChange={(v) => setApprovalType(v as ApprovalType)}
|
||||
>
|
||||
<SelectTrigger size="default" className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="escalation">상신결재</SelectItem>
|
||||
<SelectItem value="self">자기결재 (전결)</SelectItem>
|
||||
<SelectItem value="consensus">합의결재</SelectItem>
|
||||
<SelectItem value="post">후결</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{templates.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={() => setShowTemplatePopover(!showTemplatePopover)}
|
||||
>
|
||||
<FileText className="mr-1 h-3.5 w-3.5" />
|
||||
템플릿
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSearchOpen(true);
|
||||
}}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
placeholder="이름 또는 사번으로 검색..."
|
||||
className="h-8 pl-9 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
|
||||
{/* 검색 결과 드롭다운 */}
|
||||
{searchOpen && searchQuery.trim() && (
|
||||
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
{/* 템플릿 선택 드롭다운 */}
|
||||
{showTemplatePopover && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowTemplatePopover(false)}
|
||||
/>
|
||||
<div className="relative z-50 mt-2 rounded-md border bg-popover p-2 shadow-lg">
|
||||
<p className="text-muted-foreground mb-2 text-[10px] font-medium">결재선 템플릿 선택</p>
|
||||
{isLoadingTemplates ? (
|
||||
<div className="flex items-center justify-center p-3">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-xs">검색 중...</span>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-muted-foreground text-xs">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="text-muted-foreground p-3 text-center text-xs">등록된 템플릿이 없습니다.</p>
|
||||
) : (
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{searchResults.map((user) => (
|
||||
<div className="max-h-40 space-y-1 overflow-y-auto">
|
||||
{templates.map((tpl) => (
|
||||
<button
|
||||
key={user.userId}
|
||||
key={tpl.template_id}
|
||||
type="button"
|
||||
onClick={() => addApprover(user)}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-accent"
|
||||
onClick={() => applyTemplate(tpl.template_id)}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-xs transition-colors hover:bg-accent ${
|
||||
selectedTemplateId === tpl.template_id ? "bg-accent" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
|
||||
<Users className="h-4 w-4" />
|
||||
</div>
|
||||
<FileText className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium sm:text-sm">
|
||||
{user.userName}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
({user.userId})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-[10px]">
|
||||
{[user.deptName, user.positionName].filter(Boolean).join(" / ") || "-"}
|
||||
</p>
|
||||
<p className="truncate font-medium">{tpl.template_name}</p>
|
||||
{tpl.description && (
|
||||
<p className="text-muted-foreground truncate text-[10px]">{tpl.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 클릭 외부 영역 닫기 */}
|
||||
{searchOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setSearchOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 선택된 결재자 목록 */}
|
||||
{approvers.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-3 rounded-md border border-dashed p-4 text-center text-xs">
|
||||
위 검색창에서 결재자를 검색하여 추가하세요
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{approvers.map((approver, idx) => (
|
||||
<div
|
||||
key={approver.id}
|
||||
className="bg-muted/30 flex items-center gap-2 rounded-md border p-2"
|
||||
>
|
||||
{/* 순서 표시 */}
|
||||
{approvalMode === "sequential" ? (
|
||||
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveApprover(idx, "up")}
|
||||
disabled={idx === 0}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 rotate-90" />
|
||||
</button>
|
||||
<Badge variant="outline" className="h-5 min-w-[24px] justify-center px-1 text-[10px]">
|
||||
{idx + 1}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveApprover(idx, "down")}
|
||||
disabled={idx === approvers.length - 1}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="secondary" className="h-5 shrink-0 px-1.5 text-[10px]">
|
||||
동시
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{approver.user_name}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
({approver.user_id})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-[10px]">
|
||||
{[approver.dept_name, approver.position_name].filter(Boolean).join(" / ") || "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 제거 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => removeApprover(approver.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 결재 흐름 시각화 */}
|
||||
{approvalMode === "sequential" && approvers.length > 1 && (
|
||||
<p className="text-muted-foreground text-center text-[10px]">
|
||||
{approvers.map((a) => a.user_name).join(" → ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 후결 안내 배너 */}
|
||||
{approvalType === "post" && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
|
||||
먼저 처리 후 나중에 결재받습니다. 결재 반려 시 별도 조치가 필요할 수 있습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 자기결재: 결재자 섹션 대신 안내 메시지 */}
|
||||
{approvalType === "self" ? (
|
||||
<div className="bg-muted text-muted-foreground rounded-md p-4 text-sm">
|
||||
본인이 직접 승인합니다.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 결재 방식 (escalation, post에서만 표시. consensus는 순서 무관) */}
|
||||
{approvalType !== "consensus" && (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">결재 방식</Label>
|
||||
<div className="mt-1.5 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApprovalMode("sequential")}
|
||||
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
|
||||
approvalMode === "sequential"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium sm:text-sm">다단 결재</p>
|
||||
<p className="text-muted-foreground text-[10px]">순차적으로 결재</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApprovalMode("parallel")}
|
||||
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
|
||||
approvalMode === "parallel"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Layers className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium sm:text-sm">동시 결재</p>
|
||||
<p className="text-muted-foreground text-[10px]">모든 결재자 동시 진행</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결재자 추가 (사용자 검색) */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">
|
||||
{approvalType === "consensus" ? "합의 결재자" : "결재자"}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{approvers.length}명 선택됨
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSearchOpen(true);
|
||||
}}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
placeholder="이름 또는 사번으로 검색..."
|
||||
className="h-8 pl-9 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
|
||||
{/* 검색 결과 드롭다운 */}
|
||||
{searchOpen && searchQuery.trim() && (
|
||||
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-xs">검색 중...</span>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-muted-foreground text-xs">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{searchResults.map((user) => (
|
||||
<button
|
||||
key={user.userId}
|
||||
type="button"
|
||||
onClick={() => addApprover(user)}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
|
||||
<Users className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium sm:text-sm">
|
||||
{user.userName}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
({user.userId})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-[10px]">
|
||||
{[user.deptName, user.positionName].filter(Boolean).join(" / ") || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 클릭 외부 영역 닫기 */}
|
||||
{searchOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setSearchOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 선택된 결재자 목록 */}
|
||||
{approvers.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-3 rounded-md border border-dashed p-4 text-center text-xs">
|
||||
위 검색창에서 결재자를 검색하여 추가하세요
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{approvers.map((approver, idx) => (
|
||||
<div
|
||||
key={approver.id}
|
||||
className="bg-muted/30 flex items-center gap-2 rounded-md border p-2"
|
||||
>
|
||||
{/* 순서 표시: consensus에서는 drag 숨김 */}
|
||||
{approvalType === "consensus" ? (
|
||||
<Badge variant="secondary" className="h-5 shrink-0 px-1.5 text-[10px]">
|
||||
합의
|
||||
</Badge>
|
||||
) : approvalMode === "sequential" ? (
|
||||
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveApprover(idx, "up")}
|
||||
disabled={idx === 0}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 rotate-90" />
|
||||
</button>
|
||||
<Badge variant="outline" className="h-5 min-w-[24px] justify-center px-1 text-[10px]">
|
||||
{idx + 1}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveApprover(idx, "down")}
|
||||
disabled={idx === approvers.length - 1}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="secondary" className="h-5 shrink-0 px-1.5 text-[10px]">
|
||||
동시
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* step_type 뱃지 (템플릿에서 불러온 혼합형일 때) */}
|
||||
{approver.step_type && (
|
||||
<Badge
|
||||
variant={STEP_TYPE_LABEL[approver.step_type]?.variant || "outline"}
|
||||
className="h-5 shrink-0 px-1.5 text-[10px]"
|
||||
>
|
||||
{STEP_TYPE_LABEL[approver.step_type]?.label || approver.step_type}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{approver.user_name}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
({approver.user_id})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-[10px]">
|
||||
{[approver.dept_name, approver.position_name].filter(Boolean).join(" / ") || "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 제거 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => removeApprover(approver.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 결재 흐름 시각화 (consensus가 아니고 sequential일 때만) */}
|
||||
{approvalType !== "consensus" && approvalMode === "sequential" && approvers.length > 1 && (
|
||||
<p className="text-muted-foreground text-center text-[10px]">
|
||||
{approvers.map((a) => a.user_name).join(" → ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 rounded-md p-2">
|
||||
@@ -462,7 +670,7 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || approvers.length === 0}
|
||||
disabled={isSubmitting || (approvalType !== "self" && approvers.length === 0)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
@@ -470,6 +678,8 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
요청 중...
|
||||
</>
|
||||
) : approvalType === "self" ? (
|
||||
"전결 처리"
|
||||
) : (
|
||||
`결재 상신 (${approvers.length}명)`
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user