1
0
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:
2026-06-04 14:08:55 +09:00
parent 8f2e351875
commit 6d3f128282
18 changed files with 223 additions and 108 deletions

View File

@@ -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>

View File

@@ -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;

View File

@@ -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
} }

View File

@@ -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")
} }

View File

@@ -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")
} }

View File

@@ -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
} }

View File

@@ -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())
} }

View File

@@ -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")
} }

View File

@@ -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
} }

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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
View File

@@ -0,0 +1,2 @@
tenant_id,name,type,slug
4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,mh-manager,COMPANY,mhd-tdc
1 tenant_id name type slug
2 4d0f26b9-702c-4bc6-8996-46e9eedfdeb7 mh-manager COMPANY mhd-tdc

View 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
1 uuid email name phone role tenant_slug department grade position jobTitle employee_id
2 550e8400-e29b-41d4-a716-446655441001 user-unchanged@example.com 동일 테스트 010-1111-1001 user test-tenant Platform Senior Engineer E1001
3 550e8400-e29b-41d4-a716-446655441002 user-modified@example.com 이름수정 완료 010-1111-1002 user test-tenant Design Junior Designer E1002
4 550e8400-e29b-41d4-a716-446655441003 user-restored@example.com 복구 테스트 010-1111-1003 user test-tenant Sales Manager Manager E1003
5 550e8400-e29b-41d4-a716-446655441005 user-brand-new@example.com 완전신규 테스트 010-1111-1005 user test-tenant R&D Senior Lead E1005

View 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
1 uuid,email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id
2 550e8400-e29b-41d4-a716-446655440010,clean-user@example.com,Clean User,010-3333-0001,user,test-tenant,R&D,Senior,,Engineer,E010
3 550e8400-e29b-41d4-a716-446655440010,conflict-user@example.com,Conflict User,010-3333-0002,user,test-tenant,Sales,,,E011
4 550e8400-e29b-41d4-a716-446655440012,same-person@example.com,Same Person,010-3333-0003,user,test-tenant,HR,,,E012
5 550e8400-e29b-41d4-a716-446655440013,reclaim-id@example.com,Reclaim ID User,010-3333-0004,user,test-tenant,Finance,,,E010

View 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
1 uuid,email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id
2 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
3 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
4 550e8400-e29b-41d4-a716-446655440008,new-reimport-user@example.com,New Re-import User,010-1111-0008,user,test-tenant,HR,Manager,,HR,E008
5 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
View 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
1 uuid,email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id
2 550e8400-e29b-41d4-a716-446655440001,uuid-user-1@example.com,UUID User 1,010-1111-0001,user,test-tenant,Platform,Senior,Lead,Engineer,E001
3 550e8400-e29b-41d4-a716-446655440002,uuid-user-2@example.com,UUID User 2,010-1111-0002,user,test-tenant,Design,Junior,,Designer,E002
4 ,no-uuid-user@example.com,No UUID User,010-1111-0003,user,test-tenant,Sales,Manager,Head,,E003
5 550e8400-e29b-41d4-a716-446655440004,id-column-uuid@example.com,ID Col UUID User,010-2222-0001,user,test-tenant

View 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,,,,,,
1 email secondary_emails name phone role tenant_slug department grade position jobTitle employee_id tenant_slug1 department1 grade1 position1 jobTitle1 employee_id1
2 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
3 bob@hanmac.com Bob Lee 010-3333-4444 user hanmac-family 디자인팀 책임 팀장 UI/UX EMP101
4 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