1
0
forked from baron/baron-sso

ci: add code check badges and coverage reports

This commit is contained in:
2026-05-29 12:05:43 +09:00
parent c489c7c38f
commit a830242947
164 changed files with 9059 additions and 2012 deletions

View File

@@ -38,21 +38,21 @@ import {
TabsTrigger,
} from "../../components/ui/tabs";
import {
type TenantSummary,
type UserAppointment,
type UserCreateRequest,
type UserCreateResponse,
createUser,
fetchAllTenants,
fetchMe,
fetchTenant,
type TenantSummary,
type UserAppointment,
type UserCreateRequest,
type UserCreateResponse,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import {
type OrgChartTenantSelection,
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields";

View File

@@ -59,10 +59,8 @@ import {
TabsTrigger,
} from "../../components/ui/tabs";
import { toast } from "../../components/ui/use-toast";
import type { PasswordPolicyResponse } from "../../lib/adminApi";
import {
type TenantSummary,
type UserAppointment,
type UserUpdateRequest,
deleteUser,
fetchAllTenants,
fetchMe,
@@ -70,18 +68,20 @@ import {
fetchTenant,
fetchUser,
fetchUserRpHistory,
type TenantSummary,
type UserAppointment,
type UserUpdateRequest,
updateUser,
} from "../../lib/adminApi";
import type { PasswordPolicyResponse } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { normalizeAdminRole } from "../../lib/roles";
import { generateSecurePassword } from "../../lib/utils";
import {
type OrgChartTenantSelection,
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
isHanmacFamilyTenant,
isHanmacFamilyUser,
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields";

View File

@@ -22,6 +22,8 @@ const users = Array.from({ length: 200 }, (_, index) => ({
}));
const fetchUsersMock = vi.hoisted(() => vi.fn());
const searchRenderBudgetMs =
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 200;
vi.mock("../../lib/i18n", () => createI18nMock());
@@ -93,16 +95,21 @@ function renderUserListPage() {
);
}
function createDeferred<T>() {
let resolve: (value: T) => void = () => {};
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
describe("UserListPage search rendering", () => {
beforeEach(() => {
selectRenderCounter.count = 0;
fetchUsersMock.mockReset();
fetchUsersMock.mockImplementation(
async (
_limit: number,
_offset: number,
search?: string,
) => {
async (_limit: number, _offset: number, search?: string) => {
const normalizedSearch = search?.trim().toLowerCase();
const items = normalizedSearch
? users.filter((user) =>
@@ -119,7 +126,7 @@ describe("UserListPage search rendering", () => {
it("does not rerender user table controls while typing a draft search", async () => {
renderUserListPage();
await screen.findByText("User 199");
await screen.findByText("User 0");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
const renderCountBeforeTyping = selectRenderCounter.count;
@@ -129,20 +136,57 @@ describe("UserListPage search rendering", () => {
expect(selectRenderCounter.count).toBe(renderCountBeforeTyping);
});
it("keeps rendered row controls below the full 200-user result set", async () => {
renderUserListPage();
await screen.findByText("User 0");
expect(screen.getAllByTestId(/^user-status-select-/).length).toBeLessThan(
200,
);
});
it("renders compact vertically centered user table headers", async () => {
renderUserListPage();
await screen.findByText("User 0");
const nameHeader = screen.getByRole("columnheader", { name: /이름/ });
const content = nameHeader.firstElementChild;
expect(nameHeader).toHaveClass("h-9", "py-1", "align-middle", "text-xs");
expect(content).toHaveClass("flex", "h-full", "items-center");
});
it("centers the initial loading message across the user table", async () => {
const deferred = createDeferred<{ items: typeof users; total: number }>();
fetchUsersMock.mockReturnValueOnce(deferred.promise);
renderUserListPage();
const loadingCell = await screen.findByTestId("user-table-loading-cell");
expect(loadingCell).toHaveClass(
"flex",
"items-center",
"justify-center",
"text-center",
);
expect(loadingCell).toHaveStyle({ gridColumn: "1 / -1" });
deferred.resolve({ items: users, total: users.length });
});
it("renders a 200-user search result update within 200ms after search submit", async () => {
renderUserListPage();
await screen.findByText("User 199");
await screen.findByText("User 0");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
const startedAt = performance.now();
fireEvent.change(searchInput, { target: { value: "user 19" } });
fireEvent.keyDown(searchInput, { key: "Enter" });
await screen.findByText("User 19");
await waitFor(() => {
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
});
expect(performance.now() - startedAt).toBeLessThan(200);
expect(screen.getByText("User 19")).toBeInTheDocument();
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
});
});

View File

@@ -1,4 +1,10 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import {
observeElementRect,
type Rect,
useVirtualizer,
type Virtualizer,
} from "@tanstack/react-virtual";
import type { AxiosError } from "axios";
import {
ArrowDown,
@@ -7,7 +13,6 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
Users,
Download,
FileDown,
FileSpreadsheet,
@@ -19,13 +24,13 @@ import {
ShieldCheck,
Trash2,
Upload,
Users,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
import {
SortableTableHead,
sortableTableHeadBaseClassName,
sortableTableHeaderClassName,
} from "../../../../common/core/components/sort";
import {
@@ -81,8 +86,6 @@ import {
} from "../../components/ui/table";
import { toast } from "../../components/ui/use-toast";
import {
type TenantSummary,
type UserSummary,
bulkDeleteUsers,
bulkUpdateUsers,
deleteUser,
@@ -91,13 +94,15 @@ import {
fetchMe,
fetchTenant,
fetchUsers,
type TenantSummary,
type UserSummary,
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import {
UserBulkUploadModal,
downloadUserTemplate,
UserBulkUploadModal,
} from "./components/UserBulkUploadModal";
import {
normalizeUserStatusValue,
@@ -114,6 +119,23 @@ type UserSchemaField = {
type UserSortKey = string;
const USER_ROW_ESTIMATED_HEIGHT = 64;
const USER_ROW_OVERSCAN = 8;
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
const userMetadataColumnWidth = 160;
const userCreatedColumnWidth = 150;
type UserRowVirtualizer = Virtualizer<HTMLDivElement, HTMLTableRowElement>;
const userTableHeadClassName =
"h-9 px-3 py-1 text-xs leading-tight align-middle whitespace-nowrap";
const userTableHeadInteractiveClassName = `${userTableHeadClassName} cursor-pointer transition-colors hover:bg-muted/50`;
const userTableHeadContentClassName = "flex h-full items-center gap-1";
const userSortableTableHeadClassName =
"!h-9 !px-3 !py-1 leading-tight whitespace-nowrap";
const userSortableTableHeadContentClassName = "h-full items-center";
const userTableStateCellClassName =
"flex h-24 items-center justify-center p-0 text-center text-sm text-muted-foreground";
const bulkPermissionOptions = [
{
value: "super_admin",
@@ -137,15 +159,24 @@ function userMatchesSearch(user: UserSummary, search: string) {
return true;
}
return [
user.name,
user.email,
user.phone,
user.id,
user.tenantSlug,
user.tenant?.name,
user.department,
].some((value) => value?.toLowerCase().includes(normalizedSearch));
return (
user.name?.toLowerCase().includes(normalizedSearch) ||
user.email?.toLowerCase().includes(normalizedSearch) ||
user.phone?.toLowerCase().includes(normalizedSearch) ||
user.id?.toLowerCase().includes(normalizedSearch) ||
user.tenantSlug?.toLowerCase().includes(normalizedSearch) ||
user.tenant?.name?.toLowerCase().includes(normalizedSearch) ||
user.department?.toLowerCase().includes(normalizedSearch) ||
false
);
}
function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
return {
width: rect.width > 0 ? rect.width : fallbackWidth,
height:
rect.height > 0 ? rect.height : USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT,
};
}
type UserListSearchControlsProps = {
@@ -253,6 +284,7 @@ function UserListPage() {
const [sortConfig, setSortConfig] =
React.useState<SortConfig<UserSortKey> | null>(null);
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
const userTableViewportRef = React.useRef<HTMLDivElement | null>(null);
const limit = 1000;
const offset = (page - 1) * limit;
@@ -417,8 +449,55 @@ function UserListPage() {
[userSchema],
);
const items = React.useMemo(() => {
if (!sortConfig) {
return rawItems;
}
return sortItems(rawItems, sortConfig, userSortResolvers);
}, [rawItems, sortConfig, userSortResolvers]);
const visibleUserSchemaFields = React.useMemo(
() => userSchema.filter((field) => visibleColumns[field.key] !== false),
[userSchema, visibleColumns],
);
const userTableColumnWidths = React.useMemo(
() => [
...userFixedColumnWidths,
...visibleUserSchemaFields.map(() => userMetadataColumnWidth),
userCreatedColumnWidth,
],
[visibleUserSchemaFields],
);
const userTableGridTemplateColumns = React.useMemo(
() => userTableColumnWidths.map((width) => `${width}px`).join(" "),
[userTableColumnWidths],
);
const userTableMinWidth = React.useMemo(
() => userTableColumnWidths.reduce((sum, width) => sum + width, 0),
[userTableColumnWidths],
);
const observeUserTableElementRect = React.useCallback(
(instance: UserRowVirtualizer, callback: (rect: Rect) => void) =>
observeElementRect(instance, (rect) => {
callback(normalizeUserTableRect(rect, userTableMinWidth));
}),
[userTableMinWidth],
);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => userTableViewportRef.current,
estimateSize: () => USER_ROW_ESTIMATED_HEIGHT,
measureElement: (element) =>
element.getBoundingClientRect().height || USER_ROW_ESTIMATED_HEIGHT,
observeElementRect: observeUserTableElementRect,
overscan: USER_ROW_OVERSCAN,
initialRect: {
width: userTableMinWidth,
height: USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT,
},
});
const virtualRows = rowVirtualizer.getVirtualItems();
const shouldVirtualizeRows = !query.isLoading && items.length > 0;
const tableColumnCount = 9 + visibleUserSchemaFields.length;
const requestSort = (key: UserSortKey) => {
setSortConfig((current) => toggleSort(current, key));
@@ -715,82 +794,92 @@ function UserListPage() {
)}
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table>
<div
ref={userTableViewportRef}
data-testid="user-table-viewport"
className={commonTableViewportClass}
>
<Table style={{ display: "grid", minWidth: userTableMinWidth }}>
<TableHeader className={sortableTableHeaderClassName}>
<TableRow>
<TableHead
className={`${sortableTableHeadBaseClassName} w-12`}
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
checked={
items.length > 0 &&
selectedUserIds.length === items.length
}
onChange={toggleSelectAll}
/>
<TableRow
style={{
display: "grid",
gridTemplateColumns: userTableGridTemplateColumns,
minWidth: userTableMinWidth,
}}
>
<TableHead className={`${userTableHeadClassName} w-12`}>
<div className="flex h-full items-center justify-center">
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
checked={
items.length > 0 &&
selectedUserIds.length === items.length
}
onChange={toggleSelectAll}
/>
</div>
</TableHead>
<TableHead
className="min-w-[120px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={`${userTableHeadInteractiveClassName} min-w-[120px]`}
onClick={() => requestSort("name")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.name", "이름")}
{getSortIcon("name")}
</div>
</TableHead>
<TableHead
className="min-w-[180px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={`${userTableHeadInteractiveClassName} min-w-[180px]`}
onClick={() => requestSort("email")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.email", "이메일")}
{getSortIcon("email")}
</div>
</TableHead>
<TableHead
className="min-w-[140px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={`${userTableHeadInteractiveClassName} min-w-[140px]`}
onClick={() => requestSort("phone")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.phone", "전화번호")}
{getSortIcon("phone")}
</div>
</TableHead>
<TableHead
className="min-w-[220px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={`${userTableHeadInteractiveClassName} min-w-[220px]`}
onClick={() => requestSort("id")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.id", "ID")}
{getSortIcon("id")}
</div>
</TableHead>
<TableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={userTableHeadInteractiveClassName}
onClick={() => requestSort("status")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.status", "STATUS")}
{getSortIcon("status")}
</div>
</TableHead>
<TableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={userTableHeadInteractiveClassName}
onClick={() => requestSort("role")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.role", "ROLE")}
{getSortIcon("role")}
</div>
</TableHead>
<TableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
className={userTableHeadInteractiveClassName}
onClick={() => requestSort("tenant_dept")}
>
<div className="flex items-center">
<div className={userTableHeadContentClassName}>
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
@@ -799,21 +888,20 @@ function UserListPage() {
</div>
</TableHead>
{/* Dynamic Columns from Schema */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<SortableTableHead
key={field.key}
className="whitespace-nowrap"
label={field.label}
onSort={requestSort}
sortConfig={sortConfig}
sortKey={field.key}
/>
),
)}
{visibleUserSchemaFields.map((field) => (
<SortableTableHead
key={field.key}
className={userSortableTableHeadClassName}
contentClassName={userSortableTableHeadContentClassName}
label={field.label}
onSort={requestSort}
sortConfig={sortConfig}
sortKey={field.key}
/>
))}
<SortableTableHead
className="whitespace-nowrap"
className={userSortableTableHeadClassName}
contentClassName={userSortableTableHeadContentClassName}
label={t("ui.admin.users.list.table.created", "CREATED")}
onSort={requestSort}
sortConfig={sortConfig}
@@ -821,22 +909,51 @@ function UserListPage() {
/>
</TableRow>
</TableHeader>
<TableBody>
<TableBody
style={
shouldVirtualizeRows
? {
display: "grid",
height: `${rowVirtualizer.getTotalSize()}px`,
minWidth: userTableMinWidth,
position: "relative",
}
: undefined
}
>
{query.isLoading && (
<TableRow>
<TableRow
data-testid="user-table-loading-row"
style={{
display: "grid",
gridTemplateColumns: userTableGridTemplateColumns,
minWidth: userTableMinWidth,
}}
>
<TableCell
colSpan={7 + userSchema.length}
className="h-24 text-center"
colSpan={tableColumnCount}
data-testid="user-table-loading-cell"
className={userTableStateCellClassName}
style={{ gridColumn: "1 / -1" }}
>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableRow
data-testid="user-table-empty-row"
style={{
display: "grid",
gridTemplateColumns: userTableGridTemplateColumns,
minWidth: userTableMinWidth,
}}
>
<TableCell
colSpan={7 + userSchema.length}
className="h-24 text-center"
colSpan={tableColumnCount}
data-testid="user-table-empty-cell"
className={userTableStateCellClassName}
style={{ gridColumn: "1 / -1" }}
>
{t(
"msg.admin.users.list.empty",
@@ -845,145 +962,162 @@ function UserListPage() {
</TableCell>
</TableRow>
)}
{items.map((user) => (
<TableRow
key={user.id}
className={
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
}
>
<TableCell>
<input
type="checkbox"
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
{shouldVirtualizeRows &&
virtualRows.map((virtualRow) => {
const user = items[virtualRow.index];
if (!user) return null;
return (
<TableRow
key={user.id}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className={
selectedUserIds.includes(user.id)
? "bg-primary/5"
: ""
}
/>
</TableCell>
<TableCell>
<Link
to={`/users/${user.id}`}
className="font-medium hover:underline text-primary truncate block max-w-[150px]"
title={user.name}
style={{
display: "grid",
gridTemplateColumns: userTableGridTemplateColumns,
height: `${virtualRow.size}px`,
minWidth: userTableMinWidth,
position: "absolute",
transform: `translateY(${virtualRow.start}px)`,
width: "100%",
}}
>
{user.name}
</Link>
</TableCell>
<TableCell
className="text-sm text-muted-foreground truncate max-w-[200px]"
title={user.email}
>
{user.email}
</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{user.phone || "-"}
</TableCell>
<TableCell
className="max-w-[220px] break-all font-mono text-xs text-muted-foreground"
data-testid={`user-internal-id-${user.id}`}
>
{user.id}
</TableCell>
<TableCell>
<Select
value={normalizeUserStatusValue(user.status)}
onValueChange={(status) =>
statusMutation.mutate({
userId: user.id,
status,
})
}
disabled={
statusMutation.isPending || user.id === profile?.id
}
>
<SelectTrigger
className="h-8 w-[150px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium"
aria-label={t(
"ui.admin.users.list.change_status",
"{{name}} 상태 변경",
{ name: user.name },
)}
data-testid={`user-status-select-${user.id}`}
<TableCell>
<input
type="checkbox"
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>
<Link
to={`/users/${user.id}`}
className="font-medium hover:underline text-primary truncate block max-w-[150px]"
title={user.name}
>
{user.name}
</Link>
</TableCell>
<TableCell
className="text-sm text-muted-foreground truncate max-w-[200px]"
title={user.email}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select
value={assignableSystemRoleValue(user.role)}
onValueChange={(value) =>
bulkUpdateMutation.mutate({
userIds: [user.id],
role: value,
})
}
disabled={
bulkUpdateMutation.isPending ||
!isSuperAdminRole(profile?.role) ||
user.id === profile?.id
}
>
<SelectTrigger className="h-8 w-[140px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium">
<SelectValue />
</SelectTrigger>
<SelectContent>
{bulkPermissionOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
{user.email}
</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{user.phone || "-"}
</TableCell>
<TableCell
className="max-w-[220px] break-all font-mono text-xs text-muted-foreground"
data-testid={`user-internal-id-${user.id}`}
>
{user.id}
</TableCell>
<TableCell>
<Select
value={normalizeUserStatusValue(user.status)}
onValueChange={(status) =>
statusMutation.mutate({
userId: user.id,
status,
})
}
disabled={
statusMutation.isPending ||
user.id === profile?.id
}
>
<SelectTrigger
className="h-8 w-[150px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium"
aria-label={t(
"ui.admin.users.list.change_status",
"{{name}} 상태 변경",
{ name: user.name },
)}
data-testid={`user-status-select-${user.id}`}
>
{t(option.labelKey, option.fallback)}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
{user.tenant?.name ||
user.tenantSlug ||
t("ui.common.unassigned", "미배정")}
</span>
{user.department && (
<span className="text-xs text-muted-foreground">
{user.department}
</span>
)}
</div>
</TableCell>
{/* Dynamic Metadata Cells */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<SelectValue />
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select
value={assignableSystemRoleValue(user.role)}
onValueChange={(value) =>
bulkUpdateMutation.mutate({
userIds: [user.id],
role: value,
})
}
disabled={
bulkUpdateMutation.isPending ||
!isSuperAdminRole(profile?.role) ||
user.id === profile?.id
}
>
<SelectTrigger className="h-8 w-[140px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium">
<SelectValue />
</SelectTrigger>
<SelectContent>
{bulkPermissionOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{t(option.labelKey, option.fallback)}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
{user.tenant?.name ||
user.tenantSlug ||
t("ui.common.unassigned", "미배정")}
</span>
{user.department && (
<span className="text-xs text-muted-foreground">
{user.department}
</span>
)}
</div>
</TableCell>
{/* Dynamic Metadata Cells */}
{visibleUserSchemaFields.map((field) => (
<TableCell key={field.key} className="text-sm">
{String(user.metadata?.[field.key] ?? "-")}
</TableCell>
),
)}
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
))}
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>

View File

@@ -16,12 +16,12 @@ import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import { toast } from "../../../components/ui/use-toast";
import {
type GroupSummary,
type TenantSummary,
type UserSummary,
bulkUpdateUsers,
fetchAllTenants,
fetchGroups,
type GroupSummary,
type TenantSummary,
type UserSummary,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";

View File

@@ -30,17 +30,17 @@ import {
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
buildTenantImportPreview,
type TenantCSVRow,
type TenantImportPreviewRow,
buildTenantImportPreview,
} from "../../tenants/utils/tenantCsvImport";
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
import { parseUserCSV } from "../utils/csvParser";
import {
type HanmacImportEmailPreview,
buildHanmacImportEmailPreview,
} from "../utils/hanmacImportEmail";
import { applyGeneralPlanningOfficePriority } from "../utils/generalPlanningOfficePriority";
import {
buildHanmacImportEmailPreview,
type HanmacImportEmailPreview,
} from "../utils/hanmacImportEmail";
interface UserBulkUploadModalProps {
onSuccess?: () => void;
@@ -551,7 +551,10 @@ export function UserBulkUploadModal({
</thead>
<tbody>
{previewData.slice(0, 10).map((u, index) => (
<tr key={`${u.email}-${index}`} className="border-t">
<tr
key={`${u.email}-${u.tenantSlug ?? ""}-${u.name}`}
className="border-t"
>
<td className="p-2">
<input
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"

View File

@@ -59,9 +59,7 @@ describe("orgChartPicker", () => {
buildAuthenticatedOrgChartUrl("https://orgchart.example.com/", {
includeInternal: false,
}),
).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart",
);
).toBe("https://orgchart.example.com/login?auto=1&returnTo=%2Fchart");
});
it("parses the first tenant id and name from orgfront confirm messages", () => {

View File

@@ -12,7 +12,9 @@ export const userStatusValues = [
export type UserStatusValue = (typeof userStatusValues)[number];
export function normalizeUserStatusValue(status?: string | null): UserStatusValue {
export function normalizeUserStatusValue(
status?: string | null,
): UserStatusValue {
switch ((status ?? "").trim().toLowerCase()) {
case "active":
return "active";

View File

@@ -238,9 +238,7 @@ function normalizeHeader(header: string) {
"worksmobile_alias_email",
"worksmobile_alias_emails",
].includes(separatorNormalized) ||
["보조이메일", "보조메일", "추가이메일", "추가메일"].includes(
compactKorean,
)
["보조이메일", "보조메일", "추가이메일", "추가메일"].includes(compactKorean)
) {
return "secondary_emails";
}