1
0
forked from baron/baron-sso

chore: snapshot local state before dev merge

This commit is contained in:
2026-06-17 21:25:42 +09:00
parent b2808759d2
commit 49560e8a8c
107 changed files with 8958 additions and 939 deletions

View File

@@ -104,6 +104,10 @@ import {
type TenantImportPreviewRow,
type TenantImportResolution,
} from "../utils/tenantCsvImport";
import {
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
} from "../utils/orgConfig";
import {
filterTenantsByScope,
filterTenantViewRowsBySearch,
@@ -119,14 +123,30 @@ const tenantCSVTemplate =
const tenantPageSize = 500;
const _tenantVirtualizationThreshold = 250;
const _tenantEstimatedRowHeight = 73;
type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
const tenantTableHeadClassName =
"h-9 px-3 py-1 text-xs leading-tight align-middle whitespace-nowrap";
const tenantTableHeadInteractiveClassName = `${tenantTableHeadClassName} cursor-pointer transition-colors hover:bg-muted/50`;
const tenantTableHeadContentClassName = "flex h-full items-center gap-1";
const _tenantLoadAheadPx = 360;
const _tenantLoadAheadRows = 30;
type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
const backendTenantSortKeys = new Set<TenantSortKey>([
"createdAt",
"id",
"name",
"slug",
"status",
"type",
"updatedAt",
]);
const bulkTenantTypeOptions = [
{ value: "COMPANY", label: "COMPANY (일반 기업)" },
{ value: "COMPANY_GROUP", label: "COMPANY_GROUP (그룹사/지주사)" },
{ value: "ORGANIZATION", label: "ORGANIZATION (정규 조직)" },
{ value: "USER_GROUP", label: "USER_GROUP (내부 부서/팀)" },
{ value: "PERSONAL", label: "PERSONAL (개인 워크스페이스)" },
] as const;
const getTenantIcon = (type?: string) => {
switch (type?.toUpperCase()) {
@@ -370,6 +390,10 @@ function TenantListPage() {
const [search, setSearch] = React.useState("");
const debouncedSearch = React.useDeferredValue(search.trim());
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState("");
const [selectedBulkType, setSelectedBulkType] = React.useState("");
const [selectedBulkVisibility, setSelectedBulkVisibility] = React.useState<
TenantVisibility | ""
>("");
const _tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
const { data: profile } = useQuery({
@@ -380,9 +404,20 @@ function TenantListPage() {
const isWritable =
profileRole === "super_admin" ||
!!profile?.systemPermissions?.manage_tenants;
const backendSortKey =
sortConfig && backendTenantSortKeys.has(sortConfig.key)
? sortConfig.key
: undefined;
const query = useInfiniteQuery({
queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
queryKey: [
"tenants",
"lazy",
debouncedSearch,
scopeTenantId,
backendSortKey,
sortConfig?.direction,
],
queryFn: ({ pageParam }) =>
fetchTenants(
tenantPageSize,
@@ -390,12 +425,19 @@ function TenantListPage() {
scopeTenantId || undefined,
pageParam ? (pageParam as string) : undefined,
debouncedSearch,
backendSortKey,
sortConfig?.direction,
),
initialPageParam: "",
getNextPageParam: (lastPage) =>
lastPage.nextCursor || lastPage.next_cursor || undefined,
});
const rawTenants = React.useMemo(
() => query.data?.pages.flatMap((page) => page.items) ?? [],
[query.data?.pages],
);
const deleteBulkMutation = useMutation({
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
onSuccess: () => {
@@ -404,21 +446,37 @@ function TenantListPage() {
},
});
const bulkUpdateStatusMutation = useMutation({
const bulkUpdateTenantsMutation = useMutation({
mutationFn: async ({
tenantIds,
status,
type,
visibility,
}: {
tenantIds: string[];
status: string;
status?: string;
type?: string;
visibility?: TenantVisibility;
}) => {
// Execute sequential updates to avoid rate limits or partial failures
await Promise.all(tenantIds.map((id) => updateTenant(id, { status })));
await Promise.all(
tenantIds.map((id) => {
const source = rawTenants.find((tenant) => tenant.id === id);
return updateTenant(id, {
...(status ? { status } : {}),
...(type ? { type } : {}),
...(visibility
? { config: { ...(source?.config ?? {}), visibility } }
: {}),
});
}),
);
},
onSuccess: () => {
query.refetch();
setSelectedIds([]);
setSelectedBulkStatus("");
setSelectedBulkType("");
setSelectedBulkVisibility("");
toast.success(
t(
"msg.admin.tenants.bulk.update_success",
@@ -437,10 +495,19 @@ function TenantListPage() {
});
const handleApplyBulkStatus = () => {
if (selectedIds.length === 0 || !selectedBulkStatus) return;
bulkUpdateStatusMutation.mutate({
if (
selectedIds.length === 0 ||
(!selectedBulkStatus && !selectedBulkType && !selectedBulkVisibility)
) {
return;
}
bulkUpdateTenantsMutation.mutate({
tenantIds: selectedIds,
status: selectedBulkStatus,
...(selectedBulkStatus ? { status: selectedBulkStatus } : {}),
...(selectedBulkType ? { type: selectedBulkType } : {}),
...(selectedBulkVisibility
? { visibility: selectedBulkVisibility }
: {}),
});
};
@@ -491,11 +558,6 @@ function TenantListPage() {
},
});
const rawTenants = React.useMemo(
() => query.data?.pages.flatMap((page) => page.items) ?? [],
[query.data?.pages],
);
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
@@ -1067,15 +1129,66 @@ function TenantListPage() {
</SelectItem>
</SelectContent>
</Select>
<Select value={selectedBulkType} onValueChange={setSelectedBulkType}>
<SelectTrigger
className="h-8 w-[180px] bg-transparent border-background/20 text-background text-xs"
data-testid="tenant-bulk-type-select"
>
<SelectValue
placeholder={t(
"ui.admin.tenants.bulk.type_placeholder",
"유형 선택",
)}
/>
</SelectTrigger>
<SelectContent>
{bulkTenantTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(
`domain.tenant_type.${option.value.toLowerCase()}`,
option.label,
)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedBulkVisibility}
onValueChange={(value) =>
setSelectedBulkVisibility(value as TenantVisibility)
}
>
<SelectTrigger
className="h-8 w-[130px] bg-transparent border-background/20 text-background text-xs"
data-testid="tenant-bulk-visibility-select"
>
<SelectValue
placeholder={t(
"ui.admin.tenants.bulk.visibility_placeholder",
"공개 범위",
)}
/>
</SelectTrigger>
<SelectContent>
{TENANT_VISIBILITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={handleApplyBulkStatus}
disabled={
!selectedBulkStatus || bulkUpdateStatusMutation.isPending
(!selectedBulkStatus &&
!selectedBulkType &&
!selectedBulkVisibility) ||
bulkUpdateTenantsMutation.isPending
}
data-testid="tenant-bulk-apply-status-btn"
data-testid="tenant-bulk-apply-btn"
>
{t("ui.common.apply", "적용")}
</Button>

View File

@@ -0,0 +1,81 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../../test/i18nMock";
import { TenantProfilePage } from "./TenantProfilePage";
const fetchAllTenantsMock = vi.hoisted(() => vi.fn());
const fetchTenantMock = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/i18n", () => createI18nMock());
vi.mock("../../../lib/adminApi", () => ({
approveTenant: vi.fn(),
deleteTenant: vi.fn(),
fetchAllTenants: fetchAllTenantsMock,
fetchMe: vi.fn(async () => ({
id: "admin-1",
role: "super_admin",
})),
fetchTenant: fetchTenantMock,
updateTenant: vi.fn(),
}));
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/tenants/tenant-leaf"]}>
{ui}
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantProfilePage initial profile loading", () => {
beforeEach(() => {
vi.clearAllMocks();
fetchTenantMock.mockResolvedValue({
id: "tenant-leaf",
type: "USER_GROUP",
parentId: "tenant-company",
name: "기술기획",
slug: "tech-planning",
description: "",
status: "active",
domains: [],
memberCount: 0,
config: {
orgUnitType: "팀",
visibility: "internal",
worksmobileExcluded: true,
},
createdAt: "2026-06-17T00:00:00Z",
updatedAt: "2026-06-17T00:00:00Z",
});
});
it("renders tenant config fields from the tenant response before the full tenant list resolves", async () => {
fetchAllTenantsMock.mockReturnValue(new Promise(() => undefined));
renderWithProviders(
<Routes>
<Route path="/tenants/:tenantId" element={<TenantProfilePage />} />
</Routes>,
);
expect(await screen.findByDisplayValue("기술기획")).toBeInTheDocument();
expect(screen.getByTestId("tenant-org-unit-type-select")).toHaveValue("팀");
expect(screen.getByLabelText("공개 범위")).toHaveValue("internal");
expect(screen.getByLabelText("WORKS 연동")).toHaveValue("excluded");
expect(fetchAllTenantsMock).not.toHaveBeenCalled();
});
});

View File

@@ -57,11 +57,6 @@ export function TenantProfilePage() {
const isWritable = hasPermission("manage_profile") || hasPermission("manage");
const canView = hasPermission("view_profile") || hasPermission("view");
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
});
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
@@ -94,6 +89,16 @@ export function TenantProfilePage() {
}
}, [tenantQuery.data]);
const hasPersistedOrgConfig =
tenantQuery.data?.slug?.toLowerCase() !== "hanmac-family" &&
(typeof tenantQuery.data?.config?.orgUnitType === "string" ||
typeof tenantQuery.data?.config?.visibility === "string" ||
typeof tenantQuery.data?.config?.worksmobileExcluded !== "undefined");
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
enabled: !!tenantQuery.data && !hasPersistedOrgConfig,
});
const allTenants = parentQuery.data?.items ?? [];
const orgConfigCandidate = tenantQuery.data
? {
@@ -103,7 +108,8 @@ export function TenantProfilePage() {
}
: undefined;
const canEditOrgConfig = orgConfigCandidate
? shouldAllowHanmacOrgConfig(orgConfigCandidate, [
? hasPersistedOrgConfig ||
shouldAllowHanmacOrgConfig(orgConfigCandidate, [
...allTenants,
orgConfigCandidate,
])
@@ -361,7 +367,10 @@ export function TenantProfilePage() {
data-testid="tenant-org-unit-type-slot"
className="space-y-1"
>
<Label className="text-sm font-semibold">
<Label
htmlFor="tenant-org-unit-type"
className="text-sm font-semibold"
>
{t(
"ui.admin.tenants.profile.org_unit_type",
"조직 세부타입",
@@ -385,7 +394,10 @@ export function TenantProfilePage() {
</select>
</div>
<div data-testid="tenant-visibility-slot" className="space-y-1">
<Label className="text-sm font-semibold">
<Label
htmlFor="tenant-visibility"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
<select
@@ -411,7 +423,10 @@ export function TenantProfilePage() {
data-testid="tenant-worksmobile-excluded-slot"
className="space-y-1"
>
<Label className="text-sm font-semibold">
<Label
htmlFor="worksmobileExcluded"
className="text-sm font-semibold"
>
{t(
"ui.admin.tenants.profile.worksmobile_sync",
"WORKS 연동",

View File

@@ -624,6 +624,52 @@ describe("TenantWorksmobilePage comparison helpers", () => {
).toEqual(["조직: Baron 소속 정보를 WORKS에 반영해야 합니다."]);
});
it("formats grade update reasons with before and after values", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
externalKey: "user-1",
baronName: "신현우",
worksmobileName: "신현우",
baronGrade: "책임",
worksmobileLevelName: "선임",
updateReasons: ["grade"],
}),
).toEqual(["직급: 선임 -> 책임"]);
});
it("formats grade update reasons with matched WORKS membership", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
externalKey: "user-1",
baronName: "연구원",
worksmobileName: "연구원",
baronGrade: "책임연구원",
worksmobileLevelName: "",
updateReasons: ["grade"],
userMemberships: [
{
baronOrgId: "1d74bebb-c5a1-49d4-bec4-90f0c89ad21f",
baronOrgSlug: "hmeg",
baronOrgName: "HmEG",
baronGrade: "책임연구원",
worksmobileOrgId: "works-hmeg",
worksmobileOrgName: "WORKS HmEG",
worksmobileDomainName: "baroncs.co.kr",
gradeNeedsUpdate: true,
},
],
}),
).toEqual([
"직급: 없음 -> 책임연구원 (Baron HmEG / WORKS WORKS HmEG)",
]);
});
it("does not format phone update details for spaced Korean country code formatting only", () => {
expect(
formatWorksmobileUpdateDetails({

View File

@@ -68,6 +68,7 @@ import {
formatWorksmobilePersonName,
formatWorksmobileSelectionFailureDescription,
formatWorksmobileUpdateDetails,
formatWorksmobileUserMembershipDetails,
getDefaultGroupComparisonFilters,
getDefaultUserComparisonFilters,
getDefaultWorksmobileComparisonColumns,
@@ -813,7 +814,7 @@ const worksmobileComparisonColumnOptions: Array<{
{ key: "externalKey", label: "external_key" },
{ key: "worksmobileDomain", label: "WORKS 도메인" },
{ key: "worksmobile", label: "WORKS" },
{ key: "worksmobileOrg", label: "상위 Works 조직" },
{ key: "worksmobileOrg", label: "WORKS 조직 매칭" },
{ key: "manage", label: "관리" },
];
@@ -832,7 +833,7 @@ const worksmobileComparisonColumnWidths: Record<
worksmobileDomain: 160,
worksmobileId: 176,
worksmobile: 220,
worksmobileOrg: 260,
worksmobileOrg: 320,
manage: 112,
};
const worksmobileComparisonTableHeadClassName =
@@ -1539,7 +1540,7 @@ function ComparisonTable({
<div
className={worksmobileComparisonTableHeadContentClassName}
>
Works
WORKS
</div>
</TableHead>
)}
@@ -1724,33 +1725,17 @@ function ComparisonTable({
)}
{isColumnVisible("worksmobileOrg") && (
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? getWorksmobileParentName(row)
: row.worksmobilePrimaryOrgName
}
email={
row.resourceType === "GROUP"
? getWorksmobileParentEmail(row)
: undefined
}
id={
row.resourceType === "GROUP"
? row.worksmobileParentId
: row.worksmobilePrimaryOrgId
}
details={
row.resourceType === "GROUP"
? formatWorksmobileParentOrgDetails(row)
: formatWorksmobileOrgDetails(row)
}
missingLabel={
row.resourceType === "GROUP"
? "상위 Works 조직 정보 없음"
: undefined
}
/>
{row.resourceType === "USER" ? (
<ComparisonUserMembershipCell row={row} />
) : (
<ComparisonOrgCell
name={getWorksmobileParentName(row)}
email={getWorksmobileParentEmail(row)}
id={row.worksmobileParentId}
details={formatWorksmobileParentOrgDetails(row)}
missingLabel="상위 Works 조직 정보 없음"
/>
)}
</TableCell>
)}
{showManageColumn && isColumnVisible("manage") && (
@@ -1893,6 +1878,33 @@ function formatWorksmobileParentOrgDetails(row: WorksmobileComparisonItem) {
return details;
}
function ComparisonUserMembershipCell({
row,
}: {
row: WorksmobileComparisonItem;
}) {
const membershipDetails = formatWorksmobileUserMembershipDetails(row);
if (membershipDetails.length > 0) {
return (
<div className="space-y-1">
{membershipDetails.map((detail) => (
<div key={detail} className="text-xs leading-relaxed">
{detail}
</div>
))}
</div>
);
}
return (
<ComparisonOrgCell
name={row.worksmobilePrimaryOrgName}
id={row.worksmobilePrimaryOrgId}
details={formatWorksmobileOrgDetails(row)}
/>
);
}
function ComparisonOrgCell({
name,
email,

View File

@@ -365,6 +365,32 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
return details;
}
export function formatWorksmobileUserMembershipDetails(
row: WorksmobileComparisonItem,
) {
return (row.userMemberships ?? []).map((membership) => {
const baronOrg =
membership.baronOrgName?.trim() ||
membership.baronOrgSlug?.trim() ||
membership.baronOrgId?.trim() ||
"Baron 조직";
const worksOrg =
membership.worksmobileOrgName?.trim() ||
membership.worksmobileOrgId?.trim() ||
"WORKS 조직 없음";
const details = [
membership.baronPrimary ? "기본" : "겸직",
`Baron ${baronOrg}`,
`WORKS ${worksOrg}`,
membership.worksmobileLevelName?.trim() ||
membership.worksmobileLevelId?.trim()
? `직급 ${membership.worksmobileLevelName?.trim() || membership.worksmobileLevelId?.trim()}`
: "",
].filter(Boolean);
return details.join(" / ");
});
}
export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
if (row.status === "missing_in_worksmobile" && row.worksmobileLastError) {
return [`최근 실패: ${row.worksmobileLastError}`];
@@ -420,6 +446,20 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
`사번: ${actualEmployeeNumber} -> ${expectedEmployeeNumber}`,
);
}
const expectedGrade = row.baronGrade?.trim() ?? "";
const actualGrade =
row.worksmobileLevelName?.trim() ?? row.worksmobileLevelId?.trim() ?? "";
if (
row.updateReasons?.includes("grade") &&
(expectedGrade || actualGrade) &&
expectedGrade !== actualGrade
) {
const membershipContext = formatWorksmobileGradeMembershipContext(row);
addDetail(
"grade",
`직급: ${actualGrade || "없음"} -> ${expectedGrade || "없음"}${membershipContext}`,
);
}
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
return details;
}
@@ -484,6 +524,17 @@ function formatWorksmobileUpdateReasonFallback(
return "전화번호: Baron 전화번호를 WORKS에 반영해야 합니다.";
case "employee_number":
return "사번: Baron 사번을 WORKS에 반영해야 합니다.";
case "grade": {
const expectedGrade = row.baronGrade?.trim() ?? "";
const actualGrade =
row.worksmobileLevelName?.trim() ??
row.worksmobileLevelId?.trim() ??
"";
if (expectedGrade || actualGrade) {
return `직급: ${actualGrade || "없음"} -> ${expectedGrade || "없음"}${formatWorksmobileGradeMembershipContext(row)}`;
}
return "직급: Baron 직급 정보를 WORKS에 반영해야 합니다.";
}
case "organization":
return row.resourceType === "GROUP"
? "조직: Baron 조직 정보를 WORKS에 반영해야 합니다."
@@ -495,6 +546,32 @@ function formatWorksmobileUpdateReasonFallback(
}
}
function formatWorksmobileGradeMembershipContext(
row: WorksmobileComparisonItem,
) {
const membership =
row.userMemberships?.find((item) => item.gradeNeedsUpdate) ??
row.userMemberships?.find(
(item) =>
item.baronGrade?.trim() &&
item.baronGrade?.trim() === row.baronGrade?.trim(),
);
if (!membership) {
return "";
}
const baronOrg =
membership.baronOrgName?.trim() ||
membership.baronOrgSlug?.trim() ||
membership.baronOrgId?.trim();
const worksOrg =
membership.worksmobileOrgName?.trim() ||
membership.worksmobileOrgId?.trim();
if (!baronOrg && !worksOrg) {
return "";
}
return ` (Baron ${baronOrg || "없음"} / WORKS ${worksOrg || "없음"})`;
}
function normalizeWorksmobilePhoneForCompare(value: string) {
const trimmed = value.trim();
if (!trimmed) {