768 lines
36 KiB
TypeScript
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>
|
|
);
|
|
}
|