forked from baron/baron-sso
perf(admin): implement server-side search and virtualization for tenant list
- Backend: Added 'search' parameter to TenantRepository and TenantService. - Backend: Updated all Tenant list calls to support searching. - Backend: Enhanced UserRepository.List to support cursor-based pagination and search. - Frontend: Switched TenantListPage to use useInfiniteQuery for lazy loading. - Frontend: Implemented list virtualization in TenantHierarchyView using @tanstack/react-virtual. - Frontend: Added server-side search with debouncing (useDeferredValue). - Fixed various Go compilation errors caused by method signature changes.
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
|||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -93,6 +94,7 @@ import {
|
|||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { normalizeAdminRole } from "../../../lib/roles";
|
import { normalizeAdminRole } from "../../../lib/roles";
|
||||||
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
|
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
import {
|
import {
|
||||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||||
filterNonHanmacFamilyTenants,
|
filterNonHanmacFamilyTenants,
|
||||||
@@ -115,7 +117,6 @@ import {
|
|||||||
resolveTenantSelectionIds,
|
resolveTenantSelectionIds,
|
||||||
type TenantViewMode,
|
type TenantViewMode,
|
||||||
type TenantViewRow,
|
type TenantViewRow,
|
||||||
tenantMatchesListSearch,
|
|
||||||
} from "./tenantListView";
|
} from "./tenantListView";
|
||||||
|
|
||||||
const tenantCSVTemplate =
|
const tenantCSVTemplate =
|
||||||
@@ -264,7 +265,6 @@ function resolveImportParentSelection(
|
|||||||
function TenantListPage() {
|
function TenantListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||||
const [search, setSearch] = React.useState("");
|
|
||||||
const [viewMode, setViewMode] = React.useState<TenantViewMode>("tree");
|
const [viewMode, setViewMode] = React.useState<TenantViewMode>("tree");
|
||||||
const [scopeTenantId, setScopeTenantId] = React.useState("");
|
const [scopeTenantId, setScopeTenantId] = React.useState("");
|
||||||
const [scopePickerOpen, setScopePickerOpen] = React.useState(false);
|
const [scopePickerOpen, setScopePickerOpen] = React.useState(false);
|
||||||
@@ -304,6 +304,8 @@ function TenantListPage() {
|
|||||||
(d: TenantImportDetail) => d.action === importResultFilter,
|
(d: TenantImportDetail) => d.action === importResultFilter,
|
||||||
);
|
);
|
||||||
}, [importResult, importResultFilter]);
|
}, [importResult, importResultFilter]);
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const debouncedSearch = React.useDeferredValue(search.trim());
|
||||||
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState("");
|
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState("");
|
||||||
const _tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
|
const _tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -314,18 +316,18 @@ function TenantListPage() {
|
|||||||
const profileRole = normalizeAdminRole(profile?.role);
|
const profileRole = normalizeAdminRole(profile?.role);
|
||||||
|
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: ["tenants", "lazy"],
|
queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
fetchTenants(
|
fetchTenants(
|
||||||
tenantPageSize,
|
tenantPageSize,
|
||||||
0,
|
0,
|
||||||
undefined,
|
scopeTenantId || undefined,
|
||||||
pageParam ? pageParam : undefined,
|
pageParam ? (pageParam as string) : undefined,
|
||||||
|
debouncedSearch,
|
||||||
),
|
),
|
||||||
initialPageParam: "",
|
initialPageParam: "",
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.nextCursor || lastPage.next_cursor || undefined,
|
lastPage.nextCursor || lastPage.next_cursor || undefined,
|
||||||
enabled: profileRole === "super_admin",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteBulkMutation = useMutation({
|
const deleteBulkMutation = useMutation({
|
||||||
@@ -436,6 +438,11 @@ 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
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
?.data?.error;
|
?.data?.error;
|
||||||
const fallbackError =
|
const fallbackError =
|
||||||
@@ -443,15 +450,7 @@ function TenantListPage() {
|
|||||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const tenantPages = React.useMemo(
|
const tenantTotal = query.data?.pages[0]?.total ?? 0;
|
||||||
() => query.data?.pages ?? [],
|
|
||||||
[query.data?.pages],
|
|
||||||
);
|
|
||||||
const rawTenants = React.useMemo(
|
|
||||||
() => tenantPages.flatMap((page) => page.items),
|
|
||||||
[tenantPages],
|
|
||||||
);
|
|
||||||
const tenantTotal = tenantPages[0]?.total ?? 0;
|
|
||||||
const hanmacFamilyTenantId = React.useMemo(() => {
|
const hanmacFamilyTenantId = React.useMemo(() => {
|
||||||
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
|
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
|
||||||
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
||||||
@@ -924,6 +923,10 @@ function TenantListPage() {
|
|||||||
getSortIcon={getSortIcon}
|
getSortIcon={getSortIcon}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
scopeTenantId={scopeTenantId}
|
scopeTenantId={scopeTenantId}
|
||||||
|
fetchNextPage={query.fetchNextPage}
|
||||||
|
hasNextPage={!!query.hasNextPage}
|
||||||
|
isFetchingNextPage={query.isFetchingNextPage}
|
||||||
|
isLoading={query.isLoading}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1500,6 +1503,10 @@ const TenantHierarchyView: React.FC<{
|
|||||||
getSortIcon: (key: TenantSortKey) => React.ReactNode;
|
getSortIcon: (key: TenantSortKey) => React.ReactNode;
|
||||||
viewMode: TenantViewMode;
|
viewMode: TenantViewMode;
|
||||||
scopeTenantId: string;
|
scopeTenantId: string;
|
||||||
|
fetchNextPage: () => void;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
isFetchingNextPage: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
}> = ({
|
}> = ({
|
||||||
tenants,
|
tenants,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -1514,7 +1521,13 @@ const TenantHierarchyView: React.FC<{
|
|||||||
getSortIcon,
|
getSortIcon,
|
||||||
viewMode,
|
viewMode,
|
||||||
scopeTenantId,
|
scopeTenantId,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isLoading,
|
||||||
}) => {
|
}) => {
|
||||||
|
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { subTree } = React.useMemo(
|
const { subTree } = React.useMemo(
|
||||||
() => buildTenantFullTree(tenants, scopeTenantId || undefined),
|
() => buildTenantFullTree(tenants, scopeTenantId || undefined),
|
||||||
[scopeTenantId, tenants],
|
[scopeTenantId, tenants],
|
||||||
@@ -1569,51 +1582,26 @@ const TenantHierarchyView: React.FC<{
|
|||||||
const flattenedRows = React.useMemo(() => {
|
const flattenedRows = React.useMemo(() => {
|
||||||
if (viewMode === "table") {
|
if (viewMode === "table") {
|
||||||
return sortItems(
|
return sortItems(
|
||||||
getTenantViewRows(tenants, "table", scopeTenantId).filter((tenant) =>
|
getTenantViewRows(tenants, "table", scopeTenantId),
|
||||||
tenantMatchesListSearch(tenant, search),
|
|
||||||
),
|
|
||||||
sortConfig,
|
sortConfig,
|
||||||
tenantSortResolvers,
|
tenantSortResolvers,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: TenantViewRow[] = [];
|
const result: TenantViewRow[] = [];
|
||||||
const term = search.toLowerCase().trim();
|
|
||||||
|
|
||||||
// When searching, we show matched nodes and all their ancestors.
|
|
||||||
const matchedIds = new Set<string>();
|
|
||||||
if (term) {
|
|
||||||
const findMatches = (nodes: TenantNode[]) => {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (tenantMatchesListSearch(node, term)) {
|
|
||||||
matchedIds.add(node.id);
|
|
||||||
}
|
|
||||||
if (node.children) findMatches(node.children);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
findMatches(subTree);
|
|
||||||
}
|
|
||||||
|
|
||||||
const collect = (nodes: TenantNode[], depth: number) => {
|
const collect = (nodes: TenantNode[], depth: number) => {
|
||||||
// Sort nodes at the current depth
|
// Sort nodes at the current depth
|
||||||
const sortedNodes = sortItems(nodes, sortConfig, tenantSortResolvers);
|
const sortedNodes = sortItems(nodes, sortConfig, tenantSortResolvers);
|
||||||
|
|
||||||
for (const node of sortedNodes) {
|
for (const node of sortedNodes) {
|
||||||
// If searching, show node if it matches OR any of its descendants match.
|
result.push({ ...node, depth });
|
||||||
const hasMatchingDescendant = (n: TenantNode): boolean => {
|
if (
|
||||||
if (matchedIds.has(n.id)) return true;
|
expandedIds.has(node.id) &&
|
||||||
return n.children.some(hasMatchingDescendant);
|
node.children &&
|
||||||
};
|
node.children.length > 0
|
||||||
|
) {
|
||||||
if (!term || hasMatchingDescendant(node)) {
|
collect(node.children, depth + 1);
|
||||||
result.push({ ...node, depth });
|
|
||||||
if (
|
|
||||||
(term || expandedIds.has(node.id)) &&
|
|
||||||
node.children &&
|
|
||||||
node.children.length > 0
|
|
||||||
) {
|
|
||||||
collect(node.children, depth + 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1622,7 +1610,6 @@ const TenantHierarchyView: React.FC<{
|
|||||||
}, [
|
}, [
|
||||||
expandedIds,
|
expandedIds,
|
||||||
scopeTenantId,
|
scopeTenantId,
|
||||||
search,
|
|
||||||
sortConfig,
|
sortConfig,
|
||||||
subTree,
|
subTree,
|
||||||
tenantSortResolvers,
|
tenantSortResolvers,
|
||||||
@@ -1630,6 +1617,34 @@ const TenantHierarchyView: React.FC<{
|
|||||||
viewMode,
|
viewMode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: flattenedRows.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => _tenantEstimatedRowHeight,
|
||||||
|
overscan: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const lastItem = virtualRows[virtualRows.length - 1];
|
||||||
|
if (!lastItem) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastItem.index >= flattenedRows.length - 1 &&
|
||||||
|
hasNextPage &&
|
||||||
|
!isFetchingNextPage
|
||||||
|
) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
virtualRows,
|
||||||
|
flattenedRows.length,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
]);
|
||||||
|
|
||||||
const visibleSelectableIds = React.useMemo(
|
const visibleSelectableIds = React.useMemo(
|
||||||
() => new Set(deletableTenants.map((tenant) => tenant.id)),
|
() => new Set(deletableTenants.map((tenant) => tenant.id)),
|
||||||
[deletableTenants],
|
[deletableTenants],
|
||||||
@@ -1640,11 +1655,15 @@ const TenantHierarchyView: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col mt-4">
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col mt-4">
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div
|
||||||
<Table className="min-w-[1180px]">
|
ref={parentRef}
|
||||||
|
className="flex-1 overflow-auto relative custom-scrollbar"
|
||||||
|
data-testid="tenant-table-container"
|
||||||
|
>
|
||||||
|
<Table className="min-w-[1180px] relative border-separate border-spacing-0">
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[48px] whitespace-nowrap">
|
<TableHead className="w-[48px] whitespace-nowrap px-4 text-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={
|
||||||
deletableTenants.length > 0 &&
|
deletableTenants.length > 0 &&
|
||||||
@@ -1718,8 +1737,14 @@ const TenantHierarchyView: React.FC<{
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody className="relative">
|
||||||
{flattenedRows.length === 0 && (
|
{rowVirtualizer.getTotalSize() > 0 && virtualRows.length > 0 && (
|
||||||
|
<tr style={{ height: `${virtualRows[0].start}px` }}>
|
||||||
|
<td colSpan={8} />
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flattenedRows.length === 0 && !isLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={8}
|
colSpan={8}
|
||||||
@@ -1732,7 +1757,10 @@ const TenantHierarchyView: React.FC<{
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{flattenedRows.map((node) => {
|
|
||||||
|
{virtualRows.map((virtualRow) => {
|
||||||
|
const node = flattenedRows[virtualRow.index];
|
||||||
|
const isSelected = selectedIds.includes(node.id);
|
||||||
const hasChildren =
|
const hasChildren =
|
||||||
viewMode === "tree" &&
|
viewMode === "tree" &&
|
||||||
node.children &&
|
node.children &&
|
||||||
@@ -1744,16 +1772,16 @@ const TenantHierarchyView: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className={
|
data-index={virtualRow.index}
|
||||||
selectedIds.includes(node.id) ? "bg-primary/5" : ""
|
ref={rowVirtualizer.measureElement}
|
||||||
}
|
className={cn(isSelected ? "bg-primary/5" : "", "h-[73px]")}
|
||||||
>
|
>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center px-4">
|
||||||
{isSeedTenant(node) ? (
|
{isSeedTenant(node) ? (
|
||||||
<span className="inline-block h-4 w-4" />
|
<span className="inline-block h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIds.includes(node.id)}
|
checked={isSelected}
|
||||||
onCheckedChange={(checked) => onSelect(node, !!checked)}
|
onCheckedChange={(checked) => onSelect(node, !!checked)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1761,27 +1789,34 @@ const TenantHierarchyView: React.FC<{
|
|||||||
<TableCell className="font-semibold p-0">
|
<TableCell className="font-semibold p-0">
|
||||||
<div
|
<div
|
||||||
className="flex items-center h-full min-h-[3rem] py-1"
|
className="flex items-center h-full min-h-[3rem] py-1"
|
||||||
style={{ paddingLeft: `${node.depth * 28 + 12}px` }}
|
style={{
|
||||||
|
paddingLeft:
|
||||||
|
viewMode === "tree"
|
||||||
|
? `${node.depth * 28 + 12}px`
|
||||||
|
: "12px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-5 flex items-center justify-center mr-1.5 shrink-0">
|
{viewMode === "tree" && (
|
||||||
{hasChildren && !search ? (
|
<div className="w-5 flex items-center justify-center mr-1.5 shrink-0">
|
||||||
<button
|
{hasChildren && !search ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => toggleExpand(node.id)}
|
type="button"
|
||||||
className="p-0.5 hover:bg-black/5 rounded cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
onClick={() => toggleExpand(node.id)}
|
||||||
>
|
className="p-0.5 hover:bg-black/5 rounded cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||||
{isExpanded ? (
|
>
|
||||||
<ChevronDown size={16} />
|
{isExpanded ? (
|
||||||
) : (
|
<ChevronDown size={16} />
|
||||||
<ChevronRight size={16} />
|
) : (
|
||||||
)}
|
<ChevronRight size={16} />
|
||||||
</button>
|
)}
|
||||||
) : (
|
</button>
|
||||||
node.depth > 0 && (
|
) : (
|
||||||
<div className="w-1 h-1 rounded-full bg-border" />
|
node.depth > 0 && (
|
||||||
)
|
<div className="w-1 h-1 rounded-full bg-border" />
|
||||||
)}
|
)
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<TypeIcon
|
<TypeIcon
|
||||||
size={14}
|
size={14}
|
||||||
@@ -1857,6 +1892,27 @@ const TenantHierarchyView: React.FC<{
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{rowVirtualizer.getTotalSize() > 0 && virtualRows.length > 0 && (
|
||||||
|
<tr
|
||||||
|
style={{
|
||||||
|
height: `${rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td colSpan={8} />
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-4">
|
||||||
|
<div className="flex items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||||
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
|
{t("msg.common.loading_more", "Loading more...")}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -293,11 +293,12 @@ export async function fetchTenants(
|
|||||||
offset = 0,
|
offset = 0,
|
||||||
parentId?: string,
|
parentId?: string,
|
||||||
cursor?: string,
|
cursor?: string,
|
||||||
|
search?: string,
|
||||||
) {
|
) {
|
||||||
const { data } = await apiClient.get<TenantListResponse>(
|
const { data } = await apiClient.get<TenantListResponse>(
|
||||||
"/v1/admin/tenants",
|
"/v1/admin/tenants",
|
||||||
{
|
{
|
||||||
params: { limit, offset, parentId, cursor },
|
params: { limit, offset, parentId, cursor, search },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ func (h *AdminHandler) countTenants(ctx context.Context) int64 {
|
|||||||
if h == nil || h.TenantRepo == nil {
|
if h == nil || h.TenantRepo == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
_, total, err := h.TenantRepo.List(ctx, 1, 0, "")
|
_, total, err := h.TenantRepo.List(ctx, 1, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -629,7 +629,7 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. List and Filter Tenants
|
// 3. List and Filter Tenants
|
||||||
tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "")
|
tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch tenants")
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch tenants")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3571,7 +3571,7 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if role == domain.RoleSuperAdmin {
|
if role == domain.RoleSuperAdmin {
|
||||||
tenants, _, err := h.TenantSvc.ListTenants(c.Context(), 100, 0, "")
|
tenants, _, err := h.TenantSvc.ListTenants(c.Context(), 100, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to list tenants")
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to list tenants")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func (h *UserHandler) resolveHanmacEmailScope(ctx context.Context) (*hanmacEmail
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tenants, _, err := h.TenantService.ListTenants(ctx, 10000, 0, "")
|
tenants, _, err := h.TenantService.ListTenants(ctx, 10000, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
if role != domain.RoleSuperAdmin {
|
if role != domain.RoleSuperAdmin {
|
||||||
// Not a super admin: Only return the entire tree(s) of the tenants they belong to
|
// Not a super admin: Only return the entire tree(s) of the tenants they belong to
|
||||||
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "")
|
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -343,13 +343,13 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
|||||||
} else {
|
} else {
|
||||||
// Super Admin case
|
// Super Admin case
|
||||||
if cursorRaw != "" && h.DB != nil {
|
if cursorRaw != "" && h.DB != nil {
|
||||||
tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw)
|
tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
|
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
|
||||||
}
|
}
|
||||||
offset = 0
|
offset = 0
|
||||||
} else {
|
} else {
|
||||||
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId)
|
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -382,7 +382,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, parentID string, cursorRaw string) ([]domain.Tenant, int64, string, error) {
|
func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, parentID string, cursorRaw string, search string) ([]domain.Tenant, int64, string, error) {
|
||||||
cursor, err := pagination.Decode(cursorRaw)
|
cursor, err := pagination.Decode(cursorRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, "", err
|
return nil, 0, "", err
|
||||||
@@ -395,6 +395,12 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
|
|||||||
pageQuery = pageQuery.Where("parent_id = ?", parentID)
|
pageQuery = pageQuery.Where("parent_id = ?", parentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
searchTerm := "%" + strings.ToLower(search) + "%"
|
||||||
|
countQuery = countQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
|
||||||
|
pageQuery = pageQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
|
||||||
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := countQuery.Count(&total).Error; err != nil {
|
if err := countQuery.Count(&total).Error; err != nil {
|
||||||
return nil, 0, "", err
|
return nil, 0, "", err
|
||||||
@@ -422,7 +428,7 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
|
|||||||
|
|
||||||
func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
||||||
parentID := strings.TrimSpace(c.Query("parentId"))
|
parentID := strings.TrimSpace(c.Query("parentId"))
|
||||||
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "")
|
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
@@ -566,7 +572,7 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
tenantIDBySlug := make(map[string]string)
|
tenantIDBySlug := make(map[string]string)
|
||||||
if h.Service != nil {
|
if h.Service != nil {
|
||||||
if tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, ""); err == nil {
|
if tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", ""); err == nil {
|
||||||
for _, tenant := range tenants {
|
for _, tenant := range tenants {
|
||||||
tenantIDBySlug[strings.ToLower(tenant.Slug)] = tenant.ID
|
tenantIDBySlug[strings.ToLower(tenant.Slug)] = tenant.ID
|
||||||
}
|
}
|
||||||
@@ -2336,7 +2342,7 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusServiceUnavailable, "tenant service is not configured")
|
return errorJSON(c, fiber.StatusServiceUnavailable, "tenant service is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "")
|
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
@@ -2410,7 +2416,7 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
usersByAppointment, _, err := h.UserRepo.List(ctx, 0, 10000, "", "")
|
usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -2741,7 +2747,7 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusUnauthorized, err.Error())
|
return errorJSON(c, fiber.StatusUnauthorized, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "")
|
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// Expand manageableSlugs to the entire tenant tree (root + all descendants)
|
// Expand manageableSlugs to the entire tenant tree (root + all descendants)
|
||||||
if h.TenantService != nil && len(baseTenantIDs) > 0 {
|
if h.TenantService != nil && len(baseTenantIDs) > 0 {
|
||||||
allTenants, _, err := h.TenantService.ListTenants(c.Context(), 10000, 0, "")
|
allTenants, _, err := h.TenantService.ListTenants(c.Context(), 10000, 0, "", "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
parentMap := make(map[string]string)
|
parentMap := make(map[string]string)
|
||||||
for _, t := range allTenants {
|
for _, t := range allTenants {
|
||||||
@@ -1614,7 +1614,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Fetch Users using Repo for efficiency
|
// 1. Fetch Users using Repo for efficiency
|
||||||
users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug)
|
users, _, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type TenantRepository interface {
|
|||||||
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
|
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
|
||||||
FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error)
|
FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error)
|
||||||
AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error
|
AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error
|
||||||
List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error)
|
List(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error)
|
||||||
ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error)
|
ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error)
|
||||||
DeleteBulk(ctx context.Context, ids []string) error
|
DeleteBulk(ctx context.Context, ids []string) error
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domai
|
|||||||
return r.db.WithContext(ctx).Create(&td).Error
|
return r.db.WithContext(ctx).Create(&td).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||||
var tenants []domain.Tenant
|
var tenants []domain.Tenant
|
||||||
var total int64
|
var total int64
|
||||||
db := r.db.WithContext(ctx).Model(&domain.Tenant{})
|
db := r.db.WithContext(ctx).Model(&domain.Tenant{})
|
||||||
@@ -133,6 +133,11 @@ func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID
|
|||||||
db = db.Where("parent_id = ?", parentID)
|
db = db.Where("parent_id = ?", parentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
searchTerm := "%" + strings.ToLower(search) + "%"
|
||||||
|
db = db.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
|
||||||
|
}
|
||||||
|
|
||||||
if err := db.Count(&total).Error; err != nil {
|
if err := db.Count(&total).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/pagination"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,7 +18,7 @@ type UserRepository interface {
|
|||||||
FindByID(ctx context.Context, id string) (*domain.User, error)
|
FindByID(ctx context.Context, id string) (*domain.User, error)
|
||||||
FindByIDs(ctx context.Context, ids []string) ([]domain.User, error)
|
FindByIDs(ctx context.Context, ids []string) ([]domain.User, error)
|
||||||
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
|
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
|
||||||
List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error)
|
List(ctx context.Context, offset, limit int, search string, tenantSlug string, cursor string) ([]domain.User, int64, string, error)
|
||||||
CountByTenant(ctx context.Context, tenantID string) (int64, error)
|
CountByTenant(ctx context.Context, tenantID string) (int64, error)
|
||||||
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
|
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
|
||||||
CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error)
|
CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error)
|
||||||
@@ -215,7 +216,7 @@ func lowerStrings(arr []string) []string {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *userRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
|
func (r *userRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string, cursorRaw string) ([]domain.User, int64, string, error) {
|
||||||
var users []domain.User
|
var users []domain.User
|
||||||
var total int64
|
var total int64
|
||||||
db := r.db.WithContext(ctx).Model(&domain.User{})
|
db := r.db.WithContext(ctx).Model(&domain.User{})
|
||||||
@@ -232,14 +233,34 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Count(&total).Error; err != nil {
|
if err := db.Count(&total).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Offset(offset).Limit(limit).Preload("Tenant").Find(&users).Error; err != nil {
|
if cursorRaw != "" {
|
||||||
return nil, 0, err
|
cursor, err := pagination.Decode(cursorRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, "", err
|
||||||
|
}
|
||||||
|
db = pagination.ApplyCreatedAtIDCursor(db, cursor, "created_at", "id")
|
||||||
|
} else {
|
||||||
|
db = db.Offset(offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
return users, total, nil
|
if err := db.Order("created_at desc, id desc").Limit(limit + 1).Preload("Tenant").Find(&users).Error; err != nil {
|
||||||
|
return nil, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []domain.User
|
||||||
|
var nextCursor string
|
||||||
|
if len(users) > limit {
|
||||||
|
items = users[:limit]
|
||||||
|
last := items[limit-1]
|
||||||
|
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
|
||||||
|
} else {
|
||||||
|
items = users
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, total, nextCursor, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *userRepository) Delete(ctx context.Context, id string) error {
|
func (r *userRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type TenantService interface {
|
|||||||
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
||||||
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
||||||
GetTenant(ctx context.Context, id string) (*domain.Tenant, error)
|
GetTenant(ctx context.Context, id string) (*domain.Tenant, error)
|
||||||
ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error)
|
ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error)
|
||||||
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
|
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
|
||||||
ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
|
ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
|
||||||
IsDomainAllowed(ctx context.Context, domainName string) (bool, error)
|
IsDomainAllowed(ctx context.Context, domainName string) (bool, error)
|
||||||
@@ -314,8 +314,8 @@ func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*doma
|
|||||||
return s.repo.FindBySlug(ctx, slug)
|
return s.repo.FindBySlug(ctx, slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||||
return s.repo.List(ctx, limit, offset, parentID)
|
return s.repo.List(ctx, limit, offset, parentID, search)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
|
func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
|
||||||
|
|||||||
@@ -938,7 +938,7 @@ func (s *worksmobileSyncService) hanmacRoot(ctx context.Context, tenantID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *worksmobileSyncService) hanmacSubtree(ctx context.Context, rootID string) ([]domain.Tenant, error) {
|
func (s *worksmobileSyncService) hanmacSubtree(ctx context.Context, rootID string) ([]domain.Tenant, error) {
|
||||||
all, _, err := s.tenantService.ListTenants(ctx, 10000, 0, "")
|
all, _, err := s.tenantService.ListTenants(ctx, 10000, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
2
tenant_mh_manager.csv
Normal file
2
tenant_mh_manager.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
tenant_id,name,type,slug
|
||||||
|
4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,mh-manager,COMPANY,mhd-tdc
|
||||||
|
5
test_users_bulk_comprehensive.csv
Normal file
5
test_users_bulk_comprehensive.csv
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
uuid,email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id
|
||||||
|
550e8400-e29b-41d4-a716-446655441001,user-unchanged@example.com,동일 테스트,010-1111-1001,user,test-tenant,Platform,Senior,,Engineer,E1001
|
||||||
|
550e8400-e29b-41d4-a716-446655441002,user-modified@example.com,이름수정 완료,010-1111-1002,user,test-tenant,Design,Junior,,Designer,E1002
|
||||||
|
550e8400-e29b-41d4-a716-446655441003,user-restored@example.com,복구 테스트,010-1111-1003,user,test-tenant,Sales,Manager,,Manager,E1003
|
||||||
|
550e8400-e29b-41d4-a716-446655441005,user-brand-new@example.com,완전신규 테스트,010-1111-1005,user,test-tenant,R&D,Senior,,Lead,E1005
|
||||||
|
5
test_users_bulk_final_check.csv
Normal file
5
test_users_bulk_final_check.csv
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
uuid,email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id
|
||||||
|
550e8400-e29b-41d4-a716-446655440010,clean-user@example.com,Clean User,010-3333-0001,user,test-tenant,R&D,Senior,,Engineer,E010
|
||||||
|
550e8400-e29b-41d4-a716-446655440010,conflict-user@example.com,Conflict User,010-3333-0002,user,test-tenant,Sales,,,E011
|
||||||
|
550e8400-e29b-41d4-a716-446655440012,same-person@example.com,Same Person,010-3333-0003,user,test-tenant,HR,,,E012
|
||||||
|
550e8400-e29b-41d4-a716-446655440013,reclaim-id@example.com,Reclaim ID User,010-3333-0004,user,test-tenant,Finance,,,E010
|
||||||
|
5
test_users_bulk_reimport_fix.csv
Normal file
5
test_users_bulk_reimport_fix.csv
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
uuid,email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id
|
||||||
|
550e8400-e29b-41d4-a716-446655440001,uuid-user-1@example.com,UUID User 1 (Re-import),010-1111-0001,user,test-tenant,Platform,Senior,Lead,Engineer,E001
|
||||||
|
550e8400-e29b-41d4-a716-446655440002,uuid-user-2@example.com,UUID User 2 (Re-import),010-1111-0002,user,test-tenant,Design,Junior,,Designer,E002
|
||||||
|
550e8400-e29b-41d4-a716-446655440008,new-reimport-user@example.com,New Re-import User,010-1111-0008,user,test-tenant,HR,Manager,,HR,E008
|
||||||
|
550e8400-e29b-41d4-a716-446655440009,conflict-uuid@example.com,Conflict UUID User,010-1111-0009,user,test-tenant,Sales,,,E001
|
||||||
|
5
test_users_bulk_uuid.csv
Normal file
5
test_users_bulk_uuid.csv
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
uuid,email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id
|
||||||
|
550e8400-e29b-41d4-a716-446655440001,uuid-user-1@example.com,UUID User 1,010-1111-0001,user,test-tenant,Platform,Senior,Lead,Engineer,E001
|
||||||
|
550e8400-e29b-41d4-a716-446655440002,uuid-user-2@example.com,UUID User 2,010-1111-0002,user,test-tenant,Design,Junior,,Designer,E002
|
||||||
|
,no-uuid-user@example.com,No UUID User,010-1111-0003,user,test-tenant,Sales,Manager,Head,,E003
|
||||||
|
550e8400-e29b-41d4-a716-446655440004,id-column-uuid@example.com,ID Col UUID User,010-2222-0001,user,test-tenant
|
||||||
|
4
test_users_bulk_with_n_secondary_emails.csv
Normal file
4
test_users_bulk_with_n_secondary_emails.csv
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
email,secondary_emails,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1
|
||||||
|
alice@hanmac.com,alice.personal@gmail.com;alice.sub@naver.com;alice.old@hanmac.co.kr,Alice Kim,010-1111-2222,user,hanmac-family,개발팀,선임,팀원,프론트엔드,EMP100,,,,,,
|
||||||
|
bob@hanmac.com,,Bob Lee,010-3333-4444,user,hanmac-family,디자인팀,책임,팀장,UI/UX,EMP101,,,,,,
|
||||||
|
charlie@hanmac.com,charlie.backup1@test.com;charlie.backup2@test.com;charlie.backup3@test.com;charlie.backup4@test.com;charlie.backup5@test.com,Charlie Park,010-5555-6666,user,hanmac-family,인사팀,수석,,HR,EMP102,,,,,,
|
||||||
|
Reference in New Issue
Block a user