Files
pipeline/frontend/app/(main)/COMPANY_16/master-data/department/page.tsx
T

768 lines
36 KiB
TypeScript

"use client";
/**
* 부서관리 — 하드코딩 페이지
*
* 좌측: 부서 목록 (dept_info)
* 우측: 선택한 부서의 인원 목록 (user_info)
*
* 모달: 부서 등록(dept_info), 사원 추가(user_info)
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Users, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import * as departmentAPI from "@/lib/api/department";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
const DEPT_TABLE = "dept_info";
const USER_TABLE = "user_info";
const DEPT_COLUMNS = [
{ key: "parent_dept_code", label: "상위부서" },
{ key: "status", label: "상태" },
];
export default function DepartmentPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 검색 필터 (DynamicSearchFilter)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 좌측: 부서
const [depts, setDepts] = useState<any[]>([]);
const [deptLoading, setDeptLoading] = useState(false);
const [deptCount, setDeptCount] = useState(0);
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
// 우측: 사원
const [members, setMembers] = useState<any[]>([]);
const [memberLoading, setMemberLoading] = useState(false);
// 부서 모달
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [deptEditMode, setDeptEditMode] = useState(false);
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 채번 시스템
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
// 사원 모달
const [userModalOpen, setUserModalOpen] = useState(false);
const [userEditMode, setUserEditMode] = useState(false);
const [userForm, setUserForm] = useState<Record<string, any>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// 사원 탭 (재직중/퇴사)
const [memberTab, setMemberTab] = useState<"active" | "resigned">("active");
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 테이블 설정
const ts = useTableSettings("c16-department", DEPT_TABLE, DEPT_COLUMNS);
// 부서 조회
const fetchDepts = useCallback(async () => {
setDeptLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑
const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code }));
setDepts(data);
setDeptCount(res.data?.data?.total || data.length);
} catch (err) {
toast.error("부서 목록을 불러오는데 실패했습니다.");
} finally {
setDeptLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchDepts(); }, [fetchDepts]);
// 선택된 부서
const selectedDept = depts.find((d) => d.id === selectedDeptId);
const selectedDeptCode = selectedDept?.dept_code || null;
// 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서)
const fetchMembers = useCallback(async () => {
setMemberLoading(true);
try {
const filters = selectedDeptCode
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
: [];
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setMembers([]); } finally { setMemberLoading(false); }
}, [selectedDeptCode]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
// 부서 등록
const openDeptRegister = async () => {
setDeptForm({});
setDeptEditMode(false);
setPreviewCode(null);
setNumberingRuleId(null);
setDeptModalOpen(true);
// 채번 규칙 조회 (dept_info.dept_code) — path params로 직접 호출
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const previewRes = await previewNumberingCode(ruleId);
if (previewRes.success && previewRes.data?.generatedCode) {
setPreviewCode(previewRes.data.generatedCode);
}
}
} catch {
// 채번 규칙 없으면 무시
}
};
const openDeptEdit = () => {
if (!selectedDept) return;
setDeptForm({ ...selectedDept });
setDeptEditMode(true);
setDeptModalOpen(true);
};
const handleDeptSave = async () => {
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null;
setSaving(true);
try {
if (deptEditMode && deptForm.dept_code) {
const response = await departmentAPI.updateDepartment(deptForm.dept_code, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
});
if (!response.success) { toast.error((response as any).error || "수정에 실패했습니다."); return; }
toast.success("수정되었습니다.");
} else {
const companyCode = user?.companyCode || "";
// 채번 규칙이 있으면 allocate로 실제 코드 할당
let allocatedCode: string | undefined;
if (numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
allocatedCode = allocRes.data.generatedCode;
} else {
toast.error("채번 코드 할당에 실패했습니다.");
return;
}
}
const response = await departmentAPI.createDepartment(companyCode, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
dept_code: allocatedCode,
});
if (!response.success) {
toast.error((response as any).error || "등록에 실패했습니다.");
return;
}
toast.success("등록되었습니다.");
}
setDeptModalOpen(false);
fetchDepts();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 부서 삭제
const handleDeptDelete = async () => {
if (!selectedDeptCode) return;
const ok = await confirm("부서를 삭제하시겠습니까?", {
description: "해당 부서에 소속된 사원 정보는 유지됩니다.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
const response = await departmentAPI.deleteDepartment(selectedDeptCode);
if (!response.success) { toast.error((response as any).error || "삭제에 실패했습니다."); return; }
toast.success(response.message || "삭제되었습니다.");
setSelectedDeptId(null);
fetchDepts();
} catch { toast.error("삭제에 실패했습니다."); }
};
// 사원 추가/수정
const openUserModal = (editData?: any) => {
if (editData) {
setUserEditMode(true);
setUserForm({ ...editData, user_password: "" });
} else {
setUserEditMode(false);
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
}
setFormErrors({});
setUserModalOpen(true);
};
const handleUserFormChange = (field: string, value: string) => {
const formatted = formatField(field, value);
setUserForm((prev) => ({ ...prev, [field]: formatted }));
const error = validateField(field, formatted);
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
};
const handleUserSave = async () => {
if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; }
if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; }
if (!userForm.dept_code) { toast.error("부서는 필수입니다."); return; }
const errors = validateForm(userForm, ["cell_phone", "email"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
setSaving(true);
try {
// 비밀번호 미입력 시 기본값 (신규만)
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
await apiClient.post("/admin/users/with-dept", {
userInfo: {
user_id: userForm.user_id,
user_name: userForm.user_name,
user_name_eng: userForm.user_name_eng || undefined,
user_password: password || undefined,
email: userEditMode ? (userForm.email || null) : (userForm.email || undefined),
tel: userForm.tel || undefined,
cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined),
sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined),
position_name: userForm.position_name || undefined,
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
position_name: userForm.position_name || undefined,
} : undefined,
isUpdate: userEditMode,
});
toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다.");
setUserModalOpen(false);
fetchMembers();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (depts.length === 0) return;
const data = depts.map((d) => ({
부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status,
}));
await exportToExcel(data, "부서관리.xlsx", "부서");
toast.success("다운로드 완료");
};
// 퇴사일 기반 재직/퇴사 분리
const today = new Date().toISOString().split("T")[0];
const activeMembers = members.filter((m) => !m.end_date || m.end_date.substring(0, 10) >= today);
const resignedMembers = members.filter((m) => m.end_date && m.end_date.substring(0, 10) < today);
const isColVisible = (key: string) => ts.isVisible(key);
// EDataTable 컬럼 정의 (부서 목록)
const deptColumns: EDataTableColumn[] = [
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
{ key: "dept_name", label: "부서명", minWidth: "min-w-[140px]" },
...(isColVisible("parent_dept_code")
? [{
key: "parent_dept_code",
label: "상위부서",
width: "w-[110px]",
render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "\u2014"}</span>,
}]
: []),
...(isColVisible("status")
? [{
key: "status",
label: "상태",
width: "w-[70px]",
render: (val: any) =>
val ? (
<Badge
variant={val === "active" ? "default" : "outline"}
className="text-[10px] px-1.5 py-0 h-5"
>
{val === "active" ? "활성" : (val || "\u2014")}
</Badge>
) : null,
}]
: []),
];
return (
<div className="flex h-full flex-col gap-3 p-4">
{/* 검색 필터 바 */}
<DynamicSearchFilter
tableName={DEPT_TABLE}
filterId="c16-department"
onFilterChange={setSearchFilters}
dataCount={deptCount}
externalFilterConfig={ts.filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-8" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-8" onClick={() => void handleExcelDownload()}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 마스터-디테일 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 부서 목록 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted shrink-0">
<div className="flex items-center gap-2.5">
<span className="text-[13px] font-bold"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/[0.08] px-2 py-0.5 rounded-full">
{deptCount}
</span>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={() => void openDeptRegister()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={() => void handleDeptDelete()}>
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={() => ts.setOpen(true)}>
<Settings2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* 부서 테이블 */}
<EDataTable
columns={deptColumns}
data={ts.groupData(depts)}
rowKey={(row) => row.id}
loading={deptLoading}
emptyMessage="등록된 부서가 없어요"
selectedId={selectedDeptId}
onSelect={(id) => setSelectedDeptId(id)}
onRowDoubleClick={() => openDeptEdit()}
showRowNumber
showPagination={false}
draggableColumns={false}
columnOrderKey="c16-department"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 사원 목록 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
{!selectedDeptId ? (
/* 빈 상태 */
<div className="flex-1 flex items-center justify-center p-5">
<div className="flex flex-col items-center justify-center text-center border-2 border-dashed border-border rounded-lg px-10 py-16">
<Users className="w-12 h-12 text-muted-foreground/40 mb-4" />
<div className="text-sm font-semibold text-muted-foreground mb-1.5"> </div>
<div className="text-xs text-muted-foreground"> </div>
</div>
</div>
) : (
<>
{/* 디테일 헤더 */}
<div className="flex items-center gap-3 px-4 py-3 border-b bg-muted shrink-0">
<span className="text-[13px] font-bold">{selectedDept?.dept_name || "-"}</span>
<span className="font-mono text-[11px] font-semibold text-primary bg-primary/[0.08] px-2 py-0.5 rounded-full">
{selectedDept?.dept_code || "-"}
</span>
<div className="ml-auto">
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
{/* 재직/퇴사 탭 */}
<div className="flex border-b border-border px-4 shrink-0 bg-muted">
<button
onClick={() => setMemberTab("active")}
className={cn("px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
memberTab === "active" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"
)}
>
{activeMembers.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1.5 py-0">{activeMembers.length}</Badge>
)}
</button>
<button
onClick={() => setMemberTab("resigned")}
className={cn("px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
memberTab === "resigned" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"
)}
>
{resignedMembers.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1.5 py-0">{resignedMembers.length}</Badge>
)}
</button>
</div>
<div className="flex-1 overflow-auto">
{memberLoading ? (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" />
</div>
) : memberTab === "active" ? (
activeMembers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm"> </div>
) : (
<Table noWrapper>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">ID</TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeMembers.map((member, idx) => (
<TableRow
key={member.id || member.user_id}
className="cursor-pointer select-none hover:bg-muted/50"
onDoubleClick={() => openUserModal(member)}
>
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
<TableCell className="font-mono text-[13px] text-muted-foreground">{member.sabun || "—"}</TableCell>
<TableCell className="text-sm font-medium">{member.user_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{member.user_id}</TableCell>
<TableCell className="text-[13px]">{member.position_name || "—"}</TableCell>
<TableCell className="text-[13px]">{member.cell_phone || "—"}</TableCell>
<TableCell className="text-[13px]">{member.email || "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
) : (
resignedMembers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm"> </div>
) : (
<Table noWrapper>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">ID</TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resignedMembers.map((member, idx) => (
<TableRow
key={member.id || member.user_id}
className="cursor-pointer select-none hover:bg-muted/50"
onDoubleClick={() => openUserModal(member)}
>
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
<TableCell className="font-mono text-[13px] text-muted-foreground">{member.sabun || "—"}</TableCell>
<TableCell className="text-sm font-medium">{member.user_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{member.user_id}</TableCell>
<TableCell className="text-[13px]">{member.position_name || "—"}</TableCell>
<TableCell className="text-[13px]">{member.cell_phone || "—"}</TableCell>
<TableCell className="text-[13px]">{member.email || "—"}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{member.end_date ? member.end_date.substring(0, 10) : "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
)}
</div>
</>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 부서 등록/수정 모달 */}
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
<DialogDescription>{deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성돼요")}
className="h-9"
disabled
readOnly
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Input
value={deptForm.dept_name || ""}
onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
placeholder="부서명을 입력해 주세요"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Select
value={deptForm.parent_dept_code || ""}
onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="상위부서 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeptModalOpen(false)}></Button>
<Button onClick={() => void handleDeptSave()} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 사원 추가/수정 모달 */}
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
<DialogDescription>
{userEditMode
? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.`
: selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-2">
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
ID <span className="text-destructive">*</span>
</span>
<Input
value={userForm.user_id || ""}
onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
placeholder="사용자 ID를 입력해 주세요"
className="h-9"
disabled={userEditMode}
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Input
value={userForm.user_name || ""}
onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
placeholder="이름을 입력해 주세요"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.sabun || ""}
onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
placeholder="사번"
className="h-9"
autoComplete="off"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.user_password || ""}
onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
placeholder={userEditMode ? "변경 시에만 입력해 주세요" : "미입력 시 기본값이 설정돼요"}
className="h-9"
type="password"
autoComplete="new-password"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.position_name || ""}
onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
placeholder="직급"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9">
<SelectValue placeholder="부서를 선택해 주세요" />
</SelectTrigger>
<SelectContent>
{depts.map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.cell_phone || ""}
onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
placeholder="010-0000-0000"
className={cn("h-9", formErrors.cell_phone && "border-destructive")}
/>
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
value={userForm.email || ""}
onChange={(e) => handleUserFormChange("email", e.target.value)}
placeholder="example@email.com"
className={cn("h-9", formErrors.email && "border-destructive")}
/>
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
type="date"
value={userForm.regdate ? userForm.regdate.substring(0, 10) : ""}
onChange={(e) => setUserForm((p) => ({ ...p, regdate: e.target.value }))}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
type="date"
value={userForm.end_date ? userForm.end_date.substring(0, 10) : ""}
onChange={(e) => setUserForm((p) => ({ ...p, end_date: e.target.value }))}
className="h-9"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserModalOpen(false)}></Button>
<Button onClick={() => void handleUserSave()} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DEPT_TABLE}
userId={user?.userId}
onSuccess={() => fetchDepts()}
/>
{ConfirmDialogComponent}
</div>
);
}