forked from baron/baron-sso
조직도 기능 추가
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Download, FileText, Loader2, Upload } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -13,7 +20,11 @@ import {
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import { importOrgChart } from "../../../lib/adminApi";
|
||||
import {
|
||||
type ImportResult,
|
||||
fetchImportProgress,
|
||||
importOrgChart,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
interface OrgChartUploadModalProps {
|
||||
@@ -27,52 +38,76 @@ export function OrgChartUploadModal({
|
||||
}: OrgChartUploadModalProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [result, setResult] = React.useState<ImportResult | null>(null);
|
||||
const [progressId, setProgressId] = React.useState<string | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (file: File) => importOrgChart(tenantId, file),
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.org.import_success",
|
||||
"조직도가 성공적으로 업로드되었습니다.",
|
||||
),
|
||||
);
|
||||
setOpen(false);
|
||||
mutationFn: ({ file, pid }: { file: File; pid: string }) =>
|
||||
importOrgChart(tenantId, file, pid),
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
setProgressId(null);
|
||||
if (data.errors.length === 0) {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.org.import_success",
|
||||
"조직도가 성공적으로 업로드되었습니다.",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t(
|
||||
"msg.admin.org.import_partial_success",
|
||||
"일부 데이터 업로드 중 오류가 발생했습니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
setProgressId(null);
|
||||
toast.error(t("msg.admin.org.import_error", "조직도 업로드 실패"), {
|
||||
description: error.response?.data?.error || error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data: progressData } = useQuery({
|
||||
queryKey: ["importProgress", progressId],
|
||||
queryFn: () => fetchImportProgress(tenantId, progressId!),
|
||||
enabled: !!progressId && mutation.isPending,
|
||||
refetchInterval: 500,
|
||||
});
|
||||
|
||||
const percent =
|
||||
progressData && progressData.total > 0
|
||||
? Math.round((progressData.current / progressData.total) * 100)
|
||||
: 0;
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
setResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (file) {
|
||||
mutation.mutate(file);
|
||||
const pid = Math.random().toString(36).substring(2, 15);
|
||||
setProgressId(pid);
|
||||
mutation.mutate({ file, pid });
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = "email,name,organization,position,jobtitle,is_owner";
|
||||
const example = `ceo@example.com,홍길동,경영진,대표이사,경영총괄,true
|
||||
cto@example.com,이몽룡,기술부문,이사,기술총괄,true
|
||||
user1@example.com,성춘향,기술부문/개발팀,팀원,프론트엔드 개발,false`;
|
||||
const blob = new Blob(
|
||||
[
|
||||
`${headers}
|
||||
${example}`,
|
||||
],
|
||||
{ type: "text/csv" },
|
||||
);
|
||||
const headers = "이메일,이름,소속,직급,직무,구분,그룹,디비젼,팀,셀";
|
||||
const example = `test1@example.com,홍길동,한맥,수석,기획,팀장,전략그룹,기획실,인사팀,-
|
||||
test2@example.com,이몽룡,삼안,선임,개발,팀원,기술본부,개발실,개발1팀,A셀`;
|
||||
const blob = new Blob([`\uFEFF${headers}\n${example}`], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -82,14 +117,23 @@ ${example}`,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) {
|
||||
setFile(null);
|
||||
setResult(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Upload size={14} />
|
||||
{t("ui.admin.org.import_btn", "조직도 임포트 (CSV)")}
|
||||
{t("ui.admin.org.import_btn", "조직도 임포트 (CSV/XLSX)")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.org.import_title", "조직도 일괄 등록")}
|
||||
@@ -97,64 +141,151 @@ ${example}`,
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.org.import_description",
|
||||
"CSV 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.",
|
||||
"CSV 또는 XLSX 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download size={14} />
|
||||
{t("ui.admin.org.download_template", "템플릿 다운로드")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{file
|
||||
? t("ui.common.change_file", "파일 변경")
|
||||
: t("ui.common.select_file", "파일 선택")}
|
||||
</Button>
|
||||
{!result ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="gap-2"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
<Download size={14} />
|
||||
{t("ui.admin.org.download_template", "템플릿 다운로드")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv, .xlsx, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{file
|
||||
? t("ui.common.change_file", "파일 변경")
|
||||
: t("ui.common.select_file", "파일 선택")}
|
||||
</Button>
|
||||
</div>
|
||||
{file && (
|
||||
<div className="rounded-lg border p-4 bg-muted/20 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="text-primary" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{file.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mutation.isPending && progressId && (
|
||||
<div className="w-full mt-2 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<div className="flex justify-between text-xs mb-1 font-medium text-muted-foreground">
|
||||
<span>데이터 처리 중...</span>
|
||||
<span>
|
||||
{percent}% ({progressData?.current || 0} /{" "}
|
||||
{progressData?.total || 0})
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2 overflow-hidden relative">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-all duration-300 ease-out absolute top-0 left-0"
|
||||
style={{ width: `${Math.max(5, percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="rounded-lg border p-4 bg-muted/20 flex items-center gap-3">
|
||||
<FileText className="text-primary" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{file.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg bg-primary/5 border border-primary/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
전체 행
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{result.totalRows}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
처리 완료
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{result.processed}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
사용자 생성/업데이트
|
||||
</div>
|
||||
<div className="text-xl font-bold text-blue-600">
|
||||
{result.userCreated} / {result.userUpdated}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-orange-500/5 border border-orange-500/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
조직(테넌트) 생성
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{result.tenantCreated}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-destructive">
|
||||
<AlertCircle size={16} />
|
||||
오류 목록 ({result.errors.length})
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-md p-2 bg-destructive/5 text-xs font-mono space-y-1">
|
||||
{result.errors.map((err, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-destructive border-b border-destructive/10 pb-1 last:border-0"
|
||||
>
|
||||
{err}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || mutation.isPending}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{mutation.isPending && (
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("ui.admin.org.start_import", "임포트 시작")}
|
||||
</Button>
|
||||
{!result ? (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || mutation.isPending}
|
||||
className="w-full sm:w-auto relative"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
처리 중 ({percent}%)
|
||||
</>
|
||||
) : (
|
||||
t("ui.admin.org.start_import", "임포트 시작")
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setOpen(false)} className="w-full sm:w-auto">
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
||||
import { Checkbox } from "../../../components/ui/checkbox";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -25,13 +25,17 @@ import {
|
||||
import {
|
||||
type TenantSummary,
|
||||
deleteTenant,
|
||||
deleteTenantsBulk,
|
||||
fetchMe,
|
||||
fetchTenants,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
||||
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
@@ -41,7 +45,6 @@ function TenantListPage() {
|
||||
React.useEffect(() => {
|
||||
if (profile?.role === "tenant_admin") {
|
||||
const manageableCount = profile.manageableTenants?.length ?? 0;
|
||||
// If only 1 in array, OR array is empty but we have a primary tenantId
|
||||
if (
|
||||
(manageableCount === 1 || manageableCount === 0) &&
|
||||
profile.tenantId
|
||||
@@ -67,6 +70,14 @@ function TenantListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteBulkMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
|
||||
onSuccess: () => {
|
||||
setSelectedIds([]);
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
profile &&
|
||||
profile.role !== "super_admin" &&
|
||||
@@ -84,7 +95,6 @@ function TenantListPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// While redirecting (only if exactly one manageable tenant)
|
||||
if (
|
||||
profile?.role === "tenant_admin" &&
|
||||
(profile.manageableTenants?.length ?? 0) <= 1
|
||||
@@ -101,8 +111,40 @@ function TenantListPage() {
|
||||
|
||||
const tenants = query.data?.items ?? [];
|
||||
|
||||
// [New] Find a primary COMPANY_GROUP tenant to act as the root for matrix org charts
|
||||
const rootTenant = tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0];
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(tenants.map((t) => t.id));
|
||||
} else {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (id: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds((prev) => [...prev, id]);
|
||||
} else {
|
||||
setSelectedIds((prev) => prev.filter((i) => i !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBulk = () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.tenants.delete_bulk_confirm",
|
||||
"선택한 {{count}}개 테넌트를 삭제할까요?",
|
||||
{ count: selectedIds.length },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteBulkMutation.mutate(selectedIds);
|
||||
};
|
||||
|
||||
const rootTenant =
|
||||
tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0];
|
||||
|
||||
const handleDelete = (tenantId: string, tenantName: string) => {
|
||||
if (
|
||||
@@ -134,16 +176,34 @@ function TenantListPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* [New] Add Upload Modal to global list page, visible to Super Admin */}
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
{rootTenant && (
|
||||
<OrgChartUploadModal
|
||||
tenantId={rootTenant.id}
|
||||
onSuccess={() => query.refetch()}
|
||||
/>
|
||||
{selectedIds.length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteBulk}
|
||||
disabled={deleteBulkMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t("ui.admin.tenants.delete_selected", "선택 삭제")} (
|
||||
{selectedIds.length})
|
||||
</Button>
|
||||
)}
|
||||
</RoleGuard>
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<OrgChartUploadModal
|
||||
tenantId={rootTenant?.id || "root"}
|
||||
onSuccess={() => query.refetch()}
|
||||
/>
|
||||
</RoleGuard>
|
||||
|
||||
<Button asChild variant="outline" className="gap-2">
|
||||
<Link to="/tenants/org-chart">
|
||||
{t("ui.admin.tenants.view_org_chart", "전체 조직도 보기")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
@@ -191,6 +251,17 @@ function TenantListPage() {
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">
|
||||
<Checkbox
|
||||
checked={
|
||||
tenants.length > 0 &&
|
||||
selectedIds.length === tenants.length
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelectAll(!!checked)
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
@@ -217,7 +288,7 @@ function TenantListPage() {
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<TableCell colSpan={8}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -225,7 +296,7 @@ function TenantListPage() {
|
||||
{!query.isLoading && tenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
colSpan={8}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -237,6 +308,14 @@ function TenantListPage() {
|
||||
)}
|
||||
{tenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(tenant.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelect(tenant.id, !!checked)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">
|
||||
{tenant.name}
|
||||
</TableCell>
|
||||
|
||||
417
adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx
Normal file
417
adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { type UserSummary, fetchUsers } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type UserWithPath = UserSummary & { _path: { level: number; name: string }[] };
|
||||
|
||||
interface OrgNode {
|
||||
name: string;
|
||||
level: number;
|
||||
members: UserWithPath[];
|
||||
subData: UserWithPath[];
|
||||
children: OrgNode[];
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
export function TenantOrgChartPage() {
|
||||
const [selectedDept, setSelectedDept] = React.useState<string>("전체");
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [lines, setLines] = React.useState<
|
||||
{
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
key: string;
|
||||
path: string;
|
||||
}[]
|
||||
>([]);
|
||||
const [svgSize, setSvgSize] = React.useState({ width: 0, height: 0 });
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["users", { limit: 5000, offset: 0 }],
|
||||
queryFn: () => fetchUsers(5000, 0),
|
||||
});
|
||||
|
||||
const users = React.useMemo(() => {
|
||||
if (!query.data?.items) return [];
|
||||
return query.data.items
|
||||
.filter((u) => u.status === "active")
|
||||
.map((u) => {
|
||||
const parts = (u.department || "").split("/").filter(Boolean);
|
||||
return {
|
||||
...u,
|
||||
_path: parts.map((name, i) => ({ level: i, name })),
|
||||
};
|
||||
});
|
||||
}, [query.data]);
|
||||
|
||||
const depts = React.useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
for (const u of users) {
|
||||
if (u._path[0]) s.add(u._path[0].name);
|
||||
}
|
||||
return Array.from(s).sort();
|
||||
}, [users]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedDept !== "전체" && !depts.includes(selectedDept)) {
|
||||
setSelectedDept("전체");
|
||||
}
|
||||
}, [selectedDept, depts]);
|
||||
|
||||
const buildHierarchy = (data: UserWithPath[], depth: number): OrgNode[] => {
|
||||
if (!data.length) return [];
|
||||
const map: Record<string, OrgNode> = {};
|
||||
const groups: OrgNode[] = [];
|
||||
|
||||
for (const m of data) {
|
||||
const step = m._path[depth];
|
||||
if (!step) continue;
|
||||
if (!map[step.name]) {
|
||||
map[step.name] = {
|
||||
name: step.name,
|
||||
level: step.level,
|
||||
members: [],
|
||||
subData: [],
|
||||
children: [],
|
||||
};
|
||||
groups.push(map[step.name]);
|
||||
}
|
||||
if (m._path.length === depth + 1) {
|
||||
map[step.name].members.push(m);
|
||||
} else {
|
||||
map[step.name].subData.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
return groups.map((g) => ({
|
||||
...g,
|
||||
children: buildHierarchy(g.subData, depth + 1),
|
||||
}));
|
||||
};
|
||||
|
||||
const calculateTotalCount = (node: OrgNode): number => {
|
||||
let count = node.members.length;
|
||||
for (const c of node.children) {
|
||||
count += calculateTotalCount(c);
|
||||
}
|
||||
node.totalCount = count;
|
||||
return count;
|
||||
};
|
||||
|
||||
const drawLines = React.useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const container = containerRef.current;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const scrollTop = container.scrollTop;
|
||||
const scrollLeft = container.scrollLeft;
|
||||
const childBoxes = container.querySelectorAll("[data-parent]");
|
||||
const newLines: any[] = [];
|
||||
|
||||
for (const box of Array.from(childBoxes)) {
|
||||
const parentId = box.getAttribute("data-parent");
|
||||
if (!parentId) continue;
|
||||
const parent = document.getElementById(parentId);
|
||||
if (!parent) continue;
|
||||
|
||||
const pRect = parent.getBoundingClientRect();
|
||||
const cRect = box.getBoundingClientRect();
|
||||
|
||||
if (pRect.width === 0 || cRect.width === 0) continue;
|
||||
|
||||
const parentLevel = Number.parseInt(parent.getAttribute("data-level") || "0", 10);
|
||||
|
||||
if (parentLevel === 0) {
|
||||
// Horizontal fork for Level 0 -> Level 1
|
||||
const px = pRect.left + pRect.width / 2 - rect.left + scrollLeft;
|
||||
const py = pRect.bottom - rect.top + scrollTop;
|
||||
const cx = cRect.left + cRect.width / 2 - rect.left + scrollLeft;
|
||||
const cy = cRect.top - rect.top + scrollTop;
|
||||
const midY = py + (cy - py) / 2;
|
||||
|
||||
newLines.push({
|
||||
key: `${parentId}->${box.id}`,
|
||||
x1: px, y1: py, x2: cx, y2: cy,
|
||||
path: `M ${px} ${py} L ${px} ${midY} L ${cx} ${midY} L ${cx} ${cy}`,
|
||||
});
|
||||
} else {
|
||||
// Vertical spine for Level >= 1 -> Level >= 2
|
||||
const spineX = pRect.left + 32 - rect.left + scrollLeft; // 32px indent from parent's left edge
|
||||
const py = pRect.bottom - rect.top + scrollTop;
|
||||
const cx = cRect.left - rect.left + scrollLeft; // Child's left edge
|
||||
const cy = cRect.top + 24 - rect.top + scrollTop; // Middle of child's header
|
||||
|
||||
newLines.push({
|
||||
key: `${parentId}->${box.id}`,
|
||||
x1: spineX, y1: py, x2: cx, y2: cy,
|
||||
path: `M ${spineX} ${py} L ${spineX} ${cy} L ${cx} ${cy}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setLines(newLines);
|
||||
setSvgSize({
|
||||
width: Math.max(container.scrollWidth, rect.width),
|
||||
height: Math.max(container.scrollHeight, rect.height),
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const timeout = setTimeout(drawLines, 150);
|
||||
window.addEventListener("resize", drawLines);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
window.removeEventListener("resize", drawLines);
|
||||
};
|
||||
}, [drawLines, selectedDept, users]);
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center text-muted-foreground">로딩 중...</div>
|
||||
);
|
||||
}
|
||||
|
||||
const targetDepts = selectedDept === "전체" ? depts : [selectedDept];
|
||||
const totalUsers = targetDepts.reduce((acc, d) => {
|
||||
return acc + users.filter((u) => u._path[0]?.name === d).length;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-theme(spacing.32))] bg-slate-50 rounded-xl overflow-hidden shadow-sm border border-slate-200">
|
||||
<header className="flex items-center justify-between px-6 py-4 bg-white border-b border-slate-200 shadow-sm z-10 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" size="icon" asChild className="h-8 w-8">
|
||||
<Link to="/tenants">
|
||||
<ChevronLeft size={16} />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800">통합 조직도</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
조직 구조를 효율적인 세로 계층형으로 시각화합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto max-w-2xl custom-scrollbar pb-1">
|
||||
{["전체", ...depts].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedDept(d);
|
||||
setLines([]); // Reset lines during switch
|
||||
}}
|
||||
className={`whitespace-nowrap px-4 py-1.5 text-sm font-semibold rounded-full border transition-colors ${
|
||||
selectedDept === d
|
||||
? "bg-slate-800 text-white border-slate-800"
|
||||
: "bg-white text-slate-600 border-slate-200 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-2 whitespace-nowrap px-4 py-1.5 bg-blue-50 text-blue-700 font-bold rounded-full border border-blue-200 text-sm flex items-center">
|
||||
총 {totalUsers}명
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-auto relative p-8 bg-[#f8fafc]"
|
||||
ref={containerRef}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 left-0 pointer-events-none z-0"
|
||||
style={{
|
||||
width: svgSize.width ? `${svgSize.width}px` : "100%",
|
||||
height: svgSize.height ? `${svgSize.height}px` : "100%"
|
||||
}}
|
||||
>
|
||||
{lines.map((l) => (
|
||||
<path
|
||||
key={l.key}
|
||||
d={l.path}
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div className="flex flex-col gap-16 relative z-10 w-max mx-auto items-center pb-24">
|
||||
{targetDepts.map((dName) => {
|
||||
const dData = users.filter((u) => u._path[0]?.name === dName);
|
||||
const hierarchy = buildHierarchy(dData, 0);
|
||||
const dNode = hierarchy[0];
|
||||
if (!dNode) return null;
|
||||
calculateTotalCount(dNode);
|
||||
|
||||
return (
|
||||
<div key={dName} className="flex flex-col items-center w-full">
|
||||
<OrgNodeView
|
||||
node={dNode}
|
||||
parentId={null}
|
||||
onToggle={drawLines}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------- Node Rendering --------------------- //
|
||||
|
||||
const ROLE_ORDER = [
|
||||
"사장",
|
||||
"부사장",
|
||||
"전무",
|
||||
"상무",
|
||||
"이사",
|
||||
"수석",
|
||||
"책임",
|
||||
"선임",
|
||||
"주임",
|
||||
"사원",
|
||||
];
|
||||
|
||||
function getRankWeight(u: UserWithPath) {
|
||||
const role = u.position || "";
|
||||
let idx = ROLE_ORDER.indexOf(role);
|
||||
if (idx === -1) idx = 99;
|
||||
const isLeader = u.position?.endsWith("장") || u.jobTitle?.endsWith("장");
|
||||
return (isLeader ? -100 : 0) + idx;
|
||||
}
|
||||
|
||||
function OrgNodeView({
|
||||
node,
|
||||
parentId,
|
||||
onToggle,
|
||||
}: {
|
||||
node: OrgNode;
|
||||
parentId: string | null;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
const myId = `node-${node.level}-${node.name.replace(/\s/g, "")}`;
|
||||
|
||||
const toggle = () => {
|
||||
setCollapsed(!collapsed);
|
||||
setTimeout(onToggle, 100);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
|
||||
const membersToShow = [...node.members].sort(
|
||||
(a, b) => getRankWeight(a) - getRankWeight(b),
|
||||
);
|
||||
|
||||
const isVerticalChildren = node.level >= 1; // Children of Level 1+ are vertical
|
||||
const isVerticallyStacked = node.level >= 1; // Level 1+ are vertically stacked inside parent
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${isVerticallyStacked ? "items-start w-full" : "items-center"} mb-3`}>
|
||||
<div
|
||||
id={myId}
|
||||
data-parent={parentId || undefined}
|
||||
data-level={node.level}
|
||||
className={`bg-white border rounded-xl shadow-sm mb-4 flex flex-col transition-all shrink-0 ${
|
||||
node.level === 0 ? "border-slate-800 border-t-4" : "border-slate-300"
|
||||
} ${collapsed ? "opacity-80" : ""}`}
|
||||
style={{ width: "fit-content", minWidth: "260px", maxWidth: "400px" }}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`px-4 py-2 font-bold flex justify-between items-center cursor-pointer select-none hover:bg-slate-50 transition-colors rounded-t-xl outline-none focus-visible:ring-2 focus-visible:ring-primary ${
|
||||
node.level === 0
|
||||
? "text-slate-800 text-lg"
|
||||
: "text-slate-700 text-sm"
|
||||
}`}
|
||||
onClick={toggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
<span className="text-slate-400 font-normal text-xs ml-4">
|
||||
({node.totalCount})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!collapsed && membersToShow.length > 0 && (
|
||||
<div className="p-1.5 pt-0 grid grid-cols-2 gap-1 w-full">
|
||||
{membersToShow.map((m) => (
|
||||
<MemberCard key={m.id} member={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed && node.children.length > 0 && (
|
||||
<div
|
||||
className={`flex ${
|
||||
isVerticalChildren
|
||||
? "flex-col items-start pl-12 gap-4 w-full"
|
||||
: "flex-row gap-10 justify-center items-start"
|
||||
} relative`}
|
||||
>
|
||||
{node.children.map((c) => (
|
||||
<OrgNodeView
|
||||
key={c.name}
|
||||
node={c}
|
||||
parentId={myId}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberCard({ member }: { member: UserWithPath }) {
|
||||
const coColor = (() => {
|
||||
const c = (member.companyCode || "").toLowerCase();
|
||||
if (c.includes("hanmac")) return "bg-[#1E3A8A] text-white border-[#1E3A8A]";
|
||||
if (c.includes("saman")) return "bg-[#047857] text-white border-[#047857]";
|
||||
if (c.includes("ptc")) return "bg-[#C2410C] text-white border-[#C2410C]";
|
||||
if (c.includes("baron")) return "bg-[#4338CA] text-white border-[#4338CA]";
|
||||
return "bg-slate-600 text-white border-slate-700";
|
||||
})();
|
||||
|
||||
const roleBadge = member.jobTitle && member.jobTitle !== member.position
|
||||
? member.jobTitle
|
||||
: (member.position?.endsWith("장") ? member.position : null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex items-center px-1.5 h-[30px] rounded border shadow-sm overflow-hidden w-full leading-none ${coColor}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-0 w-full">
|
||||
<div className="flex items-baseline gap-1 truncate shrink-0">
|
||||
<span className="font-bold text-[11px] whitespace-nowrap">{member.name}</span>
|
||||
{member.position && member.position !== roleBadge && (
|
||||
<span className="text-[10px] opacity-90 whitespace-nowrap font-medium">{member.position}</span>
|
||||
)}
|
||||
</div>
|
||||
{roleBadge && (
|
||||
<span className="bg-white/20 text-[9px] px-1 py-[1.5px] rounded-[3px] font-bold tracking-tight shrink-0 whitespace-nowrap ml-auto">
|
||||
{roleBadge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -72,7 +72,7 @@ function UserListPage() {
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||
const limit = 50;
|
||||
const limit = 1000;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
@@ -190,13 +190,14 @@ function UserListPage() {
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: bulkDeleteUsers,
|
||||
onSuccess: () => {
|
||||
onSuccess: (_, variables) => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.bulk.delete_success",
|
||||
"선택한 사용자들이 삭제되었습니다.",
|
||||
"{{count}}명의 사용자가 삭제되었습니다.",
|
||||
{ count: variables.length },
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -493,9 +494,18 @@ function UserListPage() {
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onChange={() => toggleSelectUser(user.id)}
|
||||
disabled={user.id === profile?.id}
|
||||
title={
|
||||
user.id === profile?.id
|
||||
? t(
|
||||
"msg.admin.users.self_delete_blocked",
|
||||
"본인 계정은 삭제할 수 없습니다.",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -559,9 +569,20 @@ function UserListPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
className="text-destructive hover:text-destructive disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
onClick={() => handleDelete(user.id, user.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
disabled={
|
||||
deleteMutation.isPending ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
title={
|
||||
user.id === profile?.id
|
||||
? t(
|
||||
"msg.admin.users.self_delete_blocked",
|
||||
"본인 계정은 삭제할 수 없습니다.",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
|
||||
@@ -74,15 +74,14 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = "email,name,phone,role,tenant,department,employee_id";
|
||||
const headers = "email,name,phone,role,tenant,department,position,jobTitle,employee_id";
|
||||
const example =
|
||||
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
|
||||
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
|
||||
const blob = new Blob(
|
||||
[
|
||||
`${headers}
|
||||
${example}`,
|
||||
`${headers}\n${example}`,
|
||||
],
|
||||
{ type: "text/csv" },
|
||||
{ type: "text/csv;charset=utf-8;" },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
|
||||
@@ -34,6 +34,10 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
item.tenantSlug = value;
|
||||
} else if (header === "department") {
|
||||
item.department = value;
|
||||
} else if (header === "position") {
|
||||
item.position = value;
|
||||
} else if (header === "jobtitle") {
|
||||
item.jobTitle = value;
|
||||
} else {
|
||||
item.metadata[header] = value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user