ERP-node/frontend/components/approval/ApprovalRequestModal.tsx

421 lines
15 KiB
TypeScript

"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) => {
setSelectedDefinitionId(v === "none" ? "" : v);
setSelectedTemplateId("");
setApprovers([]);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder={isLoadingDefs ? "로딩 중..." : "결재 유형 선택 (선택사항)"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{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}
onValueChange={(v) => setSelectedTemplateId(v === "none" ? "" : v)}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder={isLoadingTemplates ? "로딩 중..." : "템플릿 선택 (선택사항)"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{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;