2026-03-03 22:00:52 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
|
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
import { Plus, X, Loader2 } from "lucide-react";
|
|
|
|
|
import {
|
|
|
|
|
getApprovalDefinitions,
|
|
|
|
|
getApprovalTemplates,
|
|
|
|
|
createApprovalRequest,
|
|
|
|
|
type ApprovalDefinition,
|
|
|
|
|
type ApprovalLineTemplate,
|
|
|
|
|
} from "@/lib/api/approval";
|
|
|
|
|
|
|
|
|
|
// 결재자 행 타입
|
|
|
|
|
interface ApproverRow {
|
|
|
|
|
id: string; // 로컬 임시 ID
|
|
|
|
|
approver_id: string;
|
|
|
|
|
approver_name: string;
|
|
|
|
|
approver_position: string;
|
|
|
|
|
approver_dept: string;
|
|
|
|
|
approver_label: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 모달 열기 이벤트로 전달되는 데이터
|
|
|
|
|
export interface ApprovalModalEventDetail {
|
|
|
|
|
targetTable: string;
|
|
|
|
|
targetRecordId: string;
|
|
|
|
|
targetRecordData?: Record<string, any>;
|
|
|
|
|
definitionId?: number;
|
|
|
|
|
screenId?: number;
|
|
|
|
|
buttonComponentId?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ApprovalRequestModalProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
eventDetail?: ApprovalModalEventDetail | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function generateLocalId(): string {
|
|
|
|
|
return `row_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
eventDetail,
|
|
|
|
|
}) => {
|
|
|
|
|
const [title, setTitle] = useState("");
|
|
|
|
|
const [description, setDescription] = useState("");
|
|
|
|
|
const [selectedDefinitionId, setSelectedDefinitionId] = useState<string>("");
|
|
|
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
|
|
|
|
const [approvers, setApprovers] = useState<ApproverRow[]>([]);
|
|
|
|
|
const [definitions, setDefinitions] = useState<ApprovalDefinition[]>([]);
|
|
|
|
|
const [templates, setTemplates] = useState<ApprovalLineTemplate[]>([]);
|
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
|
const [isLoadingDefs, setIsLoadingDefs] = useState(false);
|
|
|
|
|
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
// 결재 유형 목록 로딩
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) return;
|
|
|
|
|
const load = async () => {
|
|
|
|
|
setIsLoadingDefs(true);
|
|
|
|
|
const res = await getApprovalDefinitions({ is_active: "Y" });
|
|
|
|
|
if (res.success && res.data) setDefinitions(res.data);
|
|
|
|
|
setIsLoadingDefs(false);
|
|
|
|
|
};
|
|
|
|
|
load();
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
// 결재 유형 변경 시 템플릿 목록 로딩
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!selectedDefinitionId) {
|
|
|
|
|
setTemplates([]);
|
|
|
|
|
setSelectedTemplateId("");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const load = async () => {
|
|
|
|
|
setIsLoadingTemplates(true);
|
|
|
|
|
const res = await getApprovalTemplates({
|
|
|
|
|
definition_id: Number(selectedDefinitionId),
|
|
|
|
|
is_active: "Y",
|
|
|
|
|
});
|
|
|
|
|
if (res.success && res.data) setTemplates(res.data);
|
|
|
|
|
setIsLoadingTemplates(false);
|
|
|
|
|
};
|
|
|
|
|
load();
|
|
|
|
|
}, [selectedDefinitionId]);
|
|
|
|
|
|
|
|
|
|
// 템플릿 선택 시 결재선 자동 세팅
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!selectedTemplateId) return;
|
|
|
|
|
const template = templates.find((t) => String(t.template_id) === selectedTemplateId);
|
|
|
|
|
if (!template?.steps) return;
|
|
|
|
|
|
|
|
|
|
const rows: ApproverRow[] = template.steps
|
|
|
|
|
.sort((a, b) => a.step_order - b.step_order)
|
|
|
|
|
.map((step) => ({
|
|
|
|
|
id: generateLocalId(),
|
|
|
|
|
approver_id: step.approver_user_id || "",
|
|
|
|
|
approver_name: step.approver_label || "",
|
|
|
|
|
approver_position: step.approver_position || "",
|
|
|
|
|
approver_dept: step.approver_dept_code || "",
|
|
|
|
|
approver_label: step.approver_label || `${step.step_order}차 결재`,
|
|
|
|
|
}));
|
|
|
|
|
setApprovers(rows);
|
|
|
|
|
}, [selectedTemplateId, templates]);
|
|
|
|
|
|
|
|
|
|
// eventDetail에서 definitionId 자동 세팅
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (open && eventDetail?.definitionId) {
|
|
|
|
|
setSelectedDefinitionId(String(eventDetail.definitionId));
|
|
|
|
|
}
|
|
|
|
|
}, [open, eventDetail]);
|
|
|
|
|
|
|
|
|
|
// 모달 닫힐 때 초기화
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) {
|
|
|
|
|
setTitle("");
|
|
|
|
|
setDescription("");
|
|
|
|
|
setSelectedDefinitionId("");
|
|
|
|
|
setSelectedTemplateId("");
|
|
|
|
|
setApprovers([]);
|
|
|
|
|
setError(null);
|
|
|
|
|
}
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
const handleAddApprover = () => {
|
|
|
|
|
setApprovers((prev) => [
|
|
|
|
|
...prev,
|
|
|
|
|
{
|
|
|
|
|
id: generateLocalId(),
|
|
|
|
|
approver_id: "",
|
|
|
|
|
approver_name: "",
|
|
|
|
|
approver_position: "",
|
|
|
|
|
approver_dept: "",
|
|
|
|
|
approver_label: `${prev.length + 1}차 결재`,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRemoveApprover = (id: string) => {
|
|
|
|
|
setApprovers((prev) => prev.filter((a) => a.id !== id));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleApproverChange = (id: string, field: keyof ApproverRow, value: string) => {
|
|
|
|
|
setApprovers((prev) =>
|
|
|
|
|
prev.map((a) => (a.id === id ? { ...a, [field]: value } : a))
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async () => {
|
|
|
|
|
if (!title.trim()) {
|
|
|
|
|
setError("결재 제목을 입력해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (approvers.length === 0) {
|
|
|
|
|
setError("결재자를 1명 이상 추가해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const emptyApprover = approvers.find((a) => !a.approver_id.trim());
|
|
|
|
|
if (emptyApprover) {
|
|
|
|
|
setError("모든 결재자의 사번/ID를 입력해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!eventDetail?.targetTable || !eventDetail?.targetRecordId) {
|
|
|
|
|
setError("결재 대상 정보가 없습니다. 버튼 설정을 확인해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsSubmitting(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
|
|
|
|
|
const res = await createApprovalRequest({
|
|
|
|
|
title: title.trim(),
|
|
|
|
|
description: description.trim() || undefined,
|
|
|
|
|
definition_id: selectedDefinitionId ? Number(selectedDefinitionId) : undefined,
|
|
|
|
|
target_table: eventDetail.targetTable,
|
|
|
|
|
target_record_id: eventDetail.targetRecordId,
|
|
|
|
|
target_record_data: eventDetail.targetRecordData,
|
|
|
|
|
screen_id: eventDetail.screenId,
|
|
|
|
|
button_component_id: eventDetail.buttonComponentId,
|
|
|
|
|
approvers: approvers.map((a) => ({
|
|
|
|
|
approver_id: a.approver_id.trim(),
|
|
|
|
|
approver_name: a.approver_name.trim() || undefined,
|
|
|
|
|
approver_position: a.approver_position.trim() || undefined,
|
|
|
|
|
approver_dept: a.approver_dept.trim() || undefined,
|
|
|
|
|
approver_label: a.approver_label.trim() || undefined,
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setIsSubmitting(false);
|
|
|
|
|
|
|
|
|
|
if (res.success) {
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
} else {
|
|
|
|
|
setError(res.error || res.message || "결재 요청에 실패했습니다.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="text-base sm:text-lg">결재 요청</DialogTitle>
|
|
|
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
|
|
|
결재자를 지정하고 결재를 요청합니다.
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3 sm:space-y-4">
|
|
|
|
|
{/* 결재 제목 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="approval-title" className="text-xs sm:text-sm">
|
|
|
|
|
결재 제목 <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="approval-title"
|
|
|
|
|
value={title}
|
|
|
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
|
|
|
placeholder="결재 제목을 입력하세요"
|
|
|
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 결재 사유 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="approval-description" className="text-xs sm:text-sm">
|
|
|
|
|
결재 사유
|
|
|
|
|
</Label>
|
|
|
|
|
<Textarea
|
|
|
|
|
id="approval-description"
|
|
|
|
|
value={description}
|
|
|
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
|
|
|
placeholder="결재 사유를 입력하세요 (선택사항)"
|
|
|
|
|
className="min-h-[60px] text-xs sm:text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 결재 유형 선택 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs sm:text-sm">결재 유형</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={selectedDefinitionId}
|
|
|
|
|
onValueChange={(v) => {
|
2026-03-04 01:13:33 +09:00
|
|
|
setSelectedDefinitionId(v === "none" ? "" : v);
|
2026-03-03 22:00:52 +09:00
|
|
|
setSelectedTemplateId("");
|
|
|
|
|
setApprovers([]);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
|
|
|
<SelectValue placeholder={isLoadingDefs ? "로딩 중..." : "결재 유형 선택 (선택사항)"} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2026-03-04 01:13:33 +09:00
|
|
|
<SelectItem value="none">유형 없음</SelectItem>
|
2026-03-03 22:00:52 +09:00
|
|
|
{definitions.map((def) => (
|
|
|
|
|
<SelectItem key={def.definition_id} value={String(def.definition_id)}>
|
|
|
|
|
{def.definition_name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 결재선 템플릿 선택 */}
|
|
|
|
|
{selectedDefinitionId && (
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs sm:text-sm">결재선 템플릿</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={selectedTemplateId}
|
2026-03-04 01:13:33 +09:00
|
|
|
onValueChange={(v) => setSelectedTemplateId(v === "none" ? "" : v)}
|
2026-03-03 22:00:52 +09:00
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
|
|
|
<SelectValue placeholder={isLoadingTemplates ? "로딩 중..." : "템플릿 선택 (선택사항)"} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2026-03-04 01:13:33 +09:00
|
|
|
<SelectItem value="none">직접 입력</SelectItem>
|
2026-03-03 22:00:52 +09:00
|
|
|
{templates.map((tmpl) => (
|
|
|
|
|
<SelectItem key={tmpl.template_id} value={String(tmpl.template_id)}>
|
|
|
|
|
{tmpl.template_name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
|
|
|
|
템플릿 선택 시 결재선이 자동으로 채워집니다
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 결재선 */}
|
|
|
|
|
<div>
|
|
|
|
|
<div className="mb-2 flex items-center justify-between">
|
|
|
|
|
<Label className="text-xs sm:text-sm">
|
|
|
|
|
결재선 <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
onClick={handleAddApprover}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
|
|
|
결재자 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{approvers.length === 0 && (
|
|
|
|
|
<p className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-xs">
|
|
|
|
|
결재자를 추가하거나 템플릿을 선택하세요
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{approvers.map((approver, idx) => (
|
|
|
|
|
<div key={approver.id} className="rounded-md border p-2 sm:p-3">
|
|
|
|
|
<div className="mb-2 flex items-center justify-between">
|
|
|
|
|
<span className="text-muted-foreground text-[10px] sm:text-xs">
|
|
|
|
|
{idx + 1}차 결재
|
|
|
|
|
</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-6 w-6 p-0"
|
|
|
|
|
onClick={() => handleRemoveApprover(approver.id)}
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px] sm:text-xs">
|
|
|
|
|
사번/ID <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={approver.approver_id}
|
|
|
|
|
onChange={(e) => handleApproverChange(approver.id, "approver_id", e.target.value)}
|
|
|
|
|
placeholder="사번 또는 ID"
|
|
|
|
|
className="h-7 text-[10px] sm:h-8 sm:text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px] sm:text-xs">이름</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={approver.approver_name}
|
|
|
|
|
onChange={(e) => handleApproverChange(approver.id, "approver_name", e.target.value)}
|
|
|
|
|
placeholder="결재자 이름"
|
|
|
|
|
className="h-7 text-[10px] sm:h-8 sm:text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px] sm:text-xs">직급</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={approver.approver_position}
|
|
|
|
|
onChange={(e) => handleApproverChange(approver.id, "approver_position", e.target.value)}
|
|
|
|
|
placeholder="직급"
|
|
|
|
|
className="h-7 text-[10px] sm:h-8 sm:text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px] sm:text-xs">라벨</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={approver.approver_label}
|
|
|
|
|
onChange={(e) => handleApproverChange(approver.id, "approver_label", e.target.value)}
|
|
|
|
|
placeholder="예: 팀장, 최종 결재"
|
|
|
|
|
className="h-7 text-[10px] sm:h-8 sm:text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 에러 메시지 */}
|
|
|
|
|
{error && (
|
|
|
|
|
<p className="text-destructive text-xs">{error}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
disabled={isSubmitting}
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleSubmit}
|
|
|
|
|
disabled={isSubmitting}
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
{isSubmitting ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
요청 중...
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
"결재 요청"
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default ApprovalRequestModal;
|