1041 lines
37 KiB
TypeScript
1041 lines
37 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Plus,
|
|
Edit,
|
|
Trash2,
|
|
Search,
|
|
Loader2,
|
|
UserPlus,
|
|
GripVertical,
|
|
} from "lucide-react";
|
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
import {
|
|
type ApprovalDefinition,
|
|
type ApprovalLineTemplate,
|
|
type ApprovalLineTemplateStep,
|
|
getApprovalDefinitions,
|
|
getApprovalTemplates,
|
|
getApprovalTemplate,
|
|
createApprovalTemplate,
|
|
updateApprovalTemplate,
|
|
deleteApprovalTemplate,
|
|
} from "@/lib/api/approval";
|
|
import { getUserList } from "@/lib/api/user";
|
|
|
|
// ============================================================
|
|
// 타입 정의
|
|
// ============================================================
|
|
|
|
type StepType = "approval" | "consensus" | "notification";
|
|
|
|
interface StepApprover {
|
|
approver_type: "user" | "position" | "dept";
|
|
approver_user_id?: string;
|
|
approver_position?: string;
|
|
approver_dept_code?: string;
|
|
approver_label?: string;
|
|
}
|
|
|
|
interface StepFormData {
|
|
step_order: number;
|
|
step_type: StepType;
|
|
approvers: StepApprover[];
|
|
}
|
|
|
|
interface TemplateFormData {
|
|
template_name: string;
|
|
description: string;
|
|
definition_id: number | null;
|
|
steps: StepFormData[];
|
|
}
|
|
|
|
const STEP_TYPE_OPTIONS: { value: StepType; label: string }[] = [
|
|
{ value: "approval", label: "결재" },
|
|
{ value: "consensus", label: "합의" },
|
|
{ value: "notification", label: "통보" },
|
|
];
|
|
|
|
const STEP_TYPE_BADGE: Record<StepType, { label: string; variant: "default" | "secondary" | "outline" }> = {
|
|
approval: { label: "결재", variant: "default" },
|
|
consensus: { label: "합의", variant: "secondary" },
|
|
notification: { label: "통보", variant: "outline" },
|
|
};
|
|
|
|
const INITIAL_FORM: TemplateFormData = {
|
|
template_name: "",
|
|
description: "",
|
|
definition_id: null,
|
|
steps: [
|
|
{
|
|
step_order: 1,
|
|
step_type: "approval",
|
|
approvers: [{ approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
// ============================================================
|
|
// 사용자 검색 컴포넌트
|
|
// ============================================================
|
|
|
|
function UserSearchInput({
|
|
value,
|
|
label,
|
|
onSelect,
|
|
onLabelChange,
|
|
}: {
|
|
value: string;
|
|
label: string;
|
|
onSelect: (userId: string, userName: string) => void;
|
|
onLabelChange: (label: string) => void;
|
|
}) {
|
|
const [searchText, setSearchText] = useState("");
|
|
const [results, setResults] = useState<any[]>([]);
|
|
const [showResults, setShowResults] = useState(false);
|
|
const [searching, setSearching] = useState(false);
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setShowResults(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
const handleSearch = useCallback(
|
|
async (text: string) => {
|
|
setSearchText(text);
|
|
if (text.length < 1) {
|
|
setResults([]);
|
|
setShowResults(false);
|
|
return;
|
|
}
|
|
setSearching(true);
|
|
try {
|
|
const res = await getUserList({ search: text, limit: 10 });
|
|
const users = res?.success !== false ? (res?.data || res || []) : [];
|
|
setResults(Array.isArray(users) ? users : []);
|
|
setShowResults(true);
|
|
} catch {
|
|
setResults([]);
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const selectUser = (user: any) => {
|
|
const userId = user.user_id || user.userId || "";
|
|
const userName = user.user_name || user.userName || userId;
|
|
onSelect(userId, userName);
|
|
setSearchText("");
|
|
setShowResults(false);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="text-[10px]">결재자 ID</Label>
|
|
<div ref={containerRef} className="relative">
|
|
<Input
|
|
value={value || searchText}
|
|
onChange={(e) => {
|
|
if (value) {
|
|
onSelect("", "");
|
|
}
|
|
handleSearch(e.target.value);
|
|
}}
|
|
placeholder="ID 또는 이름 검색"
|
|
className="h-7 text-xs"
|
|
/>
|
|
{showResults && results.length > 0 && (
|
|
<div className="absolute z-50 mt-1 max-h-40 w-full overflow-y-auto rounded-md border bg-popover shadow-md">
|
|
{results.map((user, i) => (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
|
onClick={() => selectUser(user)}
|
|
>
|
|
<span className="font-medium">{user.user_name || user.userName}</span>
|
|
<span className="text-muted-foreground">({user.user_id || user.userId})</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{showResults && results.length === 0 && !searching && searchText.length > 0 && (
|
|
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover p-2 text-center text-xs text-muted-foreground shadow-md">
|
|
검색 결과 없음
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[10px]">표시 라벨</Label>
|
|
<Input
|
|
value={label}
|
|
onChange={(e) => onLabelChange(e.target.value)}
|
|
placeholder="예: 팀장"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// 단계 편집 행 컴포넌트
|
|
// ============================================================
|
|
|
|
function StepEditor({
|
|
step,
|
|
stepIndex,
|
|
onUpdate,
|
|
onRemove,
|
|
}: {
|
|
step: StepFormData;
|
|
stepIndex: number;
|
|
onUpdate: (stepIndex: number, updated: StepFormData) => void;
|
|
onRemove: (stepIndex: number) => void;
|
|
}) {
|
|
const updateStepType = (newType: StepType) => {
|
|
const updated = { ...step, step_type: newType };
|
|
if (newType === "notification" && updated.approvers.length > 1) {
|
|
updated.approvers = [updated.approvers[0]];
|
|
}
|
|
onUpdate(stepIndex, updated);
|
|
};
|
|
|
|
const addApprover = () => {
|
|
onUpdate(stepIndex, {
|
|
...step,
|
|
approvers: [
|
|
...step.approvers,
|
|
{ approver_type: "user", approver_user_id: "", approver_label: "" },
|
|
],
|
|
});
|
|
};
|
|
|
|
const removeApprover = (approverIdx: number) => {
|
|
if (step.approvers.length <= 1) return;
|
|
onUpdate(stepIndex, {
|
|
...step,
|
|
approvers: step.approvers.filter((_, i) => i !== approverIdx),
|
|
});
|
|
};
|
|
|
|
const updateApprover = (approverIdx: number, field: string, value: string) => {
|
|
onUpdate(stepIndex, {
|
|
...step,
|
|
approvers: step.approvers.map((a, i) =>
|
|
i === approverIdx ? { ...a, [field]: value } : a,
|
|
),
|
|
});
|
|
};
|
|
|
|
const handleUserSelect = (approverIdx: number, userId: string, userName: string) => {
|
|
onUpdate(stepIndex, {
|
|
...step,
|
|
approvers: step.approvers.map((a, i) =>
|
|
i === approverIdx
|
|
? { ...a, approver_user_id: userId, approver_label: a.approver_label || userName }
|
|
: a,
|
|
),
|
|
});
|
|
};
|
|
|
|
const badgeInfo = STEP_TYPE_BADGE[step.step_type];
|
|
|
|
return (
|
|
<div className="rounded-md border bg-muted/30 p-3 space-y-2">
|
|
{/* 단계 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-xs font-semibold">{step.step_order}단계</span>
|
|
<Badge variant={badgeInfo.variant} className="text-[10px]">
|
|
{badgeInfo.label}
|
|
</Badge>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 text-destructive"
|
|
onClick={() => onRemove(stepIndex)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* step_type 선택 */}
|
|
<div>
|
|
<Label className="text-[10px]">결재 유형</Label>
|
|
<Select value={step.step_type} onValueChange={(v) => updateStepType(v as StepType)}>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{STEP_TYPE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 통보 타입 안내 */}
|
|
{step.step_type === "notification" && (
|
|
<p className="text-[10px] text-muted-foreground italic">
|
|
(자동 처리됩니다 - 통보 대상자에게 알림만 발송)
|
|
</p>
|
|
)}
|
|
|
|
{/* 결재자 목록 */}
|
|
<div className="space-y-2">
|
|
{step.approvers.map((approver, aIdx) => (
|
|
<div key={aIdx} className="rounded border bg-background p-2 space-y-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-medium text-muted-foreground">
|
|
{step.step_type === "consensus"
|
|
? `합의자 ${aIdx + 1}`
|
|
: step.step_type === "notification"
|
|
? "통보 대상"
|
|
: "결재자"}
|
|
</span>
|
|
{step.approvers.length > 1 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-5 w-5 text-destructive"
|
|
onClick={() => removeApprover(aIdx)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<Label className="text-[10px]">결재자 유형</Label>
|
|
<Select
|
|
value={approver.approver_type}
|
|
onValueChange={(v) => updateApprover(aIdx, "approver_type", v)}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="user" className="text-xs">사용자 지정</SelectItem>
|
|
<SelectItem value="position" className="text-xs">직급 지정</SelectItem>
|
|
<SelectItem value="dept" className="text-xs">부서 지정</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{approver.approver_type === "user" && (
|
|
<UserSearchInput
|
|
value={approver.approver_user_id || ""}
|
|
label={approver.approver_label || ""}
|
|
onSelect={(userId, userName) => handleUserSelect(aIdx, userId, userName)}
|
|
onLabelChange={(label) => updateApprover(aIdx, "approver_label", label)}
|
|
/>
|
|
)}
|
|
{approver.approver_type === "position" && (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="text-[10px]">직급</Label>
|
|
<Input
|
|
value={approver.approver_position || ""}
|
|
onChange={(e) => updateApprover(aIdx, "approver_position", e.target.value)}
|
|
placeholder="예: 부장, 이사"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[10px]">표시 라벨</Label>
|
|
<Input
|
|
value={approver.approver_label || ""}
|
|
onChange={(e) => updateApprover(aIdx, "approver_label", e.target.value)}
|
|
placeholder="예: 팀장"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{approver.approver_type === "dept" && (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="text-[10px]">부서 코드</Label>
|
|
<Input
|
|
value={approver.approver_dept_code || ""}
|
|
onChange={(e) => updateApprover(aIdx, "approver_dept_code", e.target.value)}
|
|
placeholder="예: DEPT001"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[10px]">표시 라벨</Label>
|
|
<Input
|
|
value={approver.approver_label || ""}
|
|
onChange={(e) => updateApprover(aIdx, "approver_label", e.target.value)}
|
|
placeholder="예: 경영지원팀"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 합의 타입일 때만 결재자 추가 버튼 */}
|
|
{step.step_type === "consensus" && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addApprover}
|
|
className="h-6 w-full gap-1 text-[10px]"
|
|
>
|
|
<UserPlus className="h-3 w-3" />
|
|
합의자 추가
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// 메인 페이지
|
|
// ============================================================
|
|
|
|
export default function ApprovalTemplatePage() {
|
|
const [templates, setTemplates] = useState<ApprovalLineTemplate[]>([]);
|
|
const [definitions, setDefinitions] = useState<ApprovalDefinition[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
const [editingTpl, setEditingTpl] = useState<ApprovalLineTemplate | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [formData, setFormData] = useState<TemplateFormData>({ ...INITIAL_FORM });
|
|
|
|
const [deleteTarget, setDeleteTarget] = useState<ApprovalLineTemplate | null>(null);
|
|
|
|
// ---- 데이터 로딩 ----
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
const [tplRes, defRes] = await Promise.all([
|
|
getApprovalTemplates(),
|
|
getApprovalDefinitions({ is_active: "Y" }),
|
|
]);
|
|
if (tplRes.success && tplRes.data) setTemplates(tplRes.data);
|
|
if (defRes.success && defRes.data) setDefinitions(defRes.data);
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
// ---- 템플릿 등록/수정에서 steps를 StepFormData로 변환 ----
|
|
const stepsToFormData = (steps: ApprovalLineTemplateStep[]): StepFormData[] => {
|
|
const stepMap = new Map<number, StepFormData>();
|
|
|
|
const sorted = [...steps].sort((a, b) => a.step_order - b.step_order);
|
|
for (const s of sorted) {
|
|
const existing = stepMap.get(s.step_order);
|
|
const approver: StepApprover = {
|
|
approver_type: s.approver_type,
|
|
approver_user_id: s.approver_user_id,
|
|
approver_position: s.approver_position,
|
|
approver_dept_code: s.approver_dept_code,
|
|
approver_label: s.approver_label,
|
|
};
|
|
|
|
if (existing) {
|
|
existing.approvers.push(approver);
|
|
if (s.step_type) existing.step_type = s.step_type;
|
|
} else {
|
|
stepMap.set(s.step_order, {
|
|
step_order: s.step_order,
|
|
step_type: s.step_type || "approval",
|
|
approvers: [approver],
|
|
});
|
|
}
|
|
}
|
|
|
|
return Array.from(stepMap.values()).sort((a, b) => a.step_order - b.step_order);
|
|
};
|
|
|
|
// ---- StepFormData를 API payload로 변환 ----
|
|
const formDataToSteps = (
|
|
steps: StepFormData[],
|
|
): Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[] => {
|
|
const result: Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[] = [];
|
|
for (const step of steps) {
|
|
for (const approver of step.approvers) {
|
|
result.push({
|
|
step_order: step.step_order,
|
|
step_type: step.step_type,
|
|
approver_type: approver.approver_type,
|
|
approver_user_id: approver.approver_user_id || undefined,
|
|
approver_position: approver.approver_position || undefined,
|
|
approver_dept_code: approver.approver_dept_code || undefined,
|
|
approver_label: approver.approver_label || undefined,
|
|
});
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// ---- 모달 열기 ----
|
|
const openCreate = () => {
|
|
setEditingTpl(null);
|
|
setFormData({
|
|
template_name: "",
|
|
description: "",
|
|
definition_id: null,
|
|
steps: [
|
|
{
|
|
step_order: 1,
|
|
step_type: "approval",
|
|
approvers: [{ approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }],
|
|
},
|
|
],
|
|
});
|
|
setEditOpen(true);
|
|
};
|
|
|
|
const openEdit = async (tpl: ApprovalLineTemplate) => {
|
|
const res = await getApprovalTemplate(tpl.template_id);
|
|
if (!res.success || !res.data) {
|
|
toast.error("템플릿 정보를 불러올 수 없습니다.");
|
|
return;
|
|
}
|
|
const detail = res.data;
|
|
setEditingTpl(detail);
|
|
setFormData({
|
|
template_name: detail.template_name,
|
|
description: detail.description || "",
|
|
definition_id: detail.definition_id || null,
|
|
steps:
|
|
detail.steps && detail.steps.length > 0
|
|
? stepsToFormData(detail.steps)
|
|
: [
|
|
{
|
|
step_order: 1,
|
|
step_type: "approval",
|
|
approvers: [{ approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }],
|
|
},
|
|
],
|
|
});
|
|
setEditOpen(true);
|
|
};
|
|
|
|
// ---- 단계 관리 ----
|
|
const addStep = () => {
|
|
setFormData((p) => ({
|
|
...p,
|
|
steps: [
|
|
...p.steps,
|
|
{
|
|
step_order: p.steps.length + 1,
|
|
step_type: "approval",
|
|
approvers: [
|
|
{
|
|
approver_type: "user",
|
|
approver_user_id: "",
|
|
approver_label: `${p.steps.length + 1}차 결재자`,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}));
|
|
};
|
|
|
|
const removeStep = (idx: number) => {
|
|
setFormData((p) => ({
|
|
...p,
|
|
steps: p.steps
|
|
.filter((_, i) => i !== idx)
|
|
.map((s, i) => ({ ...s, step_order: i + 1 })),
|
|
}));
|
|
};
|
|
|
|
const updateStep = (idx: number, updated: StepFormData) => {
|
|
setFormData((p) => ({
|
|
...p,
|
|
steps: p.steps.map((s, i) => (i === idx ? updated : s)),
|
|
}));
|
|
};
|
|
|
|
// ---- 저장 ----
|
|
const handleSave = async () => {
|
|
if (!formData.template_name.trim()) {
|
|
toast.warning("템플릿명을 입력해주세요.");
|
|
return;
|
|
}
|
|
if (formData.steps.length === 0) {
|
|
toast.warning("결재 단계를 최소 1개 추가해주세요.");
|
|
return;
|
|
}
|
|
|
|
const hasEmptyApprover = formData.steps.some((step) =>
|
|
step.approvers.some((a) => {
|
|
if (a.approver_type === "user" && !a.approver_user_id) return true;
|
|
if (a.approver_type === "position" && !a.approver_position) return true;
|
|
if (a.approver_type === "dept" && !a.approver_dept_code) return true;
|
|
return false;
|
|
}),
|
|
);
|
|
|
|
if (hasEmptyApprover) {
|
|
toast.warning("모든 결재자 정보를 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
const payload = {
|
|
template_name: formData.template_name,
|
|
description: formData.description || undefined,
|
|
definition_id: formData.definition_id || undefined,
|
|
steps: formDataToSteps(formData.steps),
|
|
};
|
|
|
|
let res;
|
|
if (editingTpl) {
|
|
res = await updateApprovalTemplate(editingTpl.template_id, payload);
|
|
} else {
|
|
res = await createApprovalTemplate(payload);
|
|
}
|
|
|
|
setSaving(false);
|
|
if (res.success) {
|
|
toast.success(editingTpl ? "수정되었습니다." : "등록되었습니다.");
|
|
setEditOpen(false);
|
|
fetchData();
|
|
} else {
|
|
toast.error(res.error || "저장 실패");
|
|
}
|
|
};
|
|
|
|
// ---- 삭제 ----
|
|
const handleDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
const res = await deleteApprovalTemplate(deleteTarget.template_id);
|
|
if (res.success) {
|
|
toast.success("삭제되었습니다.");
|
|
setDeleteTarget(null);
|
|
fetchData();
|
|
} else {
|
|
toast.error(res.error || "삭제 실패");
|
|
}
|
|
};
|
|
|
|
// ---- 필터 ----
|
|
const filtered = templates.filter(
|
|
(t) =>
|
|
t.template_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(t.description || "").toLowerCase().includes(searchTerm.toLowerCase()),
|
|
);
|
|
|
|
// ---- 단계 요약 뱃지 생성 ----
|
|
const renderStepSummary = (tpl: ApprovalLineTemplate) => {
|
|
if (!tpl.steps || tpl.steps.length === 0) return <span className="text-muted-foreground">-</span>;
|
|
|
|
const stepMap = new Map<number, { type: StepType; count: number }>();
|
|
for (const s of tpl.steps) {
|
|
const existing = stepMap.get(s.step_order);
|
|
if (existing) {
|
|
existing.count++;
|
|
} else {
|
|
stepMap.set(s.step_order, { type: s.step_type || "approval", count: 1 });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-1">
|
|
{Array.from(stepMap.entries())
|
|
.sort(([a], [b]) => a - b)
|
|
.map(([order, info]) => {
|
|
const badge = STEP_TYPE_BADGE[info.type];
|
|
return (
|
|
<Badge key={order} variant={badge.variant} className="text-[10px]">
|
|
{order}단계 {badge.label}
|
|
{info.count > 1 && ` (${info.count}명)`}
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ---- 날짜 포맷 ----
|
|
const formatDate = (dateStr: string) => {
|
|
if (!dateStr) return "-";
|
|
const d = new Date(dateStr);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
};
|
|
|
|
return (
|
|
<div className="bg-background flex min-h-screen flex-col">
|
|
<div className="space-y-6 p-6">
|
|
{/* 페이지 헤더 */}
|
|
<div className="space-y-2 border-b pb-4">
|
|
<h1 className="text-3xl font-bold tracking-tight">결재 템플릿 관리</h1>
|
|
<p className="text-muted-foreground text-sm">
|
|
결재선 템플릿의 단계 구성 및 결재자를 관리합니다.
|
|
</p>
|
|
</div>
|
|
|
|
{/* 검색 + 신규 등록 버튼 */}
|
|
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative w-full sm:w-[300px]">
|
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
|
<Input
|
|
placeholder="템플릿명 또는 설명 검색..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="h-10 pl-10 text-sm"
|
|
/>
|
|
</div>
|
|
<span className="text-muted-foreground text-sm">
|
|
총 <span className="text-foreground font-semibold">{filtered.length}</span> 건
|
|
</span>
|
|
</div>
|
|
<Button onClick={openCreate} className="h-10 gap-2 text-sm font-medium">
|
|
<Plus className="h-4 w-4" />
|
|
신규 등록
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 템플릿 목록 */}
|
|
{loading ? (
|
|
<>
|
|
{/* 데스크톱 스켈레톤 */}
|
|
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
|
<TableHead className="h-12 text-sm font-semibold">템플릿명</TableHead>
|
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
|
<TableHead className="h-12 text-sm font-semibold">단계 구성</TableHead>
|
|
<TableHead className="h-12 w-[120px] text-sm font-semibold">연결된 유형</TableHead>
|
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">생성일</TableHead>
|
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">관리</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<TableRow key={i} className="border-b">
|
|
<TableCell className="h-14"><div className="h-4 w-32 animate-pulse rounded bg-muted" /></TableCell>
|
|
<TableCell className="h-14"><div className="h-4 animate-pulse rounded bg-muted" /></TableCell>
|
|
<TableCell className="h-14"><div className="h-4 w-40 animate-pulse rounded bg-muted" /></TableCell>
|
|
<TableCell className="h-14"><div className="h-4 w-20 animate-pulse rounded bg-muted" /></TableCell>
|
|
<TableCell className="h-14"><div className="mx-auto h-4 w-20 animate-pulse rounded bg-muted" /></TableCell>
|
|
<TableCell className="h-14"><div className="mx-auto h-8 w-16 animate-pulse rounded bg-muted" /></TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
{/* 모바일 스켈레톤 */}
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i} className="rounded-lg border bg-card p-4 shadow-sm">
|
|
<div className="mb-3 flex items-start justify-between">
|
|
<div className="h-5 w-36 animate-pulse rounded bg-muted" />
|
|
<div className="h-5 w-14 animate-pulse rounded-full bg-muted" />
|
|
</div>
|
|
<div className="space-y-2 border-t pt-3">
|
|
{Array.from({ length: 3 }).map((_, j) => (
|
|
<div key={j} className="flex justify-between">
|
|
<div className="h-4 w-16 animate-pulse rounded bg-muted" />
|
|
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : filtered.length === 0 ? (
|
|
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
|
<p className="text-muted-foreground text-sm">등록된 결재 템플릿이 없습니다.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 데스크톱 테이블 */}
|
|
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
|
<TableHead className="h-12 text-sm font-semibold">템플릿명</TableHead>
|
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
|
<TableHead className="h-12 text-sm font-semibold">단계 구성</TableHead>
|
|
<TableHead className="h-12 w-[120px] text-sm font-semibold">연결된 유형</TableHead>
|
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">생성일</TableHead>
|
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">관리</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filtered.map((tpl) => (
|
|
<TableRow key={tpl.template_id} className="border-b transition-colors hover:bg-muted/50">
|
|
<TableCell className="h-14 text-sm font-medium">{tpl.template_name}</TableCell>
|
|
<TableCell className="text-muted-foreground h-14 text-sm">
|
|
{tpl.description || "-"}
|
|
</TableCell>
|
|
<TableCell className="h-14 text-sm">{renderStepSummary(tpl)}</TableCell>
|
|
<TableCell className="h-14 text-sm">{tpl.definition_name || "-"}</TableCell>
|
|
<TableCell className="h-14 text-center text-sm">
|
|
{formatDate(tpl.created_at)}
|
|
</TableCell>
|
|
<TableCell className="h-14 text-center">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => openEdit(tpl)}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive"
|
|
onClick={() => setDeleteTarget(tpl)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
{/* 모바일 카드 */}
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
{filtered.map((tpl) => (
|
|
<div key={tpl.template_id} className="rounded-lg border bg-card p-4 shadow-sm">
|
|
<div className="mb-3 flex items-start justify-between">
|
|
<h3 className="text-base font-semibold">{tpl.template_name}</h3>
|
|
{tpl.definition_name && (
|
|
<Badge variant="outline" className="text-xs">{tpl.definition_name}</Badge>
|
|
)}
|
|
</div>
|
|
{tpl.description && (
|
|
<p className="text-muted-foreground mb-3 text-sm">{tpl.description}</p>
|
|
)}
|
|
<div className="space-y-2 border-t pt-3">
|
|
<div className="text-sm">
|
|
<span className="text-muted-foreground">단계 구성</span>
|
|
<div className="mt-1">{renderStepSummary(tpl)}</div>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">생성일</span>
|
|
<span>{formatDate(tpl.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 flex gap-2 border-t pt-3">
|
|
<Button variant="outline" size="sm" className="h-9 flex-1 gap-1 text-sm" onClick={() => openEdit(tpl)}>
|
|
<Edit className="h-4 w-4" />
|
|
수정
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="h-9 flex-1 gap-1 text-sm text-destructive hover:text-destructive" onClick={() => setDeleteTarget(tpl)}>
|
|
<Trash2 className="h-4 w-4" />
|
|
삭제
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* 등록/수정 Dialog */}
|
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">
|
|
{editingTpl ? "결재 템플릿 수정" : "결재 템플릿 등록"}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
결재선의 기본 정보와 단계별 결재자를 설정합니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="max-h-[60vh] space-y-3 overflow-y-auto sm:space-y-4">
|
|
{/* 템플릿 기본 정보 */}
|
|
<div>
|
|
<Label htmlFor="template_name" className="text-xs sm:text-sm">
|
|
템플릿 이름 *
|
|
</Label>
|
|
<Input
|
|
id="template_name"
|
|
value={formData.template_name}
|
|
onChange={(e) => setFormData((p) => ({ ...p, template_name: e.target.value }))}
|
|
placeholder="예: 일반 3단계 결재선"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="description" className="text-xs sm:text-sm">
|
|
설명
|
|
</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
|
placeholder="템플릿에 대한 설명을 입력하세요"
|
|
rows={2}
|
|
className="text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">결재 유형 연결</Label>
|
|
<Select
|
|
value={formData.definition_id ? String(formData.definition_id) : "none"}
|
|
onValueChange={(v) =>
|
|
setFormData((p) => ({ ...p, definition_id: v === "none" ? null : Number(v) }))
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue placeholder="결재 유형 선택 (선택사항)" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">연결 없음</SelectItem>
|
|
{definitions.map((d) => (
|
|
<SelectItem key={d.definition_id} value={String(d.definition_id)}>
|
|
{d.definition_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
|
특정 결재 유형에 이 템플릿을 연결할 수 있습니다.
|
|
</p>
|
|
</div>
|
|
|
|
{/* 결재 단계 편집 영역 */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs font-semibold sm:text-sm">결재 단계</Label>
|
|
<Button variant="outline" size="sm" onClick={addStep} className="h-7 gap-1 text-xs">
|
|
<Plus className="h-3 w-3" />
|
|
단계 추가
|
|
</Button>
|
|
</div>
|
|
|
|
{formData.steps.length === 0 && (
|
|
<p className="text-muted-foreground py-4 text-center text-xs">
|
|
결재 단계를 추가해주세요.
|
|
</p>
|
|
)}
|
|
|
|
{formData.steps.map((step, idx) => (
|
|
<StepEditor
|
|
key={`step-${idx}-${step.step_order}`}
|
|
step={step}
|
|
stepIndex={idx}
|
|
onUpdate={updateStep}
|
|
onRemove={removeStep}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setEditOpen(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{editingTpl ? "수정" : "등록"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 삭제 확인 Dialog */}
|
|
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>결재 템플릿 삭제</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
"{deleteTarget?.template_name}"을(를) 삭제하시겠습니까?
|
|
<br />
|
|
이 작업은 되돌릴 수 없습니다.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDelete}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
삭제
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<ScrollToTop />
|
|
</div>
|
|
);
|
|
}
|