849 lines
39 KiB
TypeScript
849 lines
39 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 회사관리 — Type D 탭 멀티뷰 (2탭)
|
|
*
|
|
* Tab 1: 회사정보 (company_mng 단일 레코드 폼)
|
|
* Tab 2: 부서관리 (dept_info 트리 + user_info 목록)
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|
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 {
|
|
Building2, Users, Pencil, Save, Loader2, Plus, Trash2,
|
|
Upload, X, Image as ImageIcon, ChevronRight, FolderOpen, Folder,
|
|
} 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 { formatField, validateField, validateForm } from "@/lib/utils/validation";
|
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
|
|
|
const COMPANY_TABLE = "company_mng";
|
|
const DEPT_TABLE = "dept_info";
|
|
const USER_TABLE = "user_info";
|
|
|
|
/* ── 트리 노드 타입 ── */
|
|
interface DeptNode {
|
|
dept_code: string;
|
|
dept_name: string;
|
|
parent_dept_code: string | null;
|
|
status?: string;
|
|
children: DeptNode[];
|
|
}
|
|
|
|
export default function CompanyPage() {
|
|
const { user } = useAuth();
|
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
|
|
|
/* ===================== Tab 1: 회사정보 ===================== */
|
|
const [companyData, setCompanyData] = useState<Record<string, any>>({});
|
|
const [companyForm, setCompanyForm] = useState<Record<string, any>>({});
|
|
const [companyLoading, setCompanyLoading] = useState(false);
|
|
const [editMode, setEditMode] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// 이미지 업로드 refs
|
|
const imageRef = useRef<HTMLInputElement>(null);
|
|
const logoRef = useRef<HTMLInputElement>(null);
|
|
const sealRef = useRef<HTMLInputElement>(null);
|
|
|
|
// 이미지 미리보기
|
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
|
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
|
const [sealPreview, setSealPreview] = useState<string | null>(null);
|
|
|
|
const fetchCompany = useCallback(async () => {
|
|
setCompanyLoading(true);
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/${COMPANY_TABLE}/data`, {
|
|
page: 1, size: 1, autoFilter: true,
|
|
});
|
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
if (rows.length > 0) {
|
|
setCompanyData(rows[0]);
|
|
setCompanyForm(rows[0]);
|
|
if (rows[0].company_image) setImagePreview(rows[0].company_image);
|
|
if (rows[0].company_logo) setLogoPreview(rows[0].company_logo);
|
|
if (rows[0].company_seal) setSealPreview(rows[0].company_seal);
|
|
}
|
|
} catch {
|
|
toast.error("회사 정보를 불러오는데 실패했어요.");
|
|
} finally {
|
|
setCompanyLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { fetchCompany(); }, [fetchCompany]);
|
|
|
|
const handleImageUpload = (
|
|
e: React.ChangeEvent<HTMLInputElement>,
|
|
field: string,
|
|
setPreview: React.Dispatch<React.SetStateAction<string | null>>,
|
|
) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
const result = reader.result as string;
|
|
setPreview(result);
|
|
setCompanyForm((prev) => ({ ...prev, [field]: result }));
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const handleCompanySave = async () => {
|
|
if (!companyForm.company_name) {
|
|
toast.error("회사명은 필수예요.");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const { id, created_at, updated_at, writer, created_date, updated_date, regdate, company_code, ...updatedData } = companyForm;
|
|
|
|
if (companyData.company_code) {
|
|
await apiClient.put(`/table-management/tables/${COMPANY_TABLE}/edit`, {
|
|
originalData: { company_code: companyData.company_code },
|
|
updatedData,
|
|
});
|
|
} else {
|
|
await apiClient.post(`/table-management/tables/${COMPANY_TABLE}/add`, { company_code, ...updatedData });
|
|
}
|
|
toast.success("회사 정보가 저장되었어요.");
|
|
setEditMode(false);
|
|
fetchCompany();
|
|
} catch (err: any) {
|
|
toast.error(err.response?.data?.message || "저장에 실패했어요.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
setCompanyForm(companyData);
|
|
setImagePreview(companyData.company_image || null);
|
|
setLogoPreview(companyData.company_logo || null);
|
|
setSealPreview(companyData.company_seal || null);
|
|
setEditMode(false);
|
|
};
|
|
|
|
/* ===================== Tab 2: 부서관리 ===================== */
|
|
const [depts, setDepts] = useState<any[]>([]);
|
|
const [deptTree, setDeptTree] = useState<DeptNode[]>([]);
|
|
const [deptLoading, setDeptLoading] = useState(false);
|
|
const [selectedDeptCode, setSelectedDeptCode] = useState<string | null>(null);
|
|
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
|
|
|
|
// 사원
|
|
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 [deptSaving, setDeptSaving] = 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 buildTree = (flatDepts: any[]): DeptNode[] => {
|
|
const map: Record<string, DeptNode> = {};
|
|
const roots: DeptNode[] = [];
|
|
flatDepts.forEach((d) => {
|
|
map[d.dept_code] = { ...d, children: [] };
|
|
});
|
|
flatDepts.forEach((d) => {
|
|
const node = map[d.dept_code];
|
|
if (d.parent_dept_code && map[d.parent_dept_code]) {
|
|
map[d.parent_dept_code].children.push(node);
|
|
} else {
|
|
roots.push(node);
|
|
}
|
|
});
|
|
return roots;
|
|
};
|
|
|
|
const fetchDepts = useCallback(async () => {
|
|
setDeptLoading(true);
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
|
|
page: 1, size: 500, autoFilter: true,
|
|
});
|
|
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setDepts(raw);
|
|
setDeptTree(buildTree(raw));
|
|
// 전부 펼치기
|
|
setExpandedDepts(new Set(raw.map((d: any) => d.dept_code)));
|
|
} catch {
|
|
toast.error("부서 목록을 불러오는데 실패했어요.");
|
|
} finally {
|
|
setDeptLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { fetchDepts(); }, [fetchDepts]);
|
|
|
|
const selectedDept = depts.find((d) => d.dept_code === selectedDeptCode);
|
|
|
|
// 사원 조회
|
|
const fetchMembers = useCallback(async () => {
|
|
if (!selectedDeptCode) { setMembers([]); return; }
|
|
setMemberLoading(true);
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] },
|
|
autoFilter: true,
|
|
});
|
|
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
|
|
} catch { setMembers([]); } finally { setMemberLoading(false); }
|
|
}, [selectedDeptCode]);
|
|
|
|
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
|
|
|
// 트리 토글
|
|
const toggleExpand = (code: string) => {
|
|
setExpandedDepts((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(code)) next.delete(code); else next.add(code);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// 부서 등록
|
|
const openDeptRegister = async () => {
|
|
setDeptForm({});
|
|
setDeptEditMode(false);
|
|
setPreviewCode(null);
|
|
setNumberingRuleId(null);
|
|
setDeptModalOpen(true);
|
|
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;
|
|
setDeptSaving(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 || "";
|
|
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 {
|
|
setDeptSaving(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 as any).message || "삭제되었어요.");
|
|
setSelectedDeptCode(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; }
|
|
setDeptSaving(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 {
|
|
setDeptSaving(false);
|
|
}
|
|
};
|
|
|
|
// EDataTable 컬럼 정의 (사원 목록)
|
|
const companyMemberColumns: EDataTableColumn[] = [
|
|
{ key: "sabun", label: "사번", width: "w-[80px]", render: (val: any) => <span className="text-[13px]">{val || "-"}</span> },
|
|
{ key: "user_name", label: "이름", width: "w-[90px]" },
|
|
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
|
|
{ key: "position_name", label: "직급", width: "w-[80px]", render: (val: any) => <span>{val || "-"}</span> },
|
|
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]", render: (val: any) => <span>{val || "-"}</span> },
|
|
{ key: "email", label: "이메일" },
|
|
];
|
|
|
|
/* ── 트리 렌더 ── */
|
|
const renderTree = (nodes: DeptNode[], depth = 0) => {
|
|
return nodes.map((node) => {
|
|
const isExpanded = expandedDepts.has(node.dept_code);
|
|
const isSelected = selectedDeptCode === node.dept_code;
|
|
const hasChildren = node.children.length > 0;
|
|
return (
|
|
<div key={node.dept_code}>
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-1.5 px-3 py-2 cursor-pointer text-sm transition-colors hover:bg-accent",
|
|
isSelected && "bg-primary/10 text-primary font-semibold border-l-2 border-primary",
|
|
!isSelected && "border-l-2 border-transparent",
|
|
)}
|
|
style={{ paddingLeft: `${12 + depth * 20}px` }}
|
|
onClick={() => setSelectedDeptCode(isSelected ? null : node.dept_code)}
|
|
>
|
|
{hasChildren ? (
|
|
<button
|
|
className="p-0.5 rounded hover:bg-accent"
|
|
onClick={(e) => { e.stopPropagation(); toggleExpand(node.dept_code); }}
|
|
>
|
|
<ChevronRight className={cn("w-3.5 h-3.5 transition-transform", isExpanded && "rotate-90")} />
|
|
</button>
|
|
) : (
|
|
<span className="w-4.5" />
|
|
)}
|
|
{isExpanded && hasChildren
|
|
? <FolderOpen className="w-4 h-4 text-muted-foreground" />
|
|
: <Folder className="w-4 h-4 text-muted-foreground" />
|
|
}
|
|
<span className="truncate">{node.dept_name}</span>
|
|
{node.status === "inactive" && <Badge variant="outline" className="text-[10px] px-1 py-0">비활성</Badge>}
|
|
</div>
|
|
{isExpanded && hasChildren && renderTree(node.children, depth + 1)}
|
|
</div>
|
|
);
|
|
});
|
|
};
|
|
|
|
/* ── 이미지 업로드 박스 ── */
|
|
const ImageUploadBox = ({
|
|
label, preview, inputRef, field, setPreview,
|
|
}: {
|
|
label: string;
|
|
preview: string | null;
|
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
|
field: string;
|
|
setPreview: React.Dispatch<React.SetStateAction<string | null>>;
|
|
}) => (
|
|
<div className="flex flex-col gap-2">
|
|
<Label className="text-sm font-medium">{label}</Label>
|
|
<div className="relative w-40 h-40 rounded-lg border-2 border-dashed border-border bg-muted/50 flex items-center justify-center overflow-hidden group">
|
|
{preview ? (
|
|
<>
|
|
<img src={preview} alt={label} className="w-full h-full object-contain" />
|
|
{editMode && (
|
|
<div className="absolute inset-0 bg-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
|
<Button size="sm" variant="secondary" className="h-7 text-xs" onClick={() => inputRef.current?.click()}>
|
|
<Upload className="w-3 h-3 mr-1" /> 변경
|
|
</Button>
|
|
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => {
|
|
setPreview(null);
|
|
setCompanyForm((prev) => ({ ...prev, [field]: null }));
|
|
}}>
|
|
<X className="w-3 h-3" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<button
|
|
className="flex flex-col items-center gap-1.5 text-muted-foreground"
|
|
onClick={() => editMode && inputRef.current?.click()}
|
|
disabled={!editMode}
|
|
>
|
|
<ImageIcon className="w-8 h-8" />
|
|
<span className="text-xs">{editMode ? "이미지 업로드" : "이미지 없음"}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(e) => handleImageUpload(e, field, setPreview)}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
|
|
{/* 탭 컨테이너 */}
|
|
<Tabs defaultValue="company" className="flex flex-col h-full gap-0 min-h-0">
|
|
{/* 탭 헤더 — border-b 스타일 */}
|
|
<div className="shrink-0 border-b bg-background px-4">
|
|
<TabsList className="h-12 bg-transparent gap-1">
|
|
<TabsTrigger
|
|
value="company"
|
|
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
|
|
>
|
|
<Building2 className="w-4 h-4" /> 회사정보
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="department"
|
|
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
|
|
>
|
|
<Users className="w-4 h-4" /> 부서관리
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
|
|
{/* ===================== Tab 1: 회사정보 ===================== */}
|
|
<TabsContent value="company" className="flex-1 overflow-auto mt-0 p-4">
|
|
<div className="border rounded-lg bg-card">
|
|
{/* 패널 헤더 */}
|
|
<div className="flex items-center justify-between px-6 py-3 border-b bg-muted/30">
|
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
|
<Building2 className="w-4 h-4 text-muted-foreground" />
|
|
<span>회사 기본정보</span>
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
{editMode ? (
|
|
<>
|
|
<Button variant="outline" size="sm" onClick={cancelEdit}>취소</Button>
|
|
<Button size="sm" onClick={handleCompanySave} disabled={saving}>
|
|
{saving ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Save className="w-3.5 h-3.5 mr-1" />}
|
|
저장해요
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button size="sm" onClick={() => setEditMode(true)}>
|
|
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{companyLoading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : (
|
|
<div className="p-6 space-y-6">
|
|
{/* 기본 정보 섹션 제목 */}
|
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
기본 정보
|
|
<div className="flex-1 h-px bg-border" />
|
|
</div>
|
|
|
|
{/* 기본 정보 그리드 (2열) */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">회사코드</Label>
|
|
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
회사명 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
value={companyForm.company_name || ""}
|
|
onChange={(e) => setCompanyForm((p) => ({ ...p, company_name: e.target.value }))}
|
|
placeholder="회사명을 입력해주세요"
|
|
className="h-9" disabled={!editMode}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">사업자등록번호</Label>
|
|
<Input
|
|
value={companyForm.business_registration_number || ""}
|
|
onChange={(e) => setCompanyForm((p) => ({ ...p, business_registration_number: e.target.value }))}
|
|
placeholder="000-00-00000"
|
|
className="h-9" disabled={!editMode}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">대표자명</Label>
|
|
<Input
|
|
value={companyForm.representative_name || ""}
|
|
onChange={(e) => setCompanyForm((p) => ({ ...p, representative_name: e.target.value }))}
|
|
placeholder="대표자명"
|
|
className="h-9" disabled={!editMode}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">대표전화</Label>
|
|
<Input
|
|
value={companyForm.representative_phone || ""}
|
|
onChange={(e) => setCompanyForm((p) => ({ ...p, representative_phone: e.target.value }))}
|
|
placeholder="02-0000-0000"
|
|
className="h-9" disabled={!editMode}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">팩스</Label>
|
|
<Input
|
|
value={companyForm.fax || ""}
|
|
onChange={(e) => setCompanyForm((p) => ({ ...p, fax: e.target.value }))}
|
|
placeholder="02-0000-0001"
|
|
className="h-9" disabled={!editMode}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">이메일</Label>
|
|
<Input
|
|
value={companyForm.email || ""}
|
|
onChange={(e) => setCompanyForm((p) => ({ ...p, email: e.target.value }))}
|
|
placeholder="example@company.com"
|
|
className="h-9" disabled={!editMode}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">홈페이지</Label>
|
|
<Input
|
|
value={companyForm.website || ""}
|
|
onChange={(e) => setCompanyForm((p) => ({ ...p, website: e.target.value }))}
|
|
placeholder="https://www.company.com"
|
|
className="h-9" disabled={!editMode}
|
|
/>
|
|
</div>
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">주소</Label>
|
|
<Input
|
|
value={companyForm.address || ""}
|
|
onChange={(e) => setCompanyForm((p) => ({ ...p, address: e.target.value }))}
|
|
placeholder="회사 주소를 입력해주세요"
|
|
className="h-9" disabled={!editMode}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 이미지 섹션 */}
|
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
이미지 관리
|
|
<div className="flex-1 h-px bg-border" />
|
|
</div>
|
|
<div className="flex gap-6">
|
|
<ImageUploadBox label="회사 이미지" preview={imagePreview} inputRef={imageRef} field="company_image" setPreview={setImagePreview} />
|
|
<ImageUploadBox label="회사 로고" preview={logoPreview} inputRef={logoRef} field="company_logo" setPreview={setLogoPreview} />
|
|
<ImageUploadBox label="직인" preview={sealPreview} inputRef={sealRef} field="company_seal" setPreview={setSealPreview} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ===================== Tab 2: 부서관리 ===================== */}
|
|
<TabsContent value="department" className="flex-1 overflow-hidden mt-0">
|
|
<div className="h-full overflow-hidden border rounded-none bg-card">
|
|
<ResizablePanelGroup direction="horizontal">
|
|
{/* 좌측: 부서 트리 */}
|
|
<ResizablePanel defaultSize={30} minSize={20}>
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
|
<Building2 className="w-4 h-4 text-muted-foreground" />
|
|
<span>부서</span>
|
|
<Badge variant="secondary" className="font-mono text-xs">{depts.length}건</Badge>
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
<Button size="sm" className="h-8" onClick={openDeptRegister}>
|
|
<Plus className="w-3.5 h-3.5 mr-1" /> 등록
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={openDeptEdit}>
|
|
<Pencil className="w-3.5 h-3.5" />
|
|
</Button>
|
|
<Button variant="destructive" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={handleDeptDelete}>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto">
|
|
{deptLoading ? (
|
|
<div className="flex items-center justify-center py-10">
|
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : deptTree.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
|
<Building2 className="w-8 h-8 mb-2" />
|
|
<span className="text-sm">등록된 부서가 없어요</span>
|
|
</div>
|
|
) : (
|
|
renderTree(deptTree)
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
{/* 우측: 사원 목록 */}
|
|
<ResizablePanel defaultSize={70} minSize={40}>
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
|
<Users className="w-4 h-4 text-muted-foreground" />
|
|
<span>{selectedDept ? "부서 인원" : "부서를 선택해주세요"}</span>
|
|
{selectedDept && <Badge variant="outline" className="font-mono text-xs">{selectedDept.dept_name}</Badge>}
|
|
{members.length > 0 && <Badge variant="secondary" className="font-mono text-xs">{members.length}명</Badge>}
|
|
</div>
|
|
{selectedDeptCode && (
|
|
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
|
|
<Plus className="w-3.5 h-3.5 mr-1" /> 사원 추가
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{selectedDeptCode ? (
|
|
<EDataTable
|
|
columns={companyMemberColumns}
|
|
data={members}
|
|
rowKey={(row) => row.user_id || row.id}
|
|
loading={memberLoading}
|
|
emptyMessage="소속 사원이 없어요"
|
|
emptyIcon={<Users className="w-8 h-8 mb-2" />}
|
|
onRowDoubleClick={(row) => openUserModal(row)}
|
|
showPagination={false}
|
|
draggableColumns={false}
|
|
/>
|
|
) : (
|
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
|
<Users className="w-10 h-10 mb-3" />
|
|
<span className="text-sm">좌측에서 부서를 선택해주세요</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
{/* ── 부서 등록/수정 모달 ── */}
|
|
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
|
|
<DialogDescription>{deptEditMode ? "부서 정보를 수정해요." : "새로운 부서를 등록해요."}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">부서코드</Label>
|
|
<Input
|
|
value={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
|
|
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성됩니다")}
|
|
className="h-9" disabled readOnly
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">부서명 <span className="text-destructive">*</span></Label>
|
|
<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">
|
|
<Label className="text-sm">상위부서</Label>
|
|
<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={handleDeptSave} disabled={deptSaving}>
|
|
{deptSaving ? <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-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">사용자 ID <span className="text-destructive">*</span></Label>
|
|
<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">
|
|
<Label className="text-sm">이름 <span className="text-destructive">*</span></Label>
|
|
<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">
|
|
<Label className="text-sm">사번</Label>
|
|
<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">
|
|
<Label className="text-sm">비밀번호</Label>
|
|
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
|
|
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" autoComplete="new-password" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">직급</Label>
|
|
<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">
|
|
<Label className="text-sm">부서 <span className="text-destructive">*</span></Label>
|
|
<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">
|
|
<Label className="text-sm">휴대폰</Label>
|
|
<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">
|
|
<Label className="text-sm">이메일</Label>
|
|
<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">
|
|
<Label className="text-sm">입사일</Label>
|
|
<Input type="date" value={userForm.regdate || ""} onChange={(e) => setUserForm((p) => ({ ...p, regdate: e.target.value }))} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">퇴사일</Label>
|
|
<Input type="date" value={userForm.end_date || ""} 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={handleUserSave} disabled={deptSaving}>
|
|
{deptSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장해요
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{ConfirmDialogComponent}
|
|
</div>
|
|
);
|
|
}
|