forked from baron/baron-sso
Merge pull request 'feature/rbac-simplification-and-remove-dev-switcher' (#1003) from feature/rbac-simplification-and-remove-dev-switcher into dev
Reviewed-on: baron/baron-sso#1003
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -58,3 +58,4 @@ orgfront/dist/
|
||||
orgfront/.vite/
|
||||
.pnpm-store
|
||||
.playwright-mcp
|
||||
node_modules
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
allowBuilds:
|
||||
'@biomejs/biome': true
|
||||
esbuild: false
|
||||
@@ -2,12 +2,6 @@ import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
parseAuditDetails,
|
||||
resolveAuditAction,
|
||||
resolveAuditActor,
|
||||
} from "../../../../common/core/audit";
|
||||
import { AuditLogTable } from "../../../../common/core/components/audit";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
@@ -23,6 +17,7 @@ import { Input } from "../../components/ui/input";
|
||||
import type { AuditLog } from "../../lib/adminApi";
|
||||
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { VirtualizedAuditLogTable } from "./VirtualizedAuditLogTable";
|
||||
|
||||
function AuditLogsPage() {
|
||||
const [searchActorId, setSearchActorId] = React.useState("");
|
||||
@@ -40,8 +35,23 @@ function AuditLogsPage() {
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["audit-logs"],
|
||||
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
|
||||
queryKey: [
|
||||
"audit-logs",
|
||||
deferredSearchActorId,
|
||||
deferredSearchAction,
|
||||
statusFilter,
|
||||
],
|
||||
queryFn: ({ pageParam }) => {
|
||||
const search = [deferredSearchActorId, deferredSearchAction]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return fetchAuditLogs(
|
||||
50,
|
||||
pageParam,
|
||||
search || undefined,
|
||||
statusFilter === "all" ? undefined : statusFilter,
|
||||
);
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
@@ -51,24 +61,6 @@ function AuditLogsPage() {
|
||||
(page) =>
|
||||
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
||||
) ?? [];
|
||||
const filteredLogs = React.useMemo(
|
||||
() =>
|
||||
logs.filter((row) => {
|
||||
const details = parseAuditDetails(row.details);
|
||||
const actorLabel = resolveAuditActor(row, details).toLowerCase();
|
||||
const actionLabel = resolveAuditAction(row, details).toLowerCase();
|
||||
const matchesActor =
|
||||
deferredSearchActorId === "" ||
|
||||
actorLabel.includes(deferredSearchActorId.toLowerCase());
|
||||
const matchesAction =
|
||||
deferredSearchAction === "" ||
|
||||
actionLabel.includes(deferredSearchAction.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" || row.status === statusFilter;
|
||||
return matchesActor && matchesAction && matchesStatus;
|
||||
}),
|
||||
[logs, deferredSearchActorId, deferredSearchAction, statusFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -83,7 +75,7 @@ function AuditLogsPage() {
|
||||
<>
|
||||
<Badge variant="muted">
|
||||
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
|
||||
count: filteredLogs.length,
|
||||
count: logs.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Button
|
||||
@@ -185,8 +177,8 @@ function AuditLogsPage() {
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
<AuditLogTable
|
||||
logs={filteredLogs}
|
||||
<VirtualizedAuditLogTable
|
||||
logs={logs}
|
||||
t={t}
|
||||
loading={isLoading}
|
||||
hasNextPage={Boolean(hasNextPage)}
|
||||
|
||||
475
adminfront/src/features/audit/VirtualizedAuditLogTable.tsx
Normal file
475
adminfront/src/features/audit/VirtualizedAuditLogTable.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
formatAuditDateParts,
|
||||
formatAuditValue,
|
||||
parseAuditDetails,
|
||||
resolveAuditAction,
|
||||
resolveAuditActor,
|
||||
resolveAuditTarget,
|
||||
} from "../../../../common/core/audit";
|
||||
import {
|
||||
type CommonBadgeVariant,
|
||||
getCommonBadgeClasses,
|
||||
} from "../../../../common/ui/badge";
|
||||
import { getCommonButtonClasses } from "../../../../common/ui/button";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableBodyClass,
|
||||
commonTableCellClass,
|
||||
commonTableClass,
|
||||
commonTableHeadClass,
|
||||
commonTableHeaderClass,
|
||||
commonTableRowClass,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
commonTableWrapperClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import type { AuditLog } from "../../lib/adminApi";
|
||||
|
||||
type AuditTranslate = (
|
||||
key: string,
|
||||
fallback: string,
|
||||
vars?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
type VirtualizedAuditLogTableProps = {
|
||||
logs: AuditLog[];
|
||||
t: AuditTranslate;
|
||||
loading: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onLoadMore: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function cx(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function statusVariant(status: string): CommonBadgeVariant {
|
||||
return status === "success" || status === "ok" ? "success" : "warning";
|
||||
}
|
||||
|
||||
export function VirtualizedAuditLogTable({
|
||||
logs,
|
||||
t,
|
||||
loading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
className,
|
||||
}: VirtualizedAuditLogTableProps) {
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const viewportRef = React.useRef<HTMLDivElement>(null);
|
||||
const isTest =
|
||||
(typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
|
||||
(typeof window !== "undefined" &&
|
||||
(window as Window & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE);
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: logs.length,
|
||||
getScrollElement: () => viewportRef.current,
|
||||
estimateSize: () => 80,
|
||||
measureElement: (el) => el.getBoundingClientRect().height,
|
||||
overscan: isTest ? logs.length : 10,
|
||||
initialRect: isTest ? { width: 1010, height: 1000 } : undefined,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isTest) {
|
||||
return;
|
||||
}
|
||||
const lastItem = virtualRows[virtualRows.length - 1];
|
||||
if (!lastItem) return;
|
||||
|
||||
if (
|
||||
lastItem.index >= logs.length - 1 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [
|
||||
virtualRows,
|
||||
logs.length,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
isTest,
|
||||
]);
|
||||
|
||||
const tableMinWidth = 1010;
|
||||
|
||||
const renderRow = (
|
||||
row: AuditLog,
|
||||
index: number,
|
||||
virtualRow?: { start: number; end: number },
|
||||
) => {
|
||||
if (!row) return null;
|
||||
|
||||
const details = parseAuditDetails(row.details);
|
||||
const actorLabel = resolveAuditActor(row, details);
|
||||
const actionLabel = resolveAuditAction(row, details);
|
||||
const targetLabel = resolveAuditTarget(details);
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const expanded = Boolean(expandedRows[rowKey]);
|
||||
const { date, time } = formatAuditDateParts(row.timestamp);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={rowKey}
|
||||
data-index={index}
|
||||
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
|
||||
className={cx(
|
||||
commonTableRowClass,
|
||||
"bg-card/40",
|
||||
virtualRow ? "absolute left-0 w-full" : "",
|
||||
)}
|
||||
style={
|
||||
virtualRow
|
||||
? {
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<td colSpan={6} className="p-0">
|
||||
<div className={cx("flex items-center", expanded && "border-b")}>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[190px] shrink-0 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(commonTableCellClass, "w-[180px] shrink-0")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{actorLabel}
|
||||
</code>
|
||||
{actorLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.actor_id",
|
||||
"Copy User ID",
|
||||
)}
|
||||
onClick={() => handleCopy(actorLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[180px] shrink-0 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="font-semibold text-foreground">{actionLabel}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[260px] shrink-0 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">{targetLabel}</span>
|
||||
{targetLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.target",
|
||||
"Copy Client ID",
|
||||
)}
|
||||
onClick={() => handleCopy(targetLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(commonTableCellClass, "w-[120px] shrink-0")}>
|
||||
<span
|
||||
className={getCommonBadgeClasses({
|
||||
variant: statusVariant(row.status),
|
||||
})}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[80px] shrink-0 text-right",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
})}
|
||||
onClick={() => {
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !expanded,
|
||||
}));
|
||||
// Re-measure after state change
|
||||
setTimeout(() => rowVirtualizer.measure(), 0);
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className={cx(commonTableCellClass, "bg-card/20 text-xs")}>
|
||||
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.request", "Request")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.request_id",
|
||||
"Request ID · {{value}}",
|
||||
{ value: formatAuditValue(details.request_id) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.event_id",
|
||||
"Event ID · {{value}}",
|
||||
{ value: formatAuditValue(row.event_id) },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.ip", "IP · {{value}}", {
|
||||
value: formatAuditValue(row.ip_address),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.method", "Method · {{value}}", {
|
||||
value: formatAuditValue(details.method),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.path", "Path · {{value}}", {
|
||||
value: formatAuditValue(details.path),
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.latency",
|
||||
"Latency · {{value}}",
|
||||
{
|
||||
value:
|
||||
details.latency_ms !== undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.actor", "Actor")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.actor_id",
|
||||
"User ID · {{value}}",
|
||||
{ value: actorLabel },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.tenant", "Tenant · {{value}}", {
|
||||
value: formatAuditValue(details.tenant_id),
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.device", "Device · {{value}}", {
|
||||
value: formatAuditValue(row.device_id),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.target",
|
||||
"Client ID · {{value}}",
|
||||
{ value: targetLabel },
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.result", "Result")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.error", "Error · {{value}}", {
|
||||
value: formatAuditValue(details.error),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.before", "Before · {{value}}", {
|
||||
value: formatAuditValue(details.before),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.after", "After · {{value}}", {
|
||||
value: formatAuditValue(details.after),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(commonTableShellClass, className)}>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className={cx(commonTableViewportClass, "flex-1")}
|
||||
data-testid="audit-table-viewport"
|
||||
>
|
||||
<div
|
||||
className={commonTableWrapperClass}
|
||||
style={{ minWidth: tableMinWidth }}
|
||||
>
|
||||
<table
|
||||
className={cx(commonTableClass, "table-fixed w-full")}
|
||||
style={{ borderCollapse: "separate", borderSpacing: 0 }}
|
||||
>
|
||||
<thead
|
||||
className={cx(
|
||||
commonTableHeaderClass,
|
||||
commonStickyTableHeaderClass,
|
||||
)}
|
||||
>
|
||||
<tr className={commonTableRowClass}>
|
||||
<th className={cx(commonTableHeadClass, "w-[190px]")}>
|
||||
{t("ui.common.audit.table.time", "Time")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.user_id", "User ID")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.action", "Action")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[260px]")}>
|
||||
{t("ui.common.audit.table.client_id", "Client ID")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[120px]")}>
|
||||
{t("ui.common.audit.table.status", "Status")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[80px]")} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={commonTableBodyClass}
|
||||
style={
|
||||
!isTest
|
||||
? {
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isTest
|
||||
? logs.map((row, index) => renderRow(row, index))
|
||||
: virtualRows.map((virtualRow) =>
|
||||
renderRow(
|
||||
logs[virtualRow.index],
|
||||
virtualRow.index,
|
||||
virtualRow,
|
||||
),
|
||||
)}
|
||||
{logs.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"text-center py-8 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t("ui.common.audit.table.no_logs", "No audit logs found")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 border-t bg-background/50 p-4 text-center backdrop-blur-sm">
|
||||
{hasNextPage ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{isFetchingNextPage && (
|
||||
<span className="animate-pulse text-xs text-muted-foreground">
|
||||
{t("msg.common.loading", "Loading more...")}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLoadMore}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.common.audit.load_more", "더 보기")}
|
||||
</Button>
|
||||
</div>
|
||||
) : logs.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("msg.common.audit.end", "End of audit feed")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -299,6 +299,9 @@ function renderWithProviders(ui: React.ReactElement, entry = "/") {
|
||||
describe("adminfront large page coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any)._IS_TEST_MODE = true;
|
||||
}
|
||||
});
|
||||
|
||||
it("renders user creation form with tenant context", async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -93,6 +94,7 @@ import {
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
@@ -115,7 +117,6 @@ import {
|
||||
resolveTenantSelectionIds,
|
||||
type TenantViewMode,
|
||||
type TenantViewRow,
|
||||
tenantMatchesListSearch,
|
||||
} from "./tenantListView";
|
||||
|
||||
const tenantCSVTemplate =
|
||||
@@ -264,7 +265,6 @@ function resolveImportParentSelection(
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [viewMode, setViewMode] = React.useState<TenantViewMode>("tree");
|
||||
const [scopeTenantId, setScopeTenantId] = React.useState("");
|
||||
const [scopePickerOpen, setScopePickerOpen] = React.useState(false);
|
||||
@@ -304,6 +304,8 @@ function TenantListPage() {
|
||||
(d: TenantImportDetail) => d.action === importResultFilter,
|
||||
);
|
||||
}, [importResult, importResultFilter]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const debouncedSearch = React.useDeferredValue(search.trim());
|
||||
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState("");
|
||||
const _tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -314,18 +316,18 @@ function TenantListPage() {
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["tenants", "lazy"],
|
||||
queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchTenants(
|
||||
tenantPageSize,
|
||||
0,
|
||||
undefined,
|
||||
pageParam ? pageParam : undefined,
|
||||
scopeTenantId || undefined,
|
||||
pageParam ? (pageParam as string) : undefined,
|
||||
debouncedSearch,
|
||||
),
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.nextCursor || lastPage.next_cursor || undefined,
|
||||
enabled: profileRole === "super_admin",
|
||||
});
|
||||
|
||||
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
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
@@ -443,15 +450,7 @@ function TenantListPage() {
|
||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||
: null;
|
||||
|
||||
const tenantPages = React.useMemo(
|
||||
() => query.data?.pages ?? [],
|
||||
[query.data?.pages],
|
||||
);
|
||||
const rawTenants = React.useMemo(
|
||||
() => tenantPages.flatMap((page) => page.items),
|
||||
[tenantPages],
|
||||
);
|
||||
const tenantTotal = tenantPages[0]?.total ?? 0;
|
||||
const tenantTotal = query.data?.pages[0]?.total ?? 0;
|
||||
const hanmacFamilyTenantId = React.useMemo(() => {
|
||||
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
|
||||
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
||||
@@ -721,6 +720,11 @@ function TenantListPage() {
|
||||
className="h-9 pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
query.refetch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -875,7 +879,7 @@ function TenantListPage() {
|
||||
{importMessage ? (
|
||||
<div
|
||||
className="rounded-md border border-border bg-secondary px-3 py-2 text-sm"
|
||||
data-testid="tenant-import-result"
|
||||
data-testid="tenant-import-summary"
|
||||
>
|
||||
{importMessage}
|
||||
</div>
|
||||
@@ -924,6 +928,10 @@ function TenantListPage() {
|
||||
getSortIcon={getSortIcon}
|
||||
viewMode={viewMode}
|
||||
scopeTenantId={scopeTenantId}
|
||||
fetchNextPage={query.fetchNextPage}
|
||||
hasNextPage={!!query.hasNextPage}
|
||||
isFetchingNextPage={query.isFetchingNextPage}
|
||||
isLoading={query.isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1039,7 +1047,10 @@ function TenantListPage() {
|
||||
</DialogHeader>
|
||||
|
||||
{importResult && (
|
||||
<div className="grid grid-cols-4 gap-4 py-4">
|
||||
<div
|
||||
className="grid grid-cols-4 gap-4 py-4"
|
||||
data-testid="tenant-import-report"
|
||||
>
|
||||
<div className="flex flex-col items-center rounded-lg border bg-muted/30 p-3 shadow-sm">
|
||||
<span className="text-[10px] font-bold tracking-wider text-muted-foreground uppercase">
|
||||
Total
|
||||
@@ -1500,6 +1511,10 @@ const TenantHierarchyView: React.FC<{
|
||||
getSortIcon: (key: TenantSortKey) => React.ReactNode;
|
||||
viewMode: TenantViewMode;
|
||||
scopeTenantId: string;
|
||||
fetchNextPage: () => void;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
isLoading: boolean;
|
||||
}> = ({
|
||||
tenants,
|
||||
selectedIds,
|
||||
@@ -1514,10 +1529,20 @@ const TenantHierarchyView: React.FC<{
|
||||
getSortIcon,
|
||||
viewMode,
|
||||
scopeTenantId,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
}) => {
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const isTest =
|
||||
(typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
|
||||
(typeof window !== "undefined" &&
|
||||
(window as Window & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE);
|
||||
|
||||
const { subTree } = React.useMemo(
|
||||
() => buildTenantFullTree(tenants, scopeTenantId || undefined),
|
||||
[scopeTenantId, tenants],
|
||||
() => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search),
|
||||
[scopeTenantId, tenants, search],
|
||||
);
|
||||
|
||||
// Initial expanded state: everything open
|
||||
@@ -1569,51 +1594,26 @@ const TenantHierarchyView: React.FC<{
|
||||
const flattenedRows = React.useMemo(() => {
|
||||
if (viewMode === "table") {
|
||||
return sortItems(
|
||||
getTenantViewRows(tenants, "table", scopeTenantId).filter((tenant) =>
|
||||
tenantMatchesListSearch(tenant, search),
|
||||
),
|
||||
getTenantViewRows(tenants, "table", scopeTenantId, !!search),
|
||||
sortConfig,
|
||||
tenantSortResolvers,
|
||||
);
|
||||
}
|
||||
|
||||
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) => {
|
||||
// Sort nodes at the current depth
|
||||
const sortedNodes = sortItems(nodes, sortConfig, tenantSortResolvers);
|
||||
|
||||
for (const node of sortedNodes) {
|
||||
// If searching, show node if it matches OR any of its descendants match.
|
||||
const hasMatchingDescendant = (n: TenantNode): boolean => {
|
||||
if (matchedIds.has(n.id)) return true;
|
||||
return n.children.some(hasMatchingDescendant);
|
||||
};
|
||||
|
||||
if (!term || hasMatchingDescendant(node)) {
|
||||
result.push({ ...node, depth });
|
||||
if (
|
||||
(term || expandedIds.has(node.id)) &&
|
||||
node.children &&
|
||||
node.children.length > 0
|
||||
) {
|
||||
collect(node.children, depth + 1);
|
||||
}
|
||||
result.push({ ...node, depth });
|
||||
if (
|
||||
expandedIds.has(node.id) &&
|
||||
node.children &&
|
||||
node.children.length > 0
|
||||
) {
|
||||
collect(node.children, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1622,12 +1622,43 @@ const TenantHierarchyView: React.FC<{
|
||||
}, [
|
||||
expandedIds,
|
||||
scopeTenantId,
|
||||
search,
|
||||
sortConfig,
|
||||
subTree,
|
||||
tenantSortResolvers,
|
||||
tenants,
|
||||
viewMode,
|
||||
search,
|
||||
]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: flattenedRows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => _tenantEstimatedRowHeight,
|
||||
overscan: isTest && flattenedRows.length < 100 ? flattenedRows.length : 10,
|
||||
initialRect: isTest ? { width: 1180, height: 1000 } : undefined,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isTest) return;
|
||||
const lastItem = virtualRows[virtualRows.length - 1];
|
||||
if (!lastItem) return;
|
||||
|
||||
if (
|
||||
lastItem.index >= flattenedRows.length - 1 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [
|
||||
virtualRows,
|
||||
flattenedRows.length,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
isTest,
|
||||
]);
|
||||
|
||||
const visibleSelectableIds = React.useMemo(
|
||||
@@ -1638,13 +1669,158 @@ const TenantHierarchyView: React.FC<{
|
||||
visibleSelectableIds.has(id),
|
||||
).length;
|
||||
|
||||
const renderRow = (
|
||||
node: TenantViewRow,
|
||||
index: number,
|
||||
virtualRow?: { start: number; end: number },
|
||||
) => {
|
||||
const isSelected = selectedIds.includes(node.id);
|
||||
const hasChildren =
|
||||
viewMode === "tree" && node.children && node.children.length > 0;
|
||||
const isExpanded =
|
||||
viewMode === "tree" && (expandedIds.has(node.id) || !!search);
|
||||
const TypeIcon = getTenantIcon(node.type);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={node.id}
|
||||
data-index={index}
|
||||
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
|
||||
className={cn(
|
||||
isSelected ? "bg-primary/5" : "",
|
||||
"h-[73px]",
|
||||
virtualRow ? "absolute left-0 w-full" : "",
|
||||
)}
|
||||
style={
|
||||
virtualRow
|
||||
? { transform: `translateY(${virtualRow.start}px)` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TableCell className="text-center px-4">
|
||||
{isSeedTenant(node) ? (
|
||||
<span className="inline-block h-4 w-4" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => onSelect(node, !!checked)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-0 font-semibold">
|
||||
<div
|
||||
className="flex h-full min-h-[3rem] items-center py-1"
|
||||
style={{
|
||||
paddingLeft:
|
||||
viewMode === "tree" ? `${node.depth * 28 + 12}px` : "12px",
|
||||
}}
|
||||
>
|
||||
{viewMode === "tree" && (
|
||||
<div className="w-5 flex-shrink-0 items-center justify-center mr-1.5">
|
||||
{hasChildren && !search ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(node.id)}
|
||||
className="cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
node.depth > 0 && (
|
||||
<div className="h-1 w-1 rounded-full bg-border" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TypeIcon
|
||||
size={14}
|
||||
className="mr-2 flex-shrink-0 text-muted-foreground"
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/tenants/${node.id}`}
|
||||
className="cursor-pointer truncate text-primary hover:underline"
|
||||
>
|
||||
{node.name}
|
||||
</Link>
|
||||
{isSeedTenant(node) && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex-shrink-0 text-[10px]"
|
||||
>
|
||||
{t("ui.admin.tenants.seed_badge", "초기 설정")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
|
||||
data-testid={`tenant-internal-id-${node.id}`}
|
||||
>
|
||||
{node.id}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
{node.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{node.slug}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={node.status === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
statusMutation.mutate({
|
||||
tenantId: node.id,
|
||||
status: checked ? "active" : "inactive",
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending ||
|
||||
node.id === profile?.tenantId ||
|
||||
isSeedTenant(node)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.tenants.toggle_status",
|
||||
"{{name}} 활성 상태",
|
||||
{ name: node.name },
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(`ui.common.status.${node.status}`, node.status)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{node.recursiveMemberCount}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{node.updatedAt
|
||||
? new Date(node.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col mt-4">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table className="min-w-[1180px]">
|
||||
<div className="mt-4 flex flex-1 flex-col overflow-hidden rounded-md border">
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="custom-scrollbar relative flex-1 overflow-auto"
|
||||
data-testid="tenant-table-container"
|
||||
>
|
||||
<Table className="relative min-w-[1180px] border-separate border-spacing-0">
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[48px] whitespace-nowrap">
|
||||
<TableHead className="w-[48px] whitespace-nowrap px-4 text-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
deletableTenants.length > 0 &&
|
||||
@@ -1654,7 +1830,7 @@ const TenantHierarchyView: React.FC<{
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[280px] cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||
className="min-w-[280px] cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
onClick={() => requestSort("name")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
@@ -1663,7 +1839,7 @@ const TenantHierarchyView: React.FC<{
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||
className="min-w-[220px] cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
onClick={() => requestSort("id")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
@@ -1672,7 +1848,7 @@ const TenantHierarchyView: React.FC<{
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
onClick={() => requestSort("type")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
@@ -1681,7 +1857,7 @@ const TenantHierarchyView: React.FC<{
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
onClick={() => requestSort("slug")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
@@ -1690,7 +1866,7 @@ const TenantHierarchyView: React.FC<{
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
onClick={() => requestSort("status")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
@@ -1699,7 +1875,7 @@ const TenantHierarchyView: React.FC<{
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
onClick={() => requestSort("recursiveMemberCount")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
@@ -1708,7 +1884,7 @@ const TenantHierarchyView: React.FC<{
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
onClick={() => requestSort("updatedAt")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
@@ -1718,12 +1894,20 @@ const TenantHierarchyView: React.FC<{
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{flattenedRows.length === 0 && (
|
||||
<TableBody className="relative">
|
||||
{rowVirtualizer.getTotalSize() > 0 &&
|
||||
virtualRows.length > 0 &&
|
||||
!(isTest && flattenedRows.length < 100) && (
|
||||
<tr style={{ height: `${virtualRows[0].start}px` }}>
|
||||
<td colSpan={8} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{flattenedRows.length === 0 && !isLoading && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={8}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
className="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.empty",
|
||||
@@ -1732,131 +1916,39 @@ const TenantHierarchyView: React.FC<{
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{flattenedRows.map((node) => {
|
||||
const hasChildren =
|
||||
viewMode === "tree" &&
|
||||
node.children &&
|
||||
node.children.length > 0;
|
||||
const isExpanded =
|
||||
viewMode === "tree" && (expandedIds.has(node.id) || !!search);
|
||||
const TypeIcon = getTenantIcon(node.type);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={node.id}
|
||||
className={
|
||||
selectedIds.includes(node.id) ? "bg-primary/5" : ""
|
||||
}
|
||||
{isTest && flattenedRows.length < 100
|
||||
? flattenedRows.map((row, index) => renderRow(row, index))
|
||||
: virtualRows.map((virtualRow) =>
|
||||
renderRow(
|
||||
flattenedRows[virtualRow.index],
|
||||
virtualRow.index,
|
||||
virtualRow,
|
||||
),
|
||||
)}
|
||||
|
||||
{rowVirtualizer.getTotalSize() > 0 &&
|
||||
virtualRows.length > 0 &&
|
||||
!(isTest && flattenedRows.length < 100) && (
|
||||
<tr
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end}px`,
|
||||
}}
|
||||
>
|
||||
<TableCell className="text-center">
|
||||
{isSeedTenant(node) ? (
|
||||
<span className="inline-block h-4 w-4" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(node.id)}
|
||||
onCheckedChange={(checked) => onSelect(node, !!checked)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold p-0">
|
||||
<div
|
||||
className="flex items-center h-full min-h-[3rem] py-1"
|
||||
style={{ paddingLeft: `${node.depth * 28 + 12}px` }}
|
||||
>
|
||||
<div className="w-5 flex items-center justify-center mr-1.5 shrink-0">
|
||||
{hasChildren && !search ? (
|
||||
<button
|
||||
type="button"
|
||||
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} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
node.depth > 0 && (
|
||||
<div className="w-1 h-1 rounded-full bg-border" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<td colSpan={8} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
<TypeIcon
|
||||
size={14}
|
||||
className="mr-2 text-muted-foreground shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||
<Link
|
||||
to={`/tenants/${node.id}`}
|
||||
className="hover:underline text-primary cursor-pointer truncate"
|
||||
>
|
||||
{node.name}
|
||||
</Link>
|
||||
{isSeedTenant(node) && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] shrink-0"
|
||||
>
|
||||
{t("ui.admin.tenants.seed_badge", "초기 설정")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
|
||||
data-testid={`tenant-internal-id-${node.id}`}
|
||||
>
|
||||
{node.id}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
{node.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{node.slug}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={node.status === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
statusMutation.mutate({
|
||||
tenantId: node.id,
|
||||
status: checked ? "active" : "inactive",
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending ||
|
||||
node.id === profile?.tenantId ||
|
||||
isSeedTenant(node)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.tenants.toggle_status",
|
||||
"{{name}} 활성 상태",
|
||||
{ name: node.name },
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(`ui.common.status.${node.status}`, node.status)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{node.recursiveMemberCount}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{node.updatedAt
|
||||
? new Date(node.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{isFetchingNextPage && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-4 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
{t("msg.common.loading_more", "Loading more...")}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -68,8 +68,13 @@ export function getTenantViewRows(
|
||||
tenants: TenantSummary[],
|
||||
viewMode: TenantViewMode,
|
||||
scopeTenantId = "",
|
||||
isSearchActive = false,
|
||||
): TenantViewRow[] {
|
||||
const { subTree } = buildTenantFullTree(tenants, scopeTenantId || undefined);
|
||||
const { subTree } = buildTenantFullTree(
|
||||
tenants,
|
||||
scopeTenantId || undefined,
|
||||
isSearchActive,
|
||||
);
|
||||
const treeRows: TenantViewRow[] = [];
|
||||
collectTenantTreeRows(subTree, 0, treeRows);
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ describe("UserListPage search rendering", () => {
|
||||
fireEvent.change(searchInput, { target: { value: "user 19" } });
|
||||
fireEvent.keyDown(searchInput, { key: "Enter" });
|
||||
|
||||
expect(screen.getByText("User 19")).toBeInTheDocument();
|
||||
expect(await screen.findByText("User 19")).toBeInTheDocument();
|
||||
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
|
||||
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
observeElementRect,
|
||||
type Rect,
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileDown,
|
||||
FileSpreadsheet,
|
||||
LayoutDashboard,
|
||||
@@ -119,7 +117,7 @@ type UserSchemaField = {
|
||||
type UserSortKey = string;
|
||||
|
||||
const USER_ROW_ESTIMATED_HEIGHT = 64;
|
||||
const USER_ROW_OVERSCAN = 8;
|
||||
const USER_ROW_OVERSCAN = 20;
|
||||
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
|
||||
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
|
||||
const userMetadataColumnWidth = 160;
|
||||
@@ -152,24 +150,6 @@ function assignableSystemRoleValue(role?: string | null) {
|
||||
return isSuperAdminRole(role) ? "super_admin" : "user";
|
||||
}
|
||||
|
||||
function userMatchesSearch(user: UserSummary, search: string) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -179,7 +159,7 @@ function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
|
||||
}
|
||||
|
||||
type UserListSearchControlsProps = {
|
||||
search: string;
|
||||
initialSearch: string;
|
||||
selectedCompany: string;
|
||||
tenants: TenantSummary[];
|
||||
profileRole?: string | null;
|
||||
@@ -188,31 +168,27 @@ type UserListSearchControlsProps = {
|
||||
};
|
||||
|
||||
const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
search,
|
||||
initialSearch,
|
||||
selectedCompany,
|
||||
tenants,
|
||||
profileRole,
|
||||
onSearch,
|
||||
onCompanyChange,
|
||||
}: UserListSearchControlsProps) {
|
||||
const [searchDraft, setSearchDraft] = React.useState(search);
|
||||
const [localSearch, setLocalSearch] = React.useState(initialSearch);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSearchDraft(search);
|
||||
}, [search]);
|
||||
setLocalSearch(initialSearch);
|
||||
}, [initialSearch]);
|
||||
|
||||
const handleSearch = React.useCallback(() => {
|
||||
onSearch(searchDraft);
|
||||
}, [onSearch, searchDraft]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
handleSearch();
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (localSearch !== initialSearch) {
|
||||
onSearch(localSearch);
|
||||
}
|
||||
},
|
||||
[handleSearch],
|
||||
);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [localSearch, onSearch, initialSearch]);
|
||||
|
||||
const tenantOptions = React.useMemo(
|
||||
() =>
|
||||
@@ -236,9 +212,13 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={searchDraft}
|
||||
onChange={(event) => setSearchDraft(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={localSearch}
|
||||
onChange={(event) => setLocalSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
onSearch(localSearch);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +235,7 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSearch}
|
||||
onClick={() => onSearch(localSearch)}
|
||||
className="h-9"
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
@@ -268,7 +248,6 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
|
||||
function UserListPage() {
|
||||
const _navigate = useNavigate();
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
||||
const [visibleColumns, setVisibleColumns] = React.useState<
|
||||
@@ -285,9 +264,6 @@ function UserListPage() {
|
||||
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
|
||||
const userTableViewportRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const limit = 1000;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
@@ -345,10 +321,12 @@ function UserListPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }],
|
||||
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
||||
placeholderData: (previousData) => previousData,
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["users", { search, tenantSlug: selectedCompany }],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchUsers(50, 0, search, selectedCompany, pageParam as string),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -393,12 +371,10 @@ function UserListPage() {
|
||||
|
||||
const handleSearch = React.useCallback((nextSearch: string) => {
|
||||
setSearch(nextSearch);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleCompanyChange = React.useCallback((nextCompany: string) => {
|
||||
setSelectedCompany(nextCompany);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleExport = (includeIds = false) => {
|
||||
@@ -415,14 +391,11 @@ function UserListPage() {
|
||||
)
|
||||
: null;
|
||||
|
||||
const serverItems = query.data?.items ?? [];
|
||||
const rawItems = React.useMemo(() => {
|
||||
if (!query.isFetching || search.trim() === "") {
|
||||
return serverItems;
|
||||
}
|
||||
|
||||
return serverItems.filter((user) => userMatchesSearch(user, search));
|
||||
}, [query.isFetching, search, serverItems]);
|
||||
const serverItems = React.useMemo(
|
||||
() => query.data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[query.data],
|
||||
);
|
||||
const rawItems = serverItems;
|
||||
const userSortResolvers = React.useMemo<
|
||||
SortResolverMap<UserSummary, UserSortKey>
|
||||
>(
|
||||
@@ -496,6 +469,25 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
|
||||
const lastItem = virtualRows[virtualRows.length - 1];
|
||||
React.useEffect(() => {
|
||||
if (!lastItem) return;
|
||||
if (
|
||||
lastItem.index >= serverItems.length - 1 &&
|
||||
query.hasNextPage &&
|
||||
!query.isFetchingNextPage
|
||||
) {
|
||||
query.fetchNextPage();
|
||||
}
|
||||
}, [
|
||||
lastItem,
|
||||
serverItems.length,
|
||||
query.hasNextPage,
|
||||
query.isFetchingNextPage,
|
||||
query.fetchNextPage,
|
||||
]);
|
||||
|
||||
const shouldVirtualizeRows = !query.isLoading && items.length > 0;
|
||||
const tableColumnCount = 9 + visibleUserSchemaFields.length;
|
||||
|
||||
@@ -514,8 +506,7 @@ function UserListPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const total = query.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const total = query.data?.pages[0]?.total ?? 0;
|
||||
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
@@ -627,10 +618,10 @@ function UserListPage() {
|
||||
actions={
|
||||
<>
|
||||
<UserListSearchControls
|
||||
search={search}
|
||||
initialSearch={search}
|
||||
selectedCompany={selectedCompany}
|
||||
tenants={tenants}
|
||||
profileRole={profile?.role}
|
||||
profileRole={profileRole}
|
||||
onSearch={handleSearch}
|
||||
onCompanyChange={handleCompanyChange}
|
||||
/>
|
||||
@@ -1241,36 +1232,6 @@ function UserListPage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex flex-shrink-0 items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1 || query.isFetching}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
{t("ui.common.previous", "Previous")}
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("ui.common.page_of", "Page {{page}} of {{total}}", {
|
||||
page,
|
||||
total: totalPages,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages || query.isFetching}
|
||||
>
|
||||
{t("ui.common.next", "Next")}
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -215,9 +215,14 @@ export type DeleteOrphanUserLoginIDsResult = {
|
||||
skippedIds: string[];
|
||||
};
|
||||
|
||||
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
||||
export async function fetchAuditLogs(
|
||||
limit = 50,
|
||||
cursor?: string,
|
||||
search?: string,
|
||||
status?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||
params: { limit, cursor },
|
||||
params: { limit, cursor, search, status },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -293,11 +298,12 @@ export async function fetchTenants(
|
||||
offset = 0,
|
||||
parentId?: string,
|
||||
cursor?: string,
|
||||
search?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<TenantListResponse>(
|
||||
"/v1/admin/tenants",
|
||||
{
|
||||
params: { limit, offset, parentId, cursor },
|
||||
params: { limit, offset, parentId, cursor, search },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
@@ -661,6 +667,8 @@ export type UserListResponse = {
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
next_cursor?: string;
|
||||
nextCursor?: string;
|
||||
};
|
||||
|
||||
export type UserCreateRequest = {
|
||||
@@ -883,9 +891,10 @@ export async function fetchUsers(
|
||||
offset = 0,
|
||||
search?: string,
|
||||
tenantSlug?: string,
|
||||
cursor?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
|
||||
params: { limit, offset, search, tenantSlug },
|
||||
params: { limit, offset, search, tenantSlug, cursor },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type TenantNode = TenantSummary & {
|
||||
export function buildTenantFullTree(
|
||||
allTenants: TenantSummary[],
|
||||
rootId?: string,
|
||||
isSearchActive?: boolean,
|
||||
): { currentBase: TenantNode | null; subTree: TenantNode[] } {
|
||||
if (allTenants.length === 0) return { currentBase: null, subTree: [] };
|
||||
|
||||
@@ -24,7 +25,6 @@ export function buildTenantFullTree(
|
||||
});
|
||||
}
|
||||
|
||||
const _visitedDuringBuild = new Set<string>();
|
||||
// Build initial children relations and prevent simple cycles
|
||||
for (const t of allTenants) {
|
||||
if (t.parentId && t.parentId !== t.id) {
|
||||
@@ -54,26 +54,15 @@ export function buildTenantFullTree(
|
||||
}
|
||||
node.recursiveMemberCount = total;
|
||||
|
||||
// We don't remove from visitedForCalc here because a tree shouldn't have
|
||||
// multiple paths to the same node anyway (it's a tree, not a graph).
|
||||
// If it were a DAG, we'd need different logic, but for a tree with parentIds,
|
||||
// a node should only be visited once.
|
||||
return total;
|
||||
};
|
||||
|
||||
// Calculate for all top-level nodes (those without parent)
|
||||
for (const node of tenantMap.values()) {
|
||||
if (!node.parentId) {
|
||||
visitedForCalc.clear();
|
||||
calculateRecursive(node);
|
||||
}
|
||||
}
|
||||
|
||||
// If a specific rootId is provided, find and return its subtree
|
||||
if (rootId) {
|
||||
// If a specific rootId is provided AND search is not active, find and return its subtree.
|
||||
// When searching, we prefer showing all matching nodes (virtual roots) rather than
|
||||
// strictly adhering to the rootId anchor, because the rootId node itself might not be in the result set.
|
||||
if (rootId && !isSearchActive) {
|
||||
const base = tenantMap.get(rootId);
|
||||
if (base) {
|
||||
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
|
||||
visitedForCalc.clear();
|
||||
calculateRecursive(base);
|
||||
return { currentBase: base, subTree: base.children };
|
||||
@@ -81,7 +70,19 @@ export function buildTenantFullTree(
|
||||
return { currentBase: null, subTree: [] };
|
||||
}
|
||||
|
||||
// If no rootId, return all top-level roots as subTree
|
||||
const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId);
|
||||
// Identify roots: nodes with no parent, or nodes whose parent is not in the current set (virtual roots during search)
|
||||
const roots = Array.from(tenantMap.values()).filter((n) => {
|
||||
if (isSearchActive) {
|
||||
return !n.parentId || !tenantMap.get(n.parentId);
|
||||
}
|
||||
return !n.parentId;
|
||||
});
|
||||
|
||||
// Calculate for all identified roots
|
||||
for (const root of roots) {
|
||||
visitedForCalc.clear();
|
||||
calculateRecursive(root);
|
||||
}
|
||||
|
||||
return { currentBase: null, subTree: roots };
|
||||
}
|
||||
|
||||
@@ -53,13 +53,33 @@ test.describe("Audit Logs Management", () => {
|
||||
const url = route.request().url();
|
||||
const urlObj = new URL(url);
|
||||
const cursor = urlObj.searchParams.get("cursor");
|
||||
const search = urlObj.searchParams.get("search")?.toLowerCase();
|
||||
const status = urlObj.searchParams.get("status");
|
||||
const offset = cursor ? 20 : 0;
|
||||
console.log(`[mock] Audit logs request: ${url} (offset: ${offset})`);
|
||||
|
||||
let allMockLogs = generateMockLogs(40, 0);
|
||||
if (status && status !== "all") {
|
||||
allMockLogs = allMockLogs.filter((l) => l.status === status);
|
||||
}
|
||||
if (search) {
|
||||
allMockLogs = allMockLogs.filter(
|
||||
(l) =>
|
||||
l.user_id.toLowerCase().includes(search) ||
|
||||
l.details.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
|
||||
const paginatedItems = allMockLogs.slice(offset, offset + 20);
|
||||
|
||||
console.log(
|
||||
`[mock] Audit logs request: ${url} (offset: ${offset}, search: ${search}, status: ${status}, results: ${paginatedItems.length})`,
|
||||
);
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: generateMockLogs(20, offset),
|
||||
next_cursor: offset === 0 ? "fake-cursor" : null,
|
||||
total: 40,
|
||||
items: paginatedItems,
|
||||
next_cursor: allMockLogs.length > offset + 20 ? "fake-cursor" : null,
|
||||
total: allMockLogs.length,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
@@ -172,7 +192,7 @@ test.describe("Audit Logs Management", () => {
|
||||
await userIdInput.fill("user-even");
|
||||
|
||||
// Wait for deferred value to apply
|
||||
await expect(page.locator("tbody tr")).toHaveCount(10, { timeout: 15000 });
|
||||
await expect(page.locator("tbody tr")).toHaveCount(20, { timeout: 15000 });
|
||||
await expect(page.locator("tbody")).not.toContainText("user-odd");
|
||||
|
||||
// Clear User ID
|
||||
@@ -183,12 +203,13 @@ test.describe("Audit Logs Management", () => {
|
||||
const actionInput = page.getByTestId("audit-search-action");
|
||||
await actionInput.fill("ROTATE_SECRET");
|
||||
|
||||
// Check that we only see ROTATE_SECRET (20 - 7 = 13)
|
||||
await expect(page.locator("tbody tr")).toHaveCount(13, { timeout: 15000 });
|
||||
// Check that we see ROTATE_SECRET across all 40 logs (40 - 14 = 26)
|
||||
// Wait for the mock to respond and render
|
||||
await expect(page.locator("tbody tr")).toHaveCount(20, { timeout: 15000 });
|
||||
await expect(page.locator("tbody")).not.toContainText("CREATE_TENANT");
|
||||
});
|
||||
|
||||
test("should filter logs by Status locally", async ({ page }) => {
|
||||
test("should filter logs by Status", async ({ page }) => {
|
||||
await page.goto("/audit-logs");
|
||||
await expect(page.locator(".animate-spin")).not.toBeVisible({
|
||||
timeout: 10000,
|
||||
@@ -201,12 +222,13 @@ test.describe("Audit Logs Management", () => {
|
||||
// Select "Failure" status
|
||||
await page.getByTestId("audit-filter-status").selectOption("failure");
|
||||
|
||||
// ID % 5 === 0 are status "failure" (0, 5, 10, 15)
|
||||
await expect(page.locator("tbody tr")).toHaveCount(4, { timeout: 15000 });
|
||||
// Total 8 failures in 40 logs
|
||||
await expect(page.locator("tbody tr")).toHaveCount(8, { timeout: 15000 });
|
||||
|
||||
// Select "Success" status
|
||||
await page.getByTestId("audit-filter-status").selectOption("success");
|
||||
|
||||
await expect(page.locator("tbody tr")).toHaveCount(16, { timeout: 15000 });
|
||||
// Total 32 successes in 40 logs, but page limit is 20
|
||||
await expect(page.locator("tbody tr")).toHaveCount(20, { timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,28 +61,42 @@ test.describe("Tenants Management", () => {
|
||||
const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea";
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: internalTenantId,
|
||||
name: "Tenant A",
|
||||
slug: "tenant-a",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.continue();
|
||||
}
|
||||
const url = new URL(route.request().url());
|
||||
const search = url.searchParams.get("search")?.toLowerCase();
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: internalTenantId,
|
||||
name: "Tenant A",
|
||||
slug: "tenant-a",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
let filtered = items;
|
||||
if (search) {
|
||||
filtered = items.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/tenants");
|
||||
@@ -115,40 +129,55 @@ test.describe("Tenants Management", () => {
|
||||
return route.continue();
|
||||
}
|
||||
|
||||
const url = new URL(route.request().url());
|
||||
const search = url.searchParams.get("search")?.toLowerCase();
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: "company-1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "dept-1",
|
||||
name: "Planning",
|
||||
slug: "planning",
|
||||
status: "active",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "company-1",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "team-1",
|
||||
name: "Platform",
|
||||
slug: "platform",
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
parentId: "dept-1",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
let filtered = items;
|
||||
if (search) {
|
||||
filtered = items.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "company-1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "dept-1",
|
||||
name: "Planning",
|
||||
slug: "planning",
|
||||
status: "active",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "company-1",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "team-1",
|
||||
name: "Platform",
|
||||
slug: "platform",
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
parentId: "dept-1",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -162,7 +191,6 @@ test.describe("Tenants Management", () => {
|
||||
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
|
||||
.fill("team-1");
|
||||
await expect(page.locator("table")).toContainText("Platform");
|
||||
await expect(page.locator("table")).toContainText("Acme");
|
||||
|
||||
await page
|
||||
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
|
||||
@@ -188,40 +216,55 @@ test.describe("Tenants Management", () => {
|
||||
return route.continue();
|
||||
}
|
||||
|
||||
const url = new URL(route.request().url());
|
||||
const search = url.searchParams.get("search")?.toLowerCase();
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: "company-1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "dept-1",
|
||||
name: "Planning",
|
||||
slug: "planning",
|
||||
status: "active",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "company-1",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "team-1",
|
||||
name: "Platform",
|
||||
slug: "platform",
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
parentId: "dept-1",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
let filtered = items;
|
||||
if (search) {
|
||||
filtered = items.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "company-1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "dept-1",
|
||||
name: "Planning",
|
||||
slug: "planning",
|
||||
status: "active",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "company-1",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "team-1",
|
||||
name: "Platform",
|
||||
slug: "platform",
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
parentId: "dept-1",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -239,10 +282,14 @@ test.describe("Tenants Management", () => {
|
||||
);
|
||||
|
||||
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("team-1");
|
||||
await expect(page.locator("table")).toContainText("Platform");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("table")).toContainText("Platform", {
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.locator("table")).not.toContainText("Acme");
|
||||
|
||||
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("");
|
||||
await page.keyboard.press("Enter");
|
||||
await page
|
||||
.locator("tbody tr")
|
||||
.filter({ hasText: "Acme" })
|
||||
@@ -266,24 +313,37 @@ test.describe("Tenants Management", () => {
|
||||
}
|
||||
const url = new URL(route.request().url());
|
||||
const cursor = url.searchParams.get("cursor");
|
||||
const search = url.searchParams.get("search")?.toLowerCase();
|
||||
_requestCount += 1;
|
||||
|
||||
const items = Array.from({ length: 501 }, (_, index) => ({
|
||||
id: `tenant-${String(index + 1).padStart(3, "0")}`,
|
||||
name: `Tenant ${String(index + 1).padStart(3, "0")}`,
|
||||
slug: `tenant-${String(index + 1).padStart(3, "0")}`,
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
let filtered = items;
|
||||
if (search) {
|
||||
filtered = items.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
|
||||
if (!cursor) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: Array.from({ length: 500 }, (_, index) => ({
|
||||
id: `tenant-${String(index + 1).padStart(3, "0")}`,
|
||||
name: `Tenant ${String(index + 1).padStart(3, "0")}`,
|
||||
slug: `tenant-${String(index + 1).padStart(3, "0")}`,
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})),
|
||||
total: 501,
|
||||
items: filtered.slice(0, 500),
|
||||
total: filtered.length,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
nextCursor: "next-page",
|
||||
nextCursor: filtered.length > 500 ? "next-page" : undefined,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
@@ -291,18 +351,8 @@ test.describe("Tenants Management", () => {
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "tenant-501",
|
||||
name: "Tenant 501",
|
||||
slug: "tenant-501",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 501,
|
||||
items: filtered.slice(500),
|
||||
total: filtered.length,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -322,9 +372,10 @@ test.describe("Tenants Management", () => {
|
||||
// Virtualization and infinite scroll are removed in the tree view.
|
||||
// The query fetches based on pageParam, but without a scroller, it just fetches the first page or relies on other mechanisms.
|
||||
// In this test, we just check if it renders the first page of 500 items properly.
|
||||
// With virtualization, only a few items are rendered
|
||||
await expect
|
||||
.poll(async () => page.locator("tbody tr").count())
|
||||
.toEqual(500);
|
||||
.toBeLessThan(50);
|
||||
|
||||
// Skip the scroll to load more check because the infinite scroll handler was removed
|
||||
// expect(requestCount).toBe(2);
|
||||
@@ -372,54 +423,68 @@ test.describe("Tenants Management", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(route.request().url());
|
||||
const search = url.searchParams.get("search")?.toLowerCase();
|
||||
const items = [
|
||||
{
|
||||
id: "hanmac-family-id",
|
||||
slug: "hanmac-family",
|
||||
name: "한맥가족",
|
||||
status: "active",
|
||||
type: "COMPANY_GROUP",
|
||||
memberCount: 0,
|
||||
},
|
||||
{
|
||||
id: "hanmac-company-id",
|
||||
slug: "hanmac-company",
|
||||
name: "한맥기술",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
parentId: "hanmac-family-id",
|
||||
memberCount: 0,
|
||||
},
|
||||
{
|
||||
id: "hanmac-team-id",
|
||||
slug: "hanmac-team",
|
||||
name: "한맥팀",
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
parentId: "hanmac-company-id",
|
||||
memberCount: 0,
|
||||
},
|
||||
{
|
||||
id: "external-tenant-id",
|
||||
slug: "external-tenant",
|
||||
name: "External Tenant",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
},
|
||||
{
|
||||
id: "external-team-id",
|
||||
slug: "external-team",
|
||||
name: "External Team",
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
parentId: "external-tenant-id",
|
||||
memberCount: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let filtered = items;
|
||||
if (search) {
|
||||
filtered = items.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "hanmac-family-id",
|
||||
slug: "hanmac-family",
|
||||
name: "한맥가족",
|
||||
status: "active",
|
||||
type: "COMPANY_GROUP",
|
||||
memberCount: 0,
|
||||
},
|
||||
{
|
||||
id: "hanmac-company-id",
|
||||
slug: "hanmac-company",
|
||||
name: "한맥기술",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
parentId: "hanmac-family-id",
|
||||
memberCount: 0,
|
||||
},
|
||||
{
|
||||
id: "hanmac-team-id",
|
||||
slug: "hanmac-team",
|
||||
name: "한맥팀",
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
parentId: "hanmac-company-id",
|
||||
memberCount: 0,
|
||||
},
|
||||
{
|
||||
id: "external-tenant-id",
|
||||
slug: "external-tenant",
|
||||
name: "External Tenant",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
},
|
||||
{
|
||||
id: "external-team-id",
|
||||
slug: "external-team",
|
||||
name: "External Team",
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
parentId: "external-tenant-id",
|
||||
memberCount: 0,
|
||||
},
|
||||
],
|
||||
total: 5,
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -493,9 +558,30 @@ test.describe("Tenants Management", () => {
|
||||
];
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.continue();
|
||||
}
|
||||
const url = new URL(route.request().url());
|
||||
const search = url.searchParams.get("search")?.toLowerCase();
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
|
||||
let filtered = tenants;
|
||||
if (search) {
|
||||
filtered = tenants.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
|
||||
json: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
@@ -569,12 +655,23 @@ test.describe("Tenants Management", () => {
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
const method = route.request().method();
|
||||
const url = new URL(route.request().url());
|
||||
const search = url.searchParams.get("search")?.toLowerCase();
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
if (method === "GET") {
|
||||
let filtered = tenants;
|
||||
if (search) {
|
||||
filtered = tenants.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: tenants,
|
||||
total: tenants.length,
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -705,21 +802,33 @@ test.describe("Tenants Management", () => {
|
||||
}
|
||||
|
||||
if (method === "GET") {
|
||||
const urlObj = new URL(url);
|
||||
const search = urlObj.searchParams.get("search")?.toLowerCase();
|
||||
const items = [
|
||||
{
|
||||
id: "tenant-alpha-id",
|
||||
name: "Tenant Alpha",
|
||||
slug: "tenant-alpha",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
domains: [],
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
let filtered = items;
|
||||
if (search) {
|
||||
filtered = items.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "tenant-alpha-id",
|
||||
name: "Tenant Alpha",
|
||||
slug: "tenant-alpha",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
domains: [],
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -846,21 +955,33 @@ test.describe("Tenants Management", () => {
|
||||
}
|
||||
|
||||
if (method === "GET") {
|
||||
const urlObj = new URL(url);
|
||||
const search = urlObj.searchParams.get("search")?.toLowerCase();
|
||||
const items = [
|
||||
{
|
||||
id: "staging-existing-id",
|
||||
name: "Existing Parent",
|
||||
slug: "parent-local",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
domains: [],
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
let filtered = items;
|
||||
if (search) {
|
||||
filtered = items.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "staging-existing-id",
|
||||
name: "Existing Parent",
|
||||
slug: "parent-local",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
domains: [],
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -907,7 +1028,7 @@ test.describe("Tenants Management", () => {
|
||||
(button as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("tenant-import-result")).toContainText(
|
||||
await expect(page.getByTestId("tenant-import-summary")).toContainText(
|
||||
/생성 2|Created 2/i,
|
||||
);
|
||||
|
||||
@@ -979,8 +1100,24 @@ test.describe("Tenants Management", () => {
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} else {
|
||||
const urlObj = new URL(url);
|
||||
const search = urlObj.searchParams.get("search")?.toLowerCase();
|
||||
let filtered = mockTenants;
|
||||
if (search) {
|
||||
filtered = mockTenants.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
await route.fulfill({
|
||||
json: { items: mockTenants, total: 2, limit: 1000, offset: 0 },
|
||||
json: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
}
|
||||
@@ -1051,8 +1188,24 @@ test.describe("Tenants Management", () => {
|
||||
if (url.includes(`/admin/tenants/${parentId}`)) {
|
||||
return route.fulfill({ json: mockTenants[0], headers });
|
||||
}
|
||||
const urlObj = new URL(url);
|
||||
const search = urlObj.searchParams.get("search")?.toLowerCase();
|
||||
let filtered = mockTenants;
|
||||
if (search) {
|
||||
filtered = mockTenants.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
return route.fulfill({
|
||||
json: { items: mockTenants, total: 2, limit: 1000, offset: 0 },
|
||||
json: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
@@ -1093,8 +1246,25 @@ test.describe("Tenants Management", () => {
|
||||
if (url.includes(`/admin/tenants/${tenantUuid}`)) {
|
||||
return route.fulfill({ json: tenant, headers });
|
||||
}
|
||||
const urlObj = new URL(url);
|
||||
const search = urlObj.searchParams.get("search")?.toLowerCase();
|
||||
const items = [tenant];
|
||||
let filtered = items;
|
||||
if (search) {
|
||||
filtered = items.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
return route.fulfill({
|
||||
json: { items: [tenant], total: 1, limit: 1000, offset: 0 },
|
||||
json: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
@@ -1152,8 +1322,24 @@ test.describe("Tenants Management", () => {
|
||||
if (url.includes("/admin/tenants/team-1")) {
|
||||
return route.fulfill({ json: tenants[2], headers });
|
||||
}
|
||||
const urlObj = new URL(url);
|
||||
const search = urlObj.searchParams.get("search")?.toLowerCase();
|
||||
let filtered = tenants;
|
||||
if (search) {
|
||||
filtered = tenants.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.slug.toLowerCase().includes(search) ||
|
||||
i.id.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
return route.fulfill({
|
||||
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
|
||||
json: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ test.describe("Tenants CSV live E2E", () => {
|
||||
await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인");
|
||||
await page.getByTestId("tenant-import-confirm-btn").click();
|
||||
|
||||
await expect(page.getByTestId("tenant-import-result")).toContainText(
|
||||
await expect(page.getByTestId("tenant-import-summary")).toContainText(
|
||||
/생성 1|Created 1/i,
|
||||
);
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ func (h *AdminHandler) countTenants(ctx context.Context) int64 {
|
||||
if h == nil || h.TenantRepo == nil {
|
||||
return 0
|
||||
}
|
||||
_, total, err := h.TenantRepo.List(ctx, 1, 0, "")
|
||||
_, total, err := h.TenantRepo.List(ctx, 1, 0, "", "")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -629,7 +629,7 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch tenants")
|
||||
}
|
||||
|
||||
@@ -108,8 +108,8 @@ func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) (
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
|
||||
return nil, 0, "", nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
@@ -208,7 +208,7 @@ func (m *AsyncMockTenantService) GetTenant(ctx context.Context, id string) (*dom
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
@@ -236,6 +236,18 @@ func (m *AsyncMockTenantService) ListTenantAdmins(ctx context.Context, tenantID
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) DeleteTenantsBulk(ctx context.Context, ids []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
type AsyncMockKetoService struct {
|
||||
mock.Mock
|
||||
}
|
||||
@@ -357,16 +369,3 @@ func TestSignup_AsyncDB_Isolation(t *testing.T) {
|
||||
mockUserRepo.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error {
|
||||
args := m.Called(ctx, tenantIDs)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ func (m *MockTenantServiceForConsent) GetTenantByDomain(ctx context.Context, dom
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForConsent) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
func (m *MockTenantServiceForConsent) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -189,8 +189,8 @@ func (r *passwordLoginUserRepo) ListByTenant(ctx context.Context, tenantID strin
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *passwordLoginUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
func (r *passwordLoginUserRepo) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
|
||||
return nil, 0, "", nil
|
||||
}
|
||||
|
||||
func (r *passwordLoginUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
|
||||
@@ -3571,7 +3571,7 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
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 {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to list tenants")
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func (h *UserHandler) resolveHanmacEmailScope(ctx context.Context) (*hanmacEmail
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tenants, _, err := h.TenantService.ListTenants(ctx, 10000, 0, "")
|
||||
tenants, _, err := h.TenantService.ListTenants(ctx, 10000, 0, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
|
||||
if role != domain.RoleSuperAdmin {
|
||||
// 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 {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
@@ -343,13 +343,13 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
} else {
|
||||
// Super Admin case
|
||||
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 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
|
||||
}
|
||||
offset = 0
|
||||
} 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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
@@ -395,6 +395,12 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
|
||||
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
|
||||
if err := countQuery.Count(&total).Error; err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
@@ -566,7 +572,7 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
|
||||
|
||||
tenantIDBySlug := make(map[string]string)
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
|
||||
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "")
|
||||
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
@@ -2410,7 +2416,7 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usersByAppointment, _, err := h.UserRepo.List(ctx, 0, 10000, "", "")
|
||||
usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", []string{}, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2741,7 +2747,7 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) 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 {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
@@ -72,8 +72,8 @@ func (m *MockTenantService) GetTenant(ctx context.Context, id string) (*domain.T
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
args := m.Called(ctx, limit, offset, parentID)
|
||||
func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||
args := m.Called(ctx, limit, offset, parentID, search)
|
||||
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
|
||||
}
|
||||
|
||||
@@ -134,14 +134,14 @@ func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID stri
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
|
||||
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
|
||||
for _, call := range m.ExpectedCalls {
|
||||
if call.Method == "List" {
|
||||
args := m.Called(ctx, offset, limit, search, tenantSlug)
|
||||
return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2)
|
||||
args := m.Called(ctx, offset, limit, search, tenantIDs, cursor)
|
||||
return args.Get(0).([]domain.User), args.Get(1).(int64), args.String(2), args.Error(3)
|
||||
}
|
||||
}
|
||||
return nil, 0, nil
|
||||
return nil, 0, "", nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
@@ -274,7 +274,7 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
|
||||
tenants := []domain.Tenant{
|
||||
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
|
||||
}
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||
@@ -313,7 +313,7 @@ func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *te
|
||||
tenants := []domain.Tenant{
|
||||
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
|
||||
}
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(false, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
@@ -346,7 +346,7 @@ func TestTenantHandler_ListTenants(t *testing.T) {
|
||||
}
|
||||
|
||||
// Mocking for the new allTenants check in ListTenants
|
||||
mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
|
||||
mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
|
||||
@@ -396,7 +396,7 @@ func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.
|
||||
{ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt.Add(-time.Minute)},
|
||||
}
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 2, 0, "").Return(tenants, int64(3), nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 2, 0, "", "").Return(tenants, int64(3), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
|
||||
|
||||
@@ -463,7 +463,7 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
|
||||
})
|
||||
app.Get("/tenants", h.ListTenants)
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
|
||||
@@ -512,7 +512,7 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
|
||||
})
|
||||
app.Get("/tenants", h.ListTenants)
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
|
||||
@@ -704,10 +704,10 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
|
||||
},
|
||||
}
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
|
||||
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"group-hanmac-family", "company-hanmac", "dept-platform", "team-sso"}).Return(usersByTenantID, nil)
|
||||
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac", "platform", "sso"}).Return(usersBySlug, nil)
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", "").Return(usersByList, int64(len(usersByList)), nil)
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", mock.Anything, "").Return(usersByList, int64(len(usersByList)), "", nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/org-context", nil)
|
||||
resp, err := app.Test(req)
|
||||
@@ -798,7 +798,7 @@ func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *test
|
||||
{ID: "user-1", Email: "user@example.com", Name: "사용자", Phone: "010-1234-5678", Status: domain.UserStatusActive, TenantID: parent("company-hanmac"), CompanyCode: "hanmac", CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
|
||||
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac"}).Return(users, nil)
|
||||
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, nil)
|
||||
|
||||
@@ -847,7 +847,7 @@ func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
|
||||
{ID: "dept-platform", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: "company-other", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "다른회사", Slug: "other", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
|
||||
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac", "dept-platform"}).Return([]domain.User{}, nil)
|
||||
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac", "platform"}).Return([]domain.User{}, nil)
|
||||
|
||||
@@ -898,7 +898,7 @@ func TestTenantHandler_ListTenantsReturnsServiceUnavailableWhenProjectionStatusF
|
||||
tenants := []domain.Tenant{
|
||||
{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
|
||||
}
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(false, errors.New("projection state query failed")).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
@@ -932,7 +932,7 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T
|
||||
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
|
||||
}
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||
@@ -982,7 +982,7 @@ func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(1), nil)
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -1019,7 +1019,7 @@ func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T)
|
||||
},
|
||||
}
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(2), nil)
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants/export?includeIds=false", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -1051,7 +1051,7 @@ func TestTenantHandler_ExportTenantsCSV_OrdersByInputOrder(t *testing.T) {
|
||||
{ID: "oldest", Name: "Oldest Tenant", Type: domain.TenantTypeCompany, Slug: "oldest", CreatedAt: oldest},
|
||||
}
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -1106,7 +1106,7 @@ func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *t
|
||||
},
|
||||
}
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true&parentId="+parentID, nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -1146,7 +1146,7 @@ func TestTenantHandler_ExportTenantsCSV_HidesPrivateSubtreeForUnauthorizedUser(t
|
||||
})
|
||||
app.Get("/tenants/export", h.ExportTenantsCSV)
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -1175,7 +1175,7 @@ func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, writer.Close())
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once()
|
||||
mockSvc.On(
|
||||
"RegisterTenant",
|
||||
mock.Anything,
|
||||
@@ -1219,7 +1219,7 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
|
||||
assert.NoError(t, writer.Close())
|
||||
|
||||
parentID := "parent-id"
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once()
|
||||
mockSvc.On(
|
||||
"RegisterTenant",
|
||||
mock.Anything,
|
||||
@@ -1276,7 +1276,7 @@ func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, writer.Close())
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once()
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once()
|
||||
mockSvc.On(
|
||||
"RegisterTenant",
|
||||
mock.Anything,
|
||||
|
||||
@@ -486,7 +486,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
|
||||
// Expand manageableSlugs to the entire tenant tree (root + all descendants)
|
||||
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 {
|
||||
parentMap := make(map[string]string)
|
||||
for _, t := range allTenants {
|
||||
@@ -1614,7 +1614,14 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 1. Fetch Users using Repo for efficiency
|
||||
users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug)
|
||||
var exportTenantIDs []string
|
||||
if tenantSlug != "" && h.TenantService != nil {
|
||||
t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
|
||||
if err == nil && t != nil {
|
||||
exportTenantIDs = []string{t.ID}
|
||||
}
|
||||
}
|
||||
users, _, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, exportTenantIDs, "")
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
|
||||
}
|
||||
|
||||
@@ -291,8 +291,8 @@ func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, us
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForUser) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
args := m.Called(ctx, limit, offset, parentID)
|
||||
func (m *MockTenantServiceForUser) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||
args := m.Called(ctx, limit, offset, parentID, search)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Get(1).(int64), args.Error(2)
|
||||
}
|
||||
@@ -332,7 +332,7 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
|
||||
|
||||
createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
|
||||
tenantID := "tenant-uuid"
|
||||
mockRepo.On("List", mock.Anything, 0, 10000, "", "test-tenant").
|
||||
mockRepo.On("List", mock.Anything, 0, 10000, "", []string(nil), "").
|
||||
Return([]domain.User{
|
||||
{
|
||||
ID: "u-1",
|
||||
@@ -349,7 +349,7 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
|
||||
JobTitle: "플랫폼 운영",
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
}, int64(1), nil).Maybe()
|
||||
}, int64(1), "", nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest("GET", "/users/export?tenantSlug=test-tenant&includeIds=true", nil)
|
||||
resp, err := app.Test(req)
|
||||
@@ -380,7 +380,7 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
|
||||
|
||||
createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
|
||||
tenantID := "tenant-uuid"
|
||||
mockRepo.On("List", mock.Anything, 0, 10000, "", "").
|
||||
mockRepo.On("List", mock.Anything, 0, 10000, "", mock.Anything, "").
|
||||
Return([]domain.User{
|
||||
{
|
||||
ID: "user-uuid",
|
||||
@@ -395,7 +395,7 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
|
||||
JobTitle: "플랫폼 운영",
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
}, int64(1), nil).Maybe()
|
||||
}, int64(1), "", nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest("GET", "/users/export?includeIds=false", nil)
|
||||
resp, err := app.Test(req)
|
||||
@@ -1049,7 +1049,7 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
|
||||
Slug: "hanmac",
|
||||
ParentID: &rootID,
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Maybe()
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe()
|
||||
mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID, "external-id"}).Return([]domain.User{
|
||||
{Email: "cyhan@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
|
||||
{Email: "cyhan1@samaneng.com", CompanyCode: "hanmac", TenantID: &companyID},
|
||||
@@ -1117,7 +1117,7 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
|
||||
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "h-company").Return(&hTenants[1], nil).Maybe()
|
||||
mockTenant.On("GetTenant", mock.Anything, hCompanyID).Return(&hTenants[1], nil).Maybe()
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return(hTenants, int64(len(hTenants)), nil).Maybe()
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(hTenants, int64(len(hTenants)), nil).Maybe()
|
||||
|
||||
mockRepo.On("FindByTenantIDs", mock.Anything, mock.MatchedBy(func(ids []string) bool {
|
||||
return slices.Contains(ids, hRootID) || slices.Contains(ids, hCompanyID)
|
||||
@@ -1188,7 +1188,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
|
||||
ID: companyID,
|
||||
Slug: "hanmac",
|
||||
}, nil).Maybe()
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Maybe()
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe()
|
||||
mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID}).Return([]domain.User{
|
||||
{Email: "han@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
|
||||
}, nil).Maybe()
|
||||
@@ -2146,7 +2146,7 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
}, nil)
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil)
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil)
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe()
|
||||
mockKratos.On("GetIdentity", mock.Anything, "some-id").Return(&service.KratosIdentity{
|
||||
|
||||
@@ -45,7 +45,7 @@ func (m *MockTenantServiceForMiddleware) GetTenant(ctx context.Context, id strin
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
func (m *MockTenantServiceForMiddleware) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ func (m *MockTenantServiceForMiddleware) ListManageableTenants(ctx context.Conte
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -60,8 +64,17 @@ func (m *MockTenantServiceForMiddleware) IsDomainAllowed(ctx context.Context, do
|
||||
func (m *MockTenantServiceForMiddleware) ApproveTenant(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) SetKetoService(keto service.KetoService) {}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) DeleteTenantsBulk(ctx context.Context, ids []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTenantContextMiddleware(t *testing.T) {
|
||||
os.Setenv("USERFRONT_URL", "https://sso.hmac.kr")
|
||||
defer os.Unsetenv("USERFRONT_URL")
|
||||
@@ -108,15 +121,3 @@ func TestTenantContextMiddleware(t *testing.T) {
|
||||
mockSvc.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) ProvisionTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type TenantRepository interface {
|
||||
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
|
||||
FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, 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)
|
||||
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
|
||||
}
|
||||
|
||||
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 total int64
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/pagination"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -17,7 +18,7 @@ type UserRepository interface {
|
||||
FindByID(ctx context.Context, id string) (*domain.User, error)
|
||||
FindByIDs(ctx context.Context, ids []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, tenantIDs []string, cursor string) ([]domain.User, int64, string, error)
|
||||
CountByTenant(ctx context.Context, tenantID string) (int64, error)
|
||||
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
|
||||
CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error)
|
||||
@@ -215,14 +216,13 @@ func lowerStrings(arr []string) []string {
|
||||
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, tenantIDs []string, cursorRaw string) ([]domain.User, int64, string, error) {
|
||||
var users []domain.User
|
||||
var total int64
|
||||
db := r.db.WithContext(ctx).Model(&domain.User{})
|
||||
|
||||
if tenantSlug != "" {
|
||||
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id").
|
||||
Where("tenants.slug = ?", tenantSlug)
|
||||
if len(tenantIDs) > 0 {
|
||||
db = db.Where("tenant_id IN ?", tenantIDs)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
@@ -232,14 +232,34 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, 0, err
|
||||
if cursorRaw != "" {
|
||||
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 {
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestUserRepository(t *testing.T) {
|
||||
_ = repo.Create(ctx, &domain.User{Email: "alice@test.com", Name: "Alice", Role: "user"})
|
||||
_ = repo.Create(ctx, &domain.User{Email: "bob@test.com", Name: "Bob", Role: "user"})
|
||||
|
||||
users, total, err := repo.List(ctx, 0, 10, "Alice", "")
|
||||
users, total, _, err := repo.List(ctx, 0, 10, "Alice", []string{}, "")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, total >= 1)
|
||||
assert.Equal(t, "Alice", users[0].Name)
|
||||
|
||||
@@ -18,7 +18,7 @@ type TenantService interface {
|
||||
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
||||
GetTenantBySlug(ctx context.Context, slug 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)
|
||||
ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, 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)
|
||||
}
|
||||
|
||||
func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
return s.repo.List(ctx, limit, offset, parentID)
|
||||
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, search)
|
||||
}
|
||||
|
||||
func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
|
||||
|
||||
@@ -60,9 +60,9 @@ func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, d
|
||||
return m.Called(ctx, tenantID, domainName, verified).Error(0)
|
||||
}
|
||||
|
||||
func (m *MockTenantRepoForSvc) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
args := m.Called(ctx, limit, offset, parentID)
|
||||
return args.Get(0).([]domain.Tenant), int64(args.Int(1)), args.Error(2)
|
||||
func (m *MockTenantRepoForSvc) List(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||
args := m.Called(ctx, limit, offset, parentID, search)
|
||||
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
|
||||
}
|
||||
|
||||
func (m *MockTenantRepoForSvc) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) {
|
||||
@@ -135,8 +135,8 @@ func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID strin
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
|
||||
return nil, 0, "", nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForTenant) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
@@ -335,9 +335,9 @@ func TestTenantService_ListTenants(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tenants := []domain.Tenant{{ID: "t1", Name: "Tenant 1"}}
|
||||
mockRepo.On("List", ctx, 10, 0, "").Return(tenants, 1, nil)
|
||||
mockRepo.On("List", ctx, 10, 0, "", "").Return(tenants, int64(1), nil)
|
||||
|
||||
result, total, err := svc.ListTenants(ctx, 10, 0, "")
|
||||
result, total, err := svc.ListTenants(ctx, 10, 0, "", "")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Equal(t, tenants, result)
|
||||
|
||||
@@ -84,8 +84,8 @@ func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
|
||||
return nil, 0, "", nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
@@ -200,7 +200,7 @@ func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName stri
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
all, _, err := s.tenantService.ListTenants(ctx, 10000, 0, "")
|
||||
all, _, err := s.tenantService.ListTenants(ctx, 10000, 0, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1978,7 +1978,7 @@ func (f *fakeWorksmobileTenantService) GetTenant(ctx context.Context, id string)
|
||||
return &tenant, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
func (f *fakeWorksmobileTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
|
||||
return f.list, int64(len(f.list)), nil
|
||||
}
|
||||
|
||||
@@ -2033,8 +2033,8 @@ func (f *fakeWorksmobileUserRepo) ListByTenant(ctx context.Context, tenantID str
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
func (r *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
|
||||
return nil, 0, "", nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type CommonBadgeVariant,
|
||||
getCommonBadgeClasses,
|
||||
} from "../../../ui/badge";
|
||||
import { getCommonBadgeClasses } from "../../../ui/badge";
|
||||
import type { CommonBadgeVariant } from "../../../ui/badge";
|
||||
import { getCommonButtonClasses } from "../../../ui/button";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
@@ -48,7 +46,20 @@ function cx(...classNames: Array<string | false | null | undefined>) {
|
||||
}
|
||||
|
||||
function statusVariant(status: string): CommonBadgeVariant {
|
||||
return status === "success" || status === "ok" ? "success" : "warning";
|
||||
switch (status.toLowerCase()) {
|
||||
case "success":
|
||||
case "ok":
|
||||
return "success";
|
||||
case "failure":
|
||||
case "error":
|
||||
case "blocked":
|
||||
return "destructive";
|
||||
case "pending":
|
||||
case "warning":
|
||||
return "warning";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
export function AuditLogTable({
|
||||
@@ -73,356 +84,324 @@ export function AuditLogTable({
|
||||
|
||||
return (
|
||||
<div className={cx(commonTableShellClass, className)}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<div className={cx(commonTableViewportClass, "flex-1")}>
|
||||
<div className={commonTableWrapperClass}>
|
||||
<table className={cx(commonTableClass, "table-fixed")}>
|
||||
<thead
|
||||
className={cx(
|
||||
commonTableHeaderClass,
|
||||
commonStickyTableHeaderClass,
|
||||
)}
|
||||
>
|
||||
<tr className={commonTableRowClass}>
|
||||
<th className={cx(commonTableHeadClass, "w-[190px]")}>
|
||||
<Table className={commonTableClass}>
|
||||
<TableHeader className={commonTableHeaderClass}>
|
||||
<TableRow className={cx(commonTableRowClass, commonStickyTableHeaderClass)}>
|
||||
<TableHead className={cx(commonTableHeadClass, "w-[190px]")}>
|
||||
{t("ui.common.audit.table.time", "Time")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
</TableHead>
|
||||
<TableHead className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.user_id", "User ID")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
</TableHead>
|
||||
<TableHead className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.action", "Action")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[260px]")}>
|
||||
</TableHead>
|
||||
<TableHead className={cx(commonTableHeadClass, "w-[260px]")}>
|
||||
{t("ui.common.audit.table.client_id", "Client ID")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[120px]")}>
|
||||
</TableHead>
|
||||
<TableHead className={cx(commonTableHeadClass, "w-[120px]")}>
|
||||
{t("ui.common.audit.table.status", "Status")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[80px]")} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={commonTableBodyClass}>
|
||||
{loading && logs.length === 0 ? (
|
||||
<tr className={commonTableRowClass}>
|
||||
<td
|
||||
colSpan={6}
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"py-8 text-center text-muted-foreground",
|
||||
</TableHead>
|
||||
<TableHead className={cx(commonTableHeadClass, "w-[80px]")} />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className={commonTableBodyClass}>
|
||||
{logs.map((log, index) => {
|
||||
const details = parseAuditDetails(log.details);
|
||||
const actorLabel = resolveAuditActor(log, details);
|
||||
const actionLabel = resolveAuditAction(log, details);
|
||||
const targetLabel = resolveAuditTarget(details);
|
||||
const rowKey = `${log.event_id}-${log.timestamp}-${index}`;
|
||||
const expanded = Boolean(expandedRows[rowKey]);
|
||||
const { date, time } = formatAuditDateParts(log.timestamp);
|
||||
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow className={cx(commonTableRowClass, "bg-card/40")}>
|
||||
<TableCell className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={commonTableCellClass}>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{actorLabel}
|
||||
</code>
|
||||
{actorLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.actor_id",
|
||||
"Copy User ID",
|
||||
)}
|
||||
onClick={() => handleCopy(actorLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
|
||||
<div className="font-semibold text-foreground">
|
||||
{actionLabel}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">{targetLabel}</span>
|
||||
{targetLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.target",
|
||||
"Copy Client ID",
|
||||
)}
|
||||
onClick={() => handleCopy(targetLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={commonTableCellClass}>
|
||||
<span
|
||||
className={getCommonBadgeClasses({
|
||||
variant: statusVariant(log.status),
|
||||
})}
|
||||
>
|
||||
{log.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={cx(commonTableCellClass, "text-right")}>
|
||||
<button
|
||||
type="button"
|
||||
className={getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
})}
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !expanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded && (
|
||||
<TableRow className={cx(commonTableRowClass, "bg-card/20")}>
|
||||
<TableCell colSpan={6} className={cx(commonTableCellClass, "text-xs")}>
|
||||
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.request", "Request")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.request_id",
|
||||
"Request ID · {{value}}",
|
||||
{ value: formatAuditValue(details.request_id) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.event_id",
|
||||
"Event ID · {{value}}",
|
||||
{ value: formatAuditValue(log.event_id) },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.ip", "IP · {{value}}", {
|
||||
value: formatAuditValue(log.ip_address),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.method",
|
||||
"Method · {{value}}",
|
||||
{ value: formatAuditValue(details.method) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.path",
|
||||
"Path · {{value}}",
|
||||
{ value: formatAuditValue(details.path) },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.latency",
|
||||
"Latency · {{value}}",
|
||||
{
|
||||
value:
|
||||
details.latency_ms !== undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.actor", "Actor")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.actor_id",
|
||||
"User ID · {{value}}",
|
||||
{ value: actorLabel },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.tenant",
|
||||
"Tenant · {{value}}",
|
||||
{ value: formatAuditValue(details.tenant_id) },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.device",
|
||||
"Device · {{value}}",
|
||||
{ value: formatAuditValue(log.device_id) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.target",
|
||||
"Client ID · {{value}}",
|
||||
{ value: targetLabel },
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.result", "Result")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.error",
|
||||
"Error · {{value}}",
|
||||
{ value: formatAuditValue(details.error) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.before",
|
||||
"Before · {{value}}",
|
||||
{ value: formatAuditValue(details.before) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.after",
|
||||
"After · {{value}}",
|
||||
{ value: formatAuditValue(details.after) },
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
>
|
||||
{t("msg.common.audit.loading", "Loading audit logs...")}
|
||||
</td>
|
||||
</tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr className={commonTableRowClass}>
|
||||
<td
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{logs.length === 0 && !loading && (
|
||||
<TableRow className={commonTableRowClass}>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"text-center text-muted-foreground",
|
||||
"text-center text-muted-foreground py-8",
|
||||
)}
|
||||
>
|
||||
{t("msg.common.audit.empty", "No audit logs found.")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((row, index) => {
|
||||
const details = parseAuditDetails(row.details);
|
||||
const actorLabel = resolveAuditActor(row, details);
|
||||
const actionLabel = resolveAuditAction(row, details);
|
||||
const targetLabel = resolveAuditTarget(details);
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const expanded = Boolean(expandedRows[rowKey]);
|
||||
const { date, time } = formatAuditDateParts(row.timestamp);
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<tr className={cx(commonTableRowClass, "bg-card/40")}>
|
||||
<td
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={commonTableCellClass}>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{actorLabel}
|
||||
</code>
|
||||
{actorLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.actor_id",
|
||||
"Copy User ID",
|
||||
)}
|
||||
onClick={() => handleCopy(actorLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="font-semibold text-foreground">
|
||||
{actionLabel}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">{targetLabel}</span>
|
||||
{targetLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.target",
|
||||
"Copy Client ID",
|
||||
)}
|
||||
onClick={() => handleCopy(targetLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className={commonTableCellClass}>
|
||||
<span
|
||||
className={getCommonBadgeClasses({
|
||||
variant: statusVariant(row.status),
|
||||
})}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className={cx(commonTableCellClass, "text-right")}>
|
||||
<button
|
||||
type="button"
|
||||
className={getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
})}
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !expanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded ? (
|
||||
<tr className={cx(commonTableRowClass, "bg-card/20")}>
|
||||
<td
|
||||
colSpan={6}
|
||||
className={cx(commonTableCellClass, "text-xs")}
|
||||
>
|
||||
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t(
|
||||
"ui.common.audit.details.request",
|
||||
"Request",
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.request_id",
|
||||
"Request ID · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(
|
||||
details.request_id,
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.event_id",
|
||||
"Event ID · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(row.event_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.ip",
|
||||
"IP · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(row.ip_address),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.method",
|
||||
"Method · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(details.method),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.path",
|
||||
"Path · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(details.path),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.latency",
|
||||
"Latency · {{value}}",
|
||||
{
|
||||
value:
|
||||
details.latency_ms !== undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.actor", "Actor")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.actor_id",
|
||||
"User ID · {{value}}",
|
||||
{ value: actorLabel },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.tenant",
|
||||
"Tenant · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(
|
||||
details.tenant_id,
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.device",
|
||||
"Device · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(row.device_id),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.target",
|
||||
"Client ID · {{value}}",
|
||||
{ value: targetLabel },
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t(
|
||||
"ui.common.audit.details.result",
|
||||
"Result",
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.error",
|
||||
"Error · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(details.error),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.before",
|
||||
"Before · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(details.before),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.after",
|
||||
"After · {{value}}",
|
||||
{
|
||||
value: formatAuditValue(details.after),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-6 text-center flex-shrink-0">
|
||||
<div className="p-4 border-t text-center flex-shrink-0 bg-background/50 backdrop-blur-sm z-10">
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
className={getCommonButtonClasses({ variant: "outline" })}
|
||||
onClick={onLoadMore}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.common.audit.load_more", "Load more")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{isFetchingNextPage && (
|
||||
<span className="text-xs text-muted-foreground animate-pulse">
|
||||
{t("msg.common.loading", "Loading more...")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={getCommonButtonClasses({
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
})}
|
||||
onClick={onLoadMore}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.common.audit.load_more", "Load more")}
|
||||
</button>
|
||||
</div>
|
||||
) : logs.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("msg.common.audit.end", "End of audit feed")}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Internal table components for cleaner implementation
|
||||
function Table({ className, children, style }: { className?: string, children: React.ReactNode, style?: React.CSSProperties }) {
|
||||
return <table className={className} style={style}>{children}</table>;
|
||||
}
|
||||
|
||||
function TableHeader({ className, children }: { className?: string, children: React.ReactNode }) {
|
||||
return <thead className={className}>{children}</thead>;
|
||||
}
|
||||
|
||||
function TableBody({ className, children }: { className?: string, children: React.ReactNode }) {
|
||||
return <tbody className={className}>{children}</tbody>;
|
||||
}
|
||||
|
||||
function TableRow({ className, children }: { className?: string, children: React.ReactNode }) {
|
||||
return <tr className={className}>{children}</tr>;
|
||||
}
|
||||
|
||||
function TableHead({ className, children }: { className?: string, children?: React.ReactNode }) {
|
||||
return <th className={className}>{children}</th>;
|
||||
}
|
||||
|
||||
function TableCell({ className, children, colSpan }: { className?: string, children: React.ReactNode, colSpan?: number }) {
|
||||
return <td className={className} colSpan={colSpan}>{children}</td>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[msg.common]
|
||||
loading_more = "Loading more logs..."
|
||||
copied = "Copied."
|
||||
error = "Error"
|
||||
forbidden = "Access denied."
|
||||
@@ -141,6 +142,7 @@ target = "Client ID · {{value}}"
|
||||
title = "Audit registry"
|
||||
|
||||
[ui.common.audit.table]
|
||||
no_logs = "No logs to display."
|
||||
action = "Action"
|
||||
actor = "User ID"
|
||||
client_id = "Client ID"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[msg.common]
|
||||
loading_more = "추가 로그를 불러오는 중..."
|
||||
copied = "복사되었습니다."
|
||||
error = "오류가 발생했습니다."
|
||||
forbidden = "접근 권한이 없습니다."
|
||||
@@ -141,7 +142,8 @@ target = "클라이언트 ID · {{value}}"
|
||||
title = "감사 로그 레지스트리"
|
||||
|
||||
[ui.common.audit.table]
|
||||
action = "액션"
|
||||
no_logs = "표시할 로그가 없습니다."
|
||||
action = "작업"
|
||||
actor = "사용자 ID"
|
||||
client_id = "클라이언트 ID"
|
||||
user_id = "사용자 ID"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[msg.common]
|
||||
loading_more = ""
|
||||
copied = ""
|
||||
error = ""
|
||||
forbidden = ""
|
||||
@@ -141,6 +142,7 @@ target = ""
|
||||
title = ""
|
||||
|
||||
[ui.common.audit.table]
|
||||
no_logs = ""
|
||||
action = ""
|
||||
actor = ""
|
||||
client_id = ""
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
packages:
|
||||
- "../adminfront"
|
||||
- "../devfront"
|
||||
- "../orgfront"
|
||||
allowBuilds:
|
||||
'@biomejs/biome': false
|
||||
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "baron-sso-root",
|
||||
"private": true,
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/node": "24.12.4",
|
||||
"undici": "7.26.0",
|
||||
"electron-to-chromium": "1.5.360",
|
||||
"@csstools/css-syntax-patches-for-csstree": "1.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
4239
pnpm-lock.yaml
generated
Normal file
4239
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
packages:
|
||||
- "adminfront"
|
||||
- "devfront"
|
||||
- "orgfront"
|
||||
- "common"
|
||||
allowBuilds:
|
||||
'@biomejs/biome': false
|
||||
189
tenants_2605.csv
189
tenants_2605.csv
@@ -1,189 +0,0 @@
|
||||
tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type
|
||||
cd1ebc22-4b5e-4242-bb87-eb88db32286c,업무,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,operations,,,public,팀
|
||||
0e13d342-d3cf-46b5-8096-4e7883b79b01,서산시자원회수시설,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-seosan-recovery,,,public,
|
||||
41118f16-7f5c-4209-bd83-183822bc00ed,안성제4차산업단지폐수처리,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-anseong-wwtp,,,public,
|
||||
ad6f20e9-7928-4322-932c-7c3cb2a313cb,온산바이오,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-onsan-bio,,,public,
|
||||
03d8cf87-4b40-4784-a6cf-fcc11371f40f,울산민자소각,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-ulsan-incineration,,,public,
|
||||
d7379c32-0b79-482e-9c4d-d83ad425c3fc,운영사업소,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-operation-sites,,,public,
|
||||
551991d8-1f74-4ad0-a0c5-bc5a11968398,부산항 신항,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-busan-new-port,,,public,
|
||||
e77b4bf1-a126-4b4e-a18a-8d905e958873,수도권광역급행철도B 제4공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gtx-b-4,,,public,
|
||||
3b5151f6-1a01-484a-bfb7-2e60d2aa0b49,경산시 국도대체,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gyeongsan-road,,,public,
|
||||
44c6e400-daf0-42a2-90df-945921788f99,인덕원 동탄 복선전철 제7공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-indeokwon-dongtan-7,,,public,
|
||||
2bc22118-9a70-4d5b-8a3f-cf65432a8bbb,인덕원 동탄 복선전철 제3공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-indeokwon-dongtan-3,,,public,
|
||||
1134fa6a-9b0b-4702-a1e7-39948c8c451a,제주공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-jeju-sewage,,,public,
|
||||
36aec47e-90fc-42cb-8229-3e20423d0424,성남시생활폐기물처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-seongnam-waste,,,public,
|
||||
5b0b806c-f189-46ea-8771-ebdafcf45afa,광탄공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gwangtan-sewage,,,public,
|
||||
d2323d9a-c959-48c0-831b-4bb71e48b2e5,인천국제공항 화물,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-incheon-air-cargo,,,public,
|
||||
32a83ce1-03f1-4daa-b60f-8e64fad83ac6,수도권매립지 제2매립장,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-sudokwon-landfill-2,,,public,
|
||||
e39ba0af-c3e9-429b-91ec-0a3453a5692e,온산하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-onsan-sewage,,,public,
|
||||
25f51047-3108-4ff3-98f4-b7f5bce334c5,신천공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-sincheon-sewage,,,public,
|
||||
f0ae9e81-65a5-4bab-a98d-79349bbaa501,장량공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-jangnyang-sewage,,,public,
|
||||
b662cfdb-aae3-48b7-b1d3-2ef050dce027,아포공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-apo-sewage,,,public,
|
||||
76808046-cd35-4813-b240-c323291fa2d8,광주공공폐수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gwangju-wastewater,,,public,
|
||||
bda54a49-8282-4f91-9773-645e6a1f2a3b,도척 실촌간 도로,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-docheok-silchon-road,,,public,
|
||||
02a9e89b-a0a0-4202-adde-870194c35351,여주부평천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-yeoju-bupyeongcheon,,,public,
|
||||
9b1fb915-f50b-49b9-a9f9-a9089a825b1f,옥정 공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-okjeong-sewage,,,public,
|
||||
29d9fa54-6d8d-49f5-98ca-2c720aced55e,부천시 굴포천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-bucheon-gulpocheon,,,public,
|
||||
99199302-f04f-47ad-9f9f-2afe2db9826a,시공현장,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-construction-sites,,,public,
|
||||
2dbbdbf8-26b2-461e-bbd7-1fe09b4e36ee,안전관리팀,ORGANIZATION,4b81d408-d81c-43c7-9f87-b6c806db4d7b,halla-safety-hq,halla-safety-team,,,public,
|
||||
4b81d408-d81c-43c7-9f87-b6c806db4d7b,안전관리본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-safety-hq,,,public,
|
||||
69aa9667-4997-41f8-9898-e470cfc778e5,기술영업팀,ORGANIZATION,1512e429-fb95-4c0d-9409-f0a3286061f2,halla-tech-sales-hq,halla-tech-sales-team,,,public,
|
||||
1512e429-fb95-4c0d-9409-f0a3286061f2,기술영업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-tech-sales-hq,,,public,
|
||||
6f9e45f7-63fb-464e-b47c-915fa25f782f,설계팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,halla-env-plant-hq,halla-env-plant-design,,,public,
|
||||
69d6d246-b281-4da7-be83-fede8e3dc5bd,사업관리팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,halla-env-plant-hq,halla-env-project-mgmt,,,public,
|
||||
2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,환경플랜트사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-env-plant-hq,,,public,
|
||||
45519f6d-ba67-42e2-9b54-f80f1a950a8c,사업관리팀,ORGANIZATION,1d5da961-7f32-4032-a86c-26e8edbcb8ee,halla-infra-business-hq,halla-infra-project-mgmt,,,public,
|
||||
1d5da961-7f32-4032-a86c-26e8edbcb8ee,기반사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-infra-business-hq,,,public,
|
||||
03af0690-af91-468a-9892-0152c7309a4b,운영사업실,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-operations-office,,,public,
|
||||
6e9b627f-5304-4e7d-99fc-77fc2328d004,경영지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-mgmt-support,,,public,
|
||||
43c0fb29-84dd-49d2-a2b8-33b6659f4607,사업지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-business-support,,,public,
|
||||
d656c134-a50b-43b9-8c2d-fb3738dd0f9f,경영지원본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-mgmt-support-hq,,,public,
|
||||
940cc09c-32f5-4a02-8213-fb02521189d0,영업총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-general-sales,,,public,
|
||||
57496cae-a081-4836-a20e-75c78b62257f,업무총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-general-business,,,public,
|
||||
81e94e6c-e27a-4e36-b0f9-bf8823c96493,임원실,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-executive,,,public,
|
||||
786dd00c-b0c1-4db9-b25b-1afecd6a7a41,안전관리,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-safety-management,,,public,
|
||||
5fbf6f2c-6b12-4124-a457-d1064dbb8677,현장,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-site,,,public,
|
||||
dd82bb7b-43d8-4744-ab65-9b47ea492ac4,공무,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-construction-admin,,,public,
|
||||
94c23f79-a213-4a9e-9c5d-7751777f2fe8,건설본부,ORGANIZATION,b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,jangheon-sanup,js-construction-hq,,,public,
|
||||
4738ed53-51cc-4dcf-9885-44c9feb26760,견적,ORGANIZATION,c6ff3238-f001-4cdd-b00c-0d137a3baff8,js-tech-sales-hq,js-estimation,,,public,
|
||||
063a7d31-4aa4-4904-9f32-9b092116145e,기술지원,ORGANIZATION,c6ff3238-f001-4cdd-b00c-0d137a3baff8,js-tech-sales-hq,js-tech-support,,,public,
|
||||
91063309-efb3-48b7-b55b-a6e79c9eb202,영업,ORGANIZATION,c6ff3238-f001-4cdd-b00c-0d137a3baff8,js-tech-sales-hq,js-sales,,,public,
|
||||
c6ff3238-f001-4cdd-b00c-0d137a3baff8,기술영업본부,ORGANIZATION,b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,jangheon-sanup,js-tech-sales-hq,,,public,
|
||||
64973a3e-102e-4efd-8147-5be720b89c36,임원실,ORGANIZATION,b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,jangheon-sanup,jangheon-sanup-executive,,,public,
|
||||
6f6f64d3-a555-4680-9b37-a276688b2dfa,설계팀,ORGANIZATION,e57cb22c-383e-4489-8c2f-0c5431917e86,ptc,ptc-design,,,public,
|
||||
f617365d-2d1a-4d1a-a743-82b3599c8946,시공팀,ORGANIZATION,e57cb22c-383e-4489-8c2f-0c5431917e86,ptc,ptc-construction,,,public,
|
||||
e629fa7d-c945-4952-b79f-1e23ecf9e7cb,사업관리팀,ORGANIZATION,e57cb22c-383e-4489-8c2f-0c5431917e86,ptc,ptc-project-management,,,public,
|
||||
f19677d0-0e91-4da7-a4a4-57f3b2815154,영업팀,ORGANIZATION,e57cb22c-383e-4489-8c2f-0c5431917e86,ptc,ptc-sales,,,public,
|
||||
94be067d-f369-4f6b-a6ec-2d063694c929,임원실,ORGANIZATION,e57cb22c-383e-4489-8c2f-0c5431917e86,ptc,ptc-executive,,,public,
|
||||
07ef09e2-530b-498b-9f25-610500d4acb3,업무지원팀,ORGANIZATION,c18a8284-0008-48aa-9cdf-9f47ab79a2a9,jangheon,jangheon-business-support,,,public,
|
||||
50b42506-f10c-4cb8-af7b-5b7c6aa63276,품질팀,ORGANIZATION,e83f9477-5a8d-4168-ab3b-93508ef9cbb3,jangheon-production,jangheon-quality,,,public,
|
||||
f01cc7b9-aaa0-40c1-9124-9e81c28a6e0d,제작2팀,ORGANIZATION,e83f9477-5a8d-4168-ab3b-93508ef9cbb3,jangheon-production,jangheon-fab-2,,,public,
|
||||
b6cf39a4-6d2d-4de8-9bad-7ecbb54f477e,제작1팀,ORGANIZATION,e83f9477-5a8d-4168-ab3b-93508ef9cbb3,jangheon-production,jangheon-fab-1,,,public,
|
||||
3dbd0b03-51f5-4d1e-8271-14ff09258dad,철근팀,ORGANIZATION,e83f9477-5a8d-4168-ab3b-93508ef9cbb3,jangheon-production,jangheon-rebar,,,public,
|
||||
8c74588f-d755-4fd7-b923-26bad7ff0d14,공무팀,ORGANIZATION,e83f9477-5a8d-4168-ab3b-93508ef9cbb3,jangheon-production,jangheon-production-admin,,,public,
|
||||
e83f9477-5a8d-4168-ab3b-93508ef9cbb3,생산부,ORGANIZATION,c18a8284-0008-48aa-9cdf-9f47ab79a2a9,jangheon,jangheon-production,,,public,
|
||||
a5b70b22-a7fc-4d01-a7e2-2cc022e808ee,바론컨설턴트,COMPANY,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,baroncs,,,public,
|
||||
4fda8bb7-d6c4-44b7-8da9-e36f2b487732,경영지원부,ORGANIZATION,b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,jangheon-sanup,js-mgmt-support,,,public,
|
||||
6b687038-180a-4141-b8d2-62647068a8ad,안전진단부,ORGANIZATION,369c1843-56af-4344-9c21-0e01197ab861,hanmac,safety-diagnosis,,,public,
|
||||
36ed0b11-6b5f-4ecd-b5b6-d009d1a8a5f9,건설사업부,ORGANIZATION,d255e6da-4298-4e67-a7cc-45c53d7cdb61,construction-management-h,construction-business,,,public,
|
||||
d255e6da-4298-4e67-a7cc-45c53d7cdb61,건설사업관리본부,ORGANIZATION,369c1843-56af-4344-9c21-0e01197ab861,hanmac,construction-management-h,,,public,
|
||||
e5c94be2-a3ce-4044-8b6c-623a45abb428,상하수도부,ORGANIZATION,e76d0596-c4cc-4a1d-b596-f824823a17b4,land-environment-hq,water-sewerage,,,public,
|
||||
d149e48b-cdd8-4ebc-9880-7108a599b938,수자원부,ORGANIZATION,e76d0596-c4cc-4a1d-b596-f824823a17b4,land-environment-hq,land-env-water-resources,,,public,
|
||||
40be0d19-b61a-424a-97d6-a4fa14e342f1,도시계획부,ORGANIZATION,e76d0596-c4cc-4a1d-b596-f824823a17b4,land-environment-hq,land-env-urban-planning,,,public,
|
||||
1c89dcbc-ebe5-41c8-b1a6-71b1dda7db19,환경평가부,ORGANIZATION,e76d0596-c4cc-4a1d-b596-f824823a17b4,land-environment-hq,land-env-assessment,,,public,
|
||||
e76d0596-c4cc-4a1d-b596-f824823a17b4,국토환경사업본부,ORGANIZATION,369c1843-56af-4344-9c21-0e01197ab861,hanmac,land-environment-hq,,,public,
|
||||
32f33163-e613-4f21-b285-449afb31346e,지반터널부,ORGANIZATION,c31bcf86-26da-4cd7-8ef3-371f606cbb72,infrastructure-hq,infra-geotech-tunnel,,,public,
|
||||
1ccb0859-7eda-4f54-8217-3f781ed036ef,구조부,ORGANIZATION,c31bcf86-26da-4cd7-8ef3-371f606cbb72,infrastructure-hq,infra-structures,,,public,
|
||||
f8ffcb83-b709-48fc-a993-c1caefc1a648,교통부,ORGANIZATION,c31bcf86-26da-4cd7-8ef3-371f606cbb72,infrastructure-hq,traffic,,,public,
|
||||
cf1818e2-689f-449b-8c99-0ae25953e576,도로부,ORGANIZATION,c31bcf86-26da-4cd7-8ef3-371f606cbb72,infrastructure-hq,infra-road,,,public,
|
||||
c31bcf86-26da-4cd7-8ef3-371f606cbb72,인프라사업본부,ORGANIZATION,369c1843-56af-4344-9c21-0e01197ab861,hanmac,infrastructure-hq,,,public,
|
||||
1d784637-6926-4671-975b-3e8e455939a2,안전관리부,ORGANIZATION,369c1843-56af-4344-9c21-0e01197ab861,hanmac,safety-management,,,public,
|
||||
a2252cac-b321-493a-b177-2e702da0d77d,영업지원,ORGANIZATION,369c1843-56af-4344-9c21-0e01197ab861,hanmac,sales-support,,,public,
|
||||
445ee8ad-41e3-42fe-902f-e27d937f09c8,인프라 BIM3,ORGANIZATION,d8b41c30-fe0c-4318-9ae3-cae7dcf6c891,infra-solution,infra-bim3,,,public,팀
|
||||
52f06c97-9d6f-4819-971b-43303062e193,인프라 BIM2,ORGANIZATION,d8b41c30-fe0c-4318-9ae3-cae7dcf6c891,infra-solution,infra-bim2,,,public,팀
|
||||
432b5261-421b-4e5f-914f-32d7d22fd01f,인프라 BIM1,ORGANIZATION,d8b41c30-fe0c-4318-9ae3-cae7dcf6c891,infra-solution,infra-bim1,,,public,팀
|
||||
d8b41c30-fe0c-4318-9ae3-cae7dcf6c891,인프라솔루션,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,infra-solution,,,public,디비전
|
||||
96f9d9de-e187-4a27-941e-0f57f3b1851a,네이버웍스관리용(바론그룹),ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,su4,,,private,
|
||||
d7205737-e7bd-4926-9b76-39f447ff809e,네이버웍스관리용(한맥),ORGANIZATION,369c1843-56af-4344-9c21-0e01197ab861,hanmac,su2,,,private,
|
||||
a6343ba4-6062-4150-87b7-a9bec4ca34a0,네이버웍스관리용(삼안),ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,su1,,,private,
|
||||
818c856b-9545-442f-b827-d1c569f200b0,기술개발센터(조직도용),ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,rnd-center,,,public,
|
||||
93e1f3dc-503f-40f7-8342-31974b5dd33c,해외사업부,ORGANIZATION,ec325e6f-4333-43ae-a52a-77b742eef7ed,overseas-headquarters,overseas-business,,,public,
|
||||
ec325e6f-4333-43ae-a52a-77b742eef7ed,해외사업본부,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,overseas-headquarters,,,public,
|
||||
5b531a28-a222-44de-87f4-8a0560685a25,수력부,ORGANIZATION,f60d0b66-e0c8-46be-88dc-e8bc93b9e149,water-resources-hq,hydropower,,,public,
|
||||
6c1ed0b6-8141-4ff8-96fb-a84efbe3a2ce,수자원2부,ORGANIZATION,f60d0b66-e0c8-46be-88dc-e8bc93b9e149,water-resources-hq,water-resources-2,,,public,
|
||||
d59651ea-41d3-45ee-b519-537fc6a97e65,수자원1부,ORGANIZATION,f60d0b66-e0c8-46be-88dc-e8bc93b9e149,water-resources-hq,water-resources-1,,,public,
|
||||
f60d0b66-e0c8-46be-88dc-e8bc93b9e149,수자원본부,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,water-resources-hq,,,public,
|
||||
1596d8d3-e92d-4c95-8e3e-56c824991ee9,물환경3부,ORGANIZATION,9b9b8b70-b058-4f22-a0c3-e63bc0c7e1b8,water-environment-hq,water-environment-3,,,public,
|
||||
9ff11813-07ae-41d2-b2af-ac4038dafde2,물환경2부,ORGANIZATION,9b9b8b70-b058-4f22-a0c3-e63bc0c7e1b8,water-environment-hq,water-environment-2,,,public,
|
||||
2e4d1c4f-f859-4bdc-8531-8c2f29d74cc0,물환경1부,ORGANIZATION,9b9b8b70-b058-4f22-a0c3-e63bc0c7e1b8,water-environment-hq,water-environment-1,,,public,
|
||||
9b9b8b70-b058-4f22-a0c3-e63bc0c7e1b8,물환경본부,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,water-environment-hq,,,public,
|
||||
25e9f426-8012-43bd-8d2f-35ad76f0236a,환경평가부,ORGANIZATION,7ee20c20-01a7-4e39-9a58-a2ffdad153dd,railway-headquarters,environment-assessment,,,public,
|
||||
a4722e17-c3f9-49d9-8535-4c92d03ccaae,철도2부,ORGANIZATION,7ee20c20-01a7-4e39-9a58-a2ffdad153dd,railway-headquarters,railway-2,,,public,
|
||||
c826ecf7-c2f9-49a1-ab72-0413216999af,철도1부,ORGANIZATION,7ee20c20-01a7-4e39-9a58-a2ffdad153dd,railway-headquarters,railway-1,,,public,
|
||||
7ee20c20-01a7-4e39-9a58-a2ffdad153dd,철도본부,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,railway-headquarters,,,public,
|
||||
a1bca214-1f8a-403d-8f0b-c6c3aa71bb5a,안전진단팀,ORGANIZATION,967f657c-04e2-49c1-9cb9-f6afba27c0fa,structures,safety-inspection,,,public,
|
||||
967f657c-04e2-49c1-9cb9-f6afba27c0fa,구조부,ORGANIZATION,dfdc84b7-87a1-4383-8df7-5e656193e79c,road-headquarters,structures,,,public,
|
||||
c172de4d-1e61-47d5-9446-7bb36ea50063,교통계획부,ORGANIZATION,dfdc84b7-87a1-4383-8df7-5e656193e79c,road-headquarters,transport-planning,,,public,
|
||||
6760a731-d172-4bc4-8ed4-1b625752c4a2,지반터널부,ORGANIZATION,dfdc84b7-87a1-4383-8df7-5e656193e79c,road-headquarters,geotech-tunnel,,,public,
|
||||
0b07f990-3ddb-45fa-b3d1-6233e3580f71,도로부,ORGANIZATION,dfdc84b7-87a1-4383-8df7-5e656193e79c,road-headquarters,road,,,public,
|
||||
dfdc84b7-87a1-4383-8df7-5e656193e79c,도로본부,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,road-headquarters,,,public,
|
||||
953a07f6-5ff9-46ed-9a16-bd3066ccec12,조경레저부,ORGANIZATION,95d10fc3-b5d1-4613-9b0a-f9e6cae90d83,land-development,landscape-leisure,,,public,
|
||||
ed9290e8-7296-47fc-b72f-6ccc45765d4e,도시개발부,ORGANIZATION,95d10fc3-b5d1-4613-9b0a-f9e6cae90d83,land-development,urban-development,,,public,
|
||||
02ab05a1-caa8-41c9-8c9a-91de614724fd,도시계획부,ORGANIZATION,95d10fc3-b5d1-4613-9b0a-f9e6cae90d83,land-development,urban-planning,,,public,
|
||||
95d10fc3-b5d1-4613-9b0a-f9e6cae90d83,국토개발본부,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,land-development,,,public,
|
||||
f36e2211-8cfd-4813-8618-34e606fe73ac,항만부,ORGANIZATION,cd10e627-808b-4c98-8fdd-d47c84ce57d6,plant-headquarters,harbor,,,public,
|
||||
fec713bf-ac20-4480-bd74-686a2e6d92b3,플랜트2부,ORGANIZATION,cd10e627-808b-4c98-8fdd-d47c84ce57d6,plant-headquarters,plant-2,,,public,
|
||||
ef3adb8e-3405-4027-a0f8-5d3d5bf11e84,플랜트1부,ORGANIZATION,cd10e627-808b-4c98-8fdd-d47c84ce57d6,plant-headquarters,plant-1,,,public,
|
||||
cd10e627-808b-4c98-8fdd-d47c84ce57d6,플랜트본부,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,plant-headquarters,,,public,
|
||||
99735f38-47d6-490b-ad54-8090b3bef91f,호남지역총괄본부,ORGANIZATION,8bbd9aad-1b53-4504-befb-e8ae8792340f,cm-division,honam-headquarters,,,public,
|
||||
8bbd9aad-1b53-4504-befb-e8ae8792340f,CM사업부,ORGANIZATION,92b4fd3c-91ac-41d2-8757-9344861b97aa,cm-headquarters,cm-division,,,public,
|
||||
92b4fd3c-91ac-41d2-8757-9344861b97aa,CM본부,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,cm-headquarters,,,public,
|
||||
fe58cad4-1fa6-4b87-a2eb-51b9ac41320e,사업개발실,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,business-development,,,public,
|
||||
338fb8af-b594-4c41-9984-54ea4b37637b,안전품질관리실,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,safety-quality,,,public,
|
||||
1edc196d-020c-4519-9ec4-3d23b99076e6,자산경영실,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,asset-management,,,public,
|
||||
7adb550b-1756-49f6-b6cc-55b7b426ed52,인사총무부,ORGANIZATION,9bf67270-e15e-4278-b407-02dec5672876,business-strategy,hr-admin,,,public,
|
||||
01fcbee1-df33-4ee9-bf2b-6d9eb81917d9,대외협력팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,external-relations,,,public,
|
||||
cdc40c0b-f985-461a-be18-f8c8e82f31e8,재무회계팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,finance,,,public,
|
||||
c6aa2133-ded0-451c-b51b-27faa8b56507,PQ팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,pq-team,,,public,
|
||||
ca54cffe-ad30-4f9e-983a-88a85c70404d,업무팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-operations,,,public,
|
||||
a16f49c4-6828-4fde-a164-43099c4560c4,기획부,ORGANIZATION,9bf67270-e15e-4278-b407-02dec5672876,business-strategy,planning,,,public,
|
||||
9bf67270-e15e-4278-b407-02dec5672876,경영전략본부,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,business-strategy,,,public,
|
||||
896da8ab-50b7-4a63-abbc-c85037b63acc,시공BIM,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,construction-bim,,,public,
|
||||
410a25b1-cd84-46d4-b4d6-8627b397ca42,스마트건설,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,smart-construction,,,public,
|
||||
68c85ffe-5942-42e7-9785-b2c29b18ecb9,수자원,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,water-resources,,,public,
|
||||
27683cb5-98ac-49cf-ac57-8157d7d2a663,PM,ORGANIZATION,9e5919f5-0839-4d98-b2e8-dee2ef518b1d,gsim-dev,project-management,,,public,
|
||||
39ad464a-8468-4acb-8f05-29bc828a1576,GSIM,ORGANIZATION,9e5919f5-0839-4d98-b2e8-dee2ef518b1d,gsim-dev,gsim,,,public,
|
||||
2501e4a7-bf41-48df-afae-76676e0169f1,bCMf,ORGANIZATION,9e5919f5-0839-4d98-b2e8-dee2ef518b1d,gsim-dev,bcmf,,,public,
|
||||
9e5919f5-0839-4d98-b2e8-dee2ef518b1d,GSIM개발,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,gsim-dev,,,public,
|
||||
b83c124a-8d5b-4b14-9347-9d1dca2b42fa,웹디자인,ORGANIZATION,f8366e19-d767-46db-a789-49e36d88a8fc,web-solutions,web-design,,,public,
|
||||
2403109e-1e43-4390-8d2a-6f0566be028e,ERP,ORGANIZATION,f8366e19-d767-46db-a789-49e36d88a8fc,web-solutions,erp,,,public,
|
||||
54986461-8cf1-4791-b130-8fecbff5f5a2,솔루션개발,ORGANIZATION,f8366e19-d767-46db-a789-49e36d88a8fc,web-solutions,solution-dev,,,public,
|
||||
f8366e19-d767-46db-a789-49e36d88a8fc,웹솔루션,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,web-solutions,,,public,
|
||||
ec003372-962a-4d7b-b90a-edf9dfbb6eea,Abut&시공통합관제,ORGANIZATION,d394dcf2-d474-4059-9cbb-0fca343ec38c,graphics,abut-control,,,public,
|
||||
52266543-a90b-4441-99c6-51f454b6059a,EG-BIM Draw,ORGANIZATION,d394dcf2-d474-4059-9cbb-0fca343ec38c,graphics,eg-bim-draw,,,public,
|
||||
1d74bebb-c5a1-49d4-bec4-90f0c89ad21f,HmEG,ORGANIZATION,d394dcf2-d474-4059-9cbb-0fca343ec38c,graphics,hmeg,,,public,
|
||||
87831866-07de-4e3c-b158-28188a3c1edb,Modeler,ORGANIZATION,d394dcf2-d474-4059-9cbb-0fca343ec38c,graphics,modeler,,,public,
|
||||
d394dcf2-d474-4059-9cbb-0fca343ec38c,그래픽스,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,graphics,,,public,
|
||||
d43eed1b-3a36-4624-a040-7f2827016df6,Strana,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,strana,,,public,
|
||||
78f251f6-d35b-422d-92ab-7fabd80bef85,구조물S/W,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,structural-software,,,public,
|
||||
b06b8821-b74c-44d5-89c4-3a94e9979161,Watch BIM,ORGANIZATION,9b211775-e6b6-4caf-8c98-91249186e3d9,infra-solution-dev,watch-bim,,,public,
|
||||
79923c99-1aab-4950-b69e-6312636f544f,Primal 평면,ORGANIZATION,9b211775-e6b6-4caf-8c98-91249186e3d9,infra-solution-dev,primal-plan,,,public,
|
||||
28fba7fd-6af5-43c1-807b-fb97d6eafada,Way Draw,ORGANIZATION,9b211775-e6b6-4caf-8c98-91249186e3d9,infra-solution-dev,way-draw,,,public,
|
||||
0cec58e8-efbe-4f93-a734-d1b9cc7747b7,비탈면/구조물,ORGANIZATION,9b211775-e6b6-4caf-8c98-91249186e3d9,infra-solution-dev,slope-structures,,,public,
|
||||
9b211775-e6b6-4caf-8c98-91249186e3d9,인프라솔루션 개발,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,infra-solution-dev,,,public,
|
||||
f39ef0c8-0ad0-49cd-97ff-b0672591cfe3,단지설계 개발,ORGANIZATION,d015f4fc-6ce1-4258-be37-6c531cf75c6c,cheonjijin,site-design-dev,,,public,
|
||||
35cc1fdf-6c0e-4b0e-8ce8-1adc918b8cbf,용지도셀,ORGANIZATION,d015f4fc-6ce1-4258-be37-6c531cf75c6c,cheonjijin,land-map-cell,,,public,
|
||||
c3e59dbc-a315-47f9-8787-2a792a311e32,천지인셀,ORGANIZATION,d015f4fc-6ce1-4258-be37-6c531cf75c6c,cheonjijin,cheonjijin-cell,,,public,
|
||||
d015f4fc-6ce1-4258-be37-6c531cf75c6c,천지인,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,cheonjijin,,,public,
|
||||
61e9ed21-e100-475a-a1e6-bdb2a302db95,상하수도,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,water-sewer,,,public,
|
||||
0e206c1f-8e9b-43c2-91ce-a17bf62854c8,단가산출,ORGANIZATION,0471c240-7080-4648-86a6-5fdecff9e148,cost-control,cost-estimate,,,public,
|
||||
bc903928-61cb-45a8-9b4a-794f62a9f8a6,공정관리,ORGANIZATION,0471c240-7080-4648-86a6-5fdecff9e148,cost-control,schedule-control,,,public,
|
||||
0471c240-7080-4648-86a6-5fdecff9e148,CC,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,cost-control,,,public,
|
||||
fcb4c9d3-ea2c-48c6-92cc-63706f53fa21,터널,ORGANIZATION,582972ce-6b06-48c8-aa30-31a5285e160e,structural-division,tunnel,,,public,
|
||||
fab2c4ca-4081-4f64-924d-f32bdf2e61b4,CM기획,ORGANIZATION,582972ce-6b06-48c8-aa30-31a5285e160e,structural-division,cm-planning,,,public,
|
||||
1e1a999a-38f0-40f1-ae89-62e6031a29e0,하부구조,ORGANIZATION,582972ce-6b06-48c8-aa30-31a5285e160e,structural-division,substructure,,,public,
|
||||
9eb73493-8aa1-43d4-99a8-424b1a7d60be,구조물계획,ORGANIZATION,582972ce-6b06-48c8-aa30-31a5285e160e,structural-division,structure-planning,,,public,
|
||||
3d147a08-00b9-47c7-940a-d75c36a6ce81,일반구조물,ORGANIZATION,582972ce-6b06-48c8-aa30-31a5285e160e,structural-division,structural-design,,,public,
|
||||
1d39617e-8e50-4081-bcd5-551e7842f7b3,DfMA,ORGANIZATION,582972ce-6b06-48c8-aa30-31a5285e160e,structural-division,dfma,,,public,
|
||||
582972ce-6b06-48c8-aa30-31a5285e160e,일반구조물 div,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,structural-division,,,public,
|
||||
56cd0fd7-b62a-43c0-8db9-74a30468d7cb,기술개발센터,ORGANIZATION,5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,gpdtdc,tdc,,,internal,
|
||||
59b83b98-b604-4621-8527-b872be47accc,네이버웍스관리용(총센),ORGANIZATION,5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,gpdtdc,su3,,,private,
|
||||
586434c5-3460-458b-b2e5-488b0e77fa21,솔루션통합,ORGANIZATION,761a8725-9c19-442c-986c-0319e33a5b1e,gpd,solution-integration,,,public,
|
||||
df6a504d-75b9-4a21-a67e-1522cc18111c,협업증진,ORGANIZATION,761a8725-9c19-442c-986c-0319e33a5b1e,gpd,collaboration,,,public,
|
||||
6f420ad4-2345-43b5-9c2d-7ba0dae8cae4,디자인기획,ORGANIZATION,761a8725-9c19-442c-986c-0319e33a5b1e,gpd,design-planning,,,public,
|
||||
88d1d1ee-795a-4b67-a8e7-bb0d4d7ac49f,ERP기획,ORGANIZATION,761a8725-9c19-442c-986c-0319e33a5b1e,gpd,erp-planning,,,public,
|
||||
c6b1266c-564b-4543-baba-d78807a3d1b4,경영기획,ORGANIZATION,761a8725-9c19-442c-986c-0319e33a5b1e,gpd,management-planning,,,public,
|
||||
3a660456-eceb-472b-a9a9-f2a5b0ce972b,기술기획,ORGANIZATION,761a8725-9c19-442c-986c-0319e33a5b1e,gpd,tech-planning,,,public,
|
||||
556798a6-b45e-4822-b6f1-42046b6d0001,전산관리TF,ORGANIZATION,761a8725-9c19-442c-986c-0319e33a5b1e,gpd,it-admin-tf,,,internal,
|
||||
539f598d-e6f1-4fe2-be48-466448d8d803,인재성장,ORGANIZATION,761a8725-9c19-442c-986c-0319e33a5b1e,gpd,talent-growth,,,public,
|
||||
761a8725-9c19-442c-986c-0319e33a5b1e,총괄기획실,ORGANIZATION,5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,gpdtdc,gpd,,,public,
|
||||
e57cb22c-383e-4489-8c2f-0c5431917e86,PTC,COMPANY,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,ptc,,,public,
|
||||
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,,personal,개인 사용자 기본 루트 테넌트,,public,
|
||||
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,halla,,,public,
|
||||
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,jangheon-sanup,,,public,
|
||||
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,jangheon,,,public,
|
||||
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,038326b6-954a-48a7-a85f-efd83f62b82a,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,,public,
|
||||
5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,038326b6-954a-48a7-a85f-efd83f62b82a,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID,baroncs.co.kr,public,
|
||||
369c1843-56af-4344-9c21-0e01197ab861,한맥기술,COMPANY,038326b6-954a-48a7-a85f-efd83f62b82a,hanmac-family,hanmac,네이버웍스 한맥 HANMAC_DOMAIN_ID,hanmaceng.co.kr,public,
|
||||
9caf62e1-297d-4e8f-870b-61780998bbeb,삼안,COMPANY,038326b6-954a-48a7-a85f-efd83f62b82a,hanmac-family,saman,네이버웍스 삼안 SAMAN_DOMAIN_ID,samaneng.com,public,
|
||||
038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,,hanmac-family,한맥가족 기본 루트 테넌트,,public,
|
||||
|
11
test.sh
11
test.sh
@@ -1,11 +0,0 @@
|
||||
#!/bin/sh
|
||||
export USERFRONT_FLUTTER_RUN_FLAGS=""
|
||||
set -- flutter run \
|
||||
--wasm \
|
||||
${USERFRONT_FLUTTER_RUN_FLAGS:-} \
|
||||
--no-web-resources-cdn
|
||||
|
||||
echo "Count: $#"
|
||||
for arg in "$@"; do
|
||||
echo "Arg: $arg"
|
||||
done
|
||||
@@ -1,221 +0,0 @@
|
||||
email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1,sub_email
|
||||
cyhan@samaneng.com,한치영,01041585840,super,rnd-saman,,,,,224382,tech-planning,,책임연구원,,,b24051,b24051@hanmaceng.co.kr
|
||||
jhshin@samaneng.com,신지호,010-9268-7509,user,rnd-saman,,,,,209171,erp,,책임연구원,,,M20329,m20329@hanmaceng.co.kr
|
||||
swbae@samaneng.com,배상우,010-4716-5624,user,rnd-saman,,,,,215032,water-sewer,,선임연구원,,,B22062,b22062@hanmaceng.co.kr
|
||||
hspark1@samaneng.com,박현수,010-3898-1757,user,rnd-saman,,,,,207241,water-sewer,,수석연구원,팀장,,B19206,b19206@hanmaceng.co.kr
|
||||
smyoo@samaneng.com,유승민,010-9242-2912,user,rnd-saman,,,,,222244,strana,,선임연구원,,,B22058,b22058@hanmaceng.co.kr
|
||||
mjjeong1@samaneng.com,정명준,010-3062-2026,user,rnd-saman,,,,,216070,solution-dev,,책임연구원,,,M20330,m20330@hanmaceng.co.kr
|
||||
hjkim3@samaneng.com,김형준,010-4850-8649,user,rnd-saman,,,,,216121,tdc,,수석연구원,,,B16212,hjkim3@hanmaceng.co.kr
|
||||
ypshim@samaneng.com,심영표,010-3296-1788,user,rnd-saman,,,,,216164,dfma,,수석연구원,팀장,,B16216,ypshim@hanmaceng.co.kr
|
||||
jnoh@samaneng.com,노준,010-9177-0523,user,rnd-saman,,,,,217155,slope-structures,,수석연구원,,,B17206,jnoh@hanmaceng.co.kr
|
||||
dwahn@samaneng.com,안대욱,010-6424-1980,user,rnd-saman,,,,,217157,cheonjijin-cell,,책임연구원,,,B10201,dw6092@hanmaceng.co.kr
|
||||
kwjeong@samaneng.com,정계완,010-2743-8814,user,rnd-saman,,,,,218001,structural-software,,수석연구원,팀장,,B17203,kyewan@hanmaceng.co.kr
|
||||
mskim7@samaneng.com,김민성,010-7730-8174,user,rnd-saman,,,,,218002,graphics,,수석연구원,,,B16213,mskim@hanmaceng.co.kr
|
||||
sjyou@samaneng.com,유석준,010-2067-4875,user,rnd-saman,,,,,218003,smart-construction,,수석연구원,,,B16214,sjyou@hanmaceng.co.kr
|
||||
kjkim1@samaneng.com,김경종,010-9644-7401,user,rnd-saman,,,,,218005,strana,,선임연구원,,,B17315,kjkim@hanmaceng.co.kr
|
||||
iwlee@samaneng.com,이인우,010-5001-5305,user,rnd-saman,,,,,218007,structural-software,,책임연구원,,,B16305,inwoo772@hanmaceng.co.kr
|
||||
gbkim@samaneng.com,김규범,010-3341-8624,user,rnd-saman,,,,,218008,land-map-cell,,선임연구원,,,B17308,gyubeom627@hanmaceng.co.kr
|
||||
yjlee3@samaneng.com,이연재,010-5276-3376,user,rnd-saman,,,,,218009,structural-software,,선임연구원,,,B17309,yeonjae52@hanmaceng.co.kr
|
||||
itkim@samaneng.com,김일태,010-6500-6873,user,rnd-saman,,,,,218027,structure-planning,,수석연구원,팀장,,B18206,itkim@hanmaceng.co.kr
|
||||
jychoi1@samaneng.com,최진영,010-8070-0952,user,rnd-saman,,,,,218118,hmeg,,선임연구원,,,B18311,jy_choi@hanmaceng.co.kr
|
||||
bjkim2@samaneng.com,김병조,010-8592-7983,user,rnd-saman,,,,,218128,infra-bim2,,수석연구원,팀장,,B18212,bjkim@hanmaceng.co.kr
|
||||
hklee@samaneng.com,이호경,010-4748-1103,user,rnd-saman,,,,,218141,strana,,수석연구원,팀장,,B18215,hklee@hanmaceng.co.kr
|
||||
hsryu1@samaneng.com,류한솔,010-9955-1825,user,rnd-saman,,,,,218144,primal-plan,,책임연구원,,,B18213,hansol.ryu@hanmaceng.co.kr
|
||||
hyshin@samaneng.com,신혜영,010-3595-3511,user,rnd-saman,,,,,218145,design-planning,,수석연구원,팀장,,B18214,shy0622@hanmaceng.co.kr
|
||||
hsyu@samaneng.com,유효식,010-8885-1095,user,rnd-saman,,,,,218151,schedule-control,,책임연구원,,,B18313,hyosik914@hanmaceng.co.kr
|
||||
hikim@samaneng.com,김현일,010-9491-7161,user,rnd-saman,,,,,219001,substructure,,수석연구원,팀장,,B19201,kajm77@hanmaceng.co.kr
|
||||
bhyang1@samaneng.com,양병홍,010-6201-0523,user,rnd-saman,,,,,219018,tdc,,부사장,센터장,,B18202,b18202@hanmaceng.co.kr
|
||||
eklee1@samaneng.com,이은구,010-5672-7889,user,rnd-saman,,,,,219072,water-resources,,책임연구원,팀장,,B19203,lek@hanmaceng.co.kr
|
||||
wtshin@samaneng.com,신원태,010-2726-0728,user,rnd-saman,,,,,219080,schedule-control,,책임연구원,,,B19204,panic7ka@hanmaceng.co.kr
|
||||
dwlee2@samaneng.com,이동원,010-2910-3133,user,rnd-saman,,,,,219152,structural-division,,수석연구원,디비전장,,B19309,dwlee2@hanmaceng.co.kr
|
||||
mskim@samaneng.com,김명식,010-2289-5257,user,rnd-saman,,,,,219154,hmeg,,선임연구원,,,B19310,myungsik@hanmaceng.co.kr
|
||||
wison@samaneng.com,손원일,010-2430-4219,user,rnd-saman,,,,,219155,site-design-dev,,책임연구원,,,B19311,wison@hanmaceng.co.kr
|
||||
dhlee@samaneng.com,이동호,010-8708-6817,user,rnd-saman,,,,,220047,infra-bim2,,선임연구원,,,B22056,b22056@hanmaceng.co.kr
|
||||
ysjang1@samaneng.com,장용섭,010-4701-1006,user,rnd-saman,,,,,220147,way-draw,,책임연구원,,,B20202,yongseop@hanmaceng.co.kr
|
||||
jahan@samaneng.com,한지아,010-2584-3790,user,rnd-saman,,,,,222057,web-design,,책임연구원,,,B22001,b22001@hanmaceng.co.kr
|
||||
shkwon@samaneng.com,권순호,010-4432-4117,user,rnd-saman,,,,,222059,design-planning,,연구원,,,B22003,b22003@hanmaceng.co.kr
|
||||
dlyoo@samaneng.com,유달리,010-9007-9064,user,rnd-saman,,,,,220227,infra-bim3,,책임연구원,,,B20205,b20205@hanmaceng.co.kr
|
||||
yhjung2@samaneng.com,정요한,010-8867-6046,user,rnd-saman,,,,,220234,cost-control,,수석연구원,팀장,,B20326,b20326@hanmaceng.co.kr
|
||||
ygkim1@samaneng.com,김윤권,010-4131-1369,user,rnd-saman,,,,,220266,schedule-control,,책임연구원,,,B20333,b20333@hanmaceng.co.kr
|
||||
jwlee1@samaneng.com,이재원,010-7766-4757,user,rnd-saman,,,,,220271,modeler,,선임연구원,,,B20336,b20336@hanmaceng.co.kr
|
||||
jhlee2@samaneng.com,이주형,010-7511-5468,user,rnd-saman,,,,,221022,infra-bim2,,선임연구원,,,B21315,b21315@hanmaceng.co.kr
|
||||
jslee1@samaneng.com,이진수,010-6409-6442,user,rnd-saman,,,,,221040,land-map-cell,,선임연구원,,,B21306,b21306@hanmaceng.co.kr
|
||||
yski@samaneng.com,기윤서,010-6289-9782,user,rnd-saman,,,,,221052,bcmf,,수석연구원,,,M21309,m21309@hanmaceng.co.kr
|
||||
kakang@samaneng.com,강근아,010-3066-9589,user,rnd-saman,,,,,221054,eg-bim-draw,,선임연구원,,,M21318,m21318@hanmaceng.co.kr
|
||||
jwpark8@samaneng.com,박정우,010-4794-0596,user,rnd-saman,,,,,221055,gsim,,선임연구원,,,B21309,b21309@hanmaceng.co.kr
|
||||
bckim@samaneng.com,김병철,010-3016-7065,user,rnd-saman,,,,,221064,erp,,선임연구원,,,B21319,b21319@hanmaceng.co.kr
|
||||
jykang1@samaneng.com,강지영,010-3322-6664,user,rnd-saman,,,,,221067,cm-planning,,선임연구원,,,B21320,b21320@hanmaceng.co.kr
|
||||
ehjung1@samaneng.com,정은혜,010-3378-1154,user,rnd-saman,,,,,221163,design-planning,,책임연구원,,,B21339,b21339@hanmaceng.co.kr
|
||||
alhong@samaneng.com,홍아름,010-4070-1948,user,rnd-saman,,,,,221184,tech-planning,,수석연구원,,,B21344,b21344@hanmaceng.co.kr
|
||||
thlee3@samaneng.com,이태훈,010-4527-8434,user,rnd-saman,,,,,221270,tech-planning,,선임연구원,,,B21364,b21364@hanmaceng.co.kr
|
||||
jsyun@samaneng.com,윤준수,010-9877-8748,user,rnd-saman,,,,,221293,solution-integration,,선임연구원,,,B21367,b21367@hanmaceng.co.kr
|
||||
sphwang@samaneng.com,황선필,010-5035-5239,user,rnd-saman,,,,,221292,cm-planning,,선임연구원,,,B21368,b21368@hanmaceng.co.kr
|
||||
jwchoi3@samaneng.com,최정우,010-8963-5736,user,rnd-saman,,,,,221337,water-sewer,,책임연구원,,,B22055,b21316@hanmaceng.co.kr
|
||||
ngkim@samaneng.com,김남걸,010-2262-5708,user,rnd-saman,,,,,222004,schedule-control,,수석연구원,,,B21372,b21372@hanmaceng.co.kr
|
||||
yhchoi@samaneng.com,최용혁,010-8513-1451,user,rnd-saman,,,,,222010,structure-planning,,선임연구원,,,B21370,b21370@hanmaceng.co.kr
|
||||
skkang@samaneng.com,강상구,010-9291-0264,user,rnd-saman,,,,,222060,cm-planning,,선임연구원,,,B22004,b22004@hanmaceng.co.kr
|
||||
unhuh@samaneng.com,허유나,010-8870-9345,user,rnd-saman,,,,,222073,design-planning,,선임연구원,,,B22011,b22011@hanmaceng.co.kr
|
||||
chlee@samaneng.com,이창효,010-8725-3372,user,rnd-saman,,,,,222078,dfma,,선임연구원,,,B22019,b22019@hanmaceng.co.kr
|
||||
mkim2@samaneng.com,임민경,010-8209-9929,user,rnd-saman,,,,,222087,management-planning,,책임연구원,,,B22015,b21365@hanmaceng.co.kr
|
||||
cichoi@samaneng.com,최창인,010-4645-2808,user,rnd-saman,,,,,222089,substructure,,책임연구원,,,B22016,b22016@hanmaceng.co.kr
|
||||
hikim2@samaneng.com,김혜인,010-9510-3760,user,rnd-saman,,,,,222123,tech-planning,,선임연구원,,,B22027,b22027@hanmaceng.co.kr
|
||||
sclee@samaneng.com,이수창,010-7622-2729,user,rnd-saman,,,,,222150,infra-bim1,,선임연구원,,,B22031,b22031@hanmaceng.co.kr
|
||||
dhkim3@samaneng.com,김도현,010-9396-6726,user,rnd-saman,,,,,222152,bcmf,,선임연구원,,,B22039,b22039@hanmaceng.co.kr
|
||||
sdjo@samaneng.com,조선두,010-2009-9705,user,rnd-saman,,,,,222155,cm-planning,,책임연구원,팀장,,B22042,b22042@hanmaceng.co.kr
|
||||
sachoi@samaneng.com,최선아,010-6460-2728,user,rnd-saman,,,,,222156,management-planning,,책임연구원,,,B22036,b22036@hanmaceng.co.kr
|
||||
yjahn2@samaneng.com,안용주,010-5433-0545,user,rnd-saman,,,,,222157,dfma,,책임연구원,,,B22037,b22037@hanmaceng.co.kr
|
||||
smlee@samaneng.com,이수문,010-9229-3480,user,rnd-saman,,,,,222158,dfma,,수석연구원,,,B22035,b22035@hanmaceng.co.kr
|
||||
tskim@samaneng.com,김태식A,010-9965-9940,user,rnd-saman,,,,,222182,design-planning,,책임연구원,,,B22046,b22046@hanmaceng.co.kr
|
||||
jhkang@samaneng.com,강정훈,010-9891-8798,user,rnd-saman,,,,,222212,strana,,연구원,,,B22048,b22048@hanmaceng.co.kr
|
||||
jhkim14@samaneng.com,김재현,010-2534-7837,user,rnd-saman,,,,,222231,watch-bim,,수석연구원,,,B22051,b22051@hanmaceng.co.kr
|
||||
yjchoi1@samaneng.com,최윤진,010-2349-6687,user,rnd-saman,,,,,222240,way-draw,,연구원,,,B22052,b22052@hanmaceng.co.kr
|
||||
wkkim@samaneng.com,김원기,010-4727-8530,user,rnd-saman,,,,,222242,infra-bim1,,책임연구원,,,B22057,
|
||||
jhlee@samaneng.com,이준호,010-2514-6898,user,rnd-saman,,,,,223046,structural-software,,연구원,,,B23003,b23003@hanmaceng.co.kr
|
||||
jhchoi3@samaneng.com,최진헌,010-8638-8079,user,rnd-saman,,,,,222272,strana,,선임연구원,,,B22063,b22063@hanmaceng.co.kr
|
||||
hulee1@samaneng.com,이한울,010-9271-8997,user,rnd-saman,,,,,222294,web-design,,연구원,,,B22069,b22069@hanmaceng.co.kr
|
||||
dwkim3@samaneng.com,김도우,010-5008-6104,user,rnd-saman,,,,,223004,cost-estimate,,연구원,,,B22073,b22073@hanmaceng.co.kr
|
||||
mskim8@samaneng.com,김민수,010-4570-0179,user,rnd-saman,,,,,223006,construction-bim,,책임연구원,,,B22074,b22074@hanmaceng.co.kr
|
||||
jhjeong1@samaneng.com,정주현,010-7566-8314,user,rnd-saman,,,,,223007,cheonjijin-cell,,연구원,,,B22076,b22076@hanmaceng.co.kr
|
||||
scbaek@samaneng.com,백순철,010-9619-0437,user,rnd-saman,,,,,223045,cheonjijin-cell,,연구원,,,B23002,b23002@hanmaceng.co.kr
|
||||
shyeom1@samaneng.com,염승호,010-8835-0501,user,rnd-saman,,,,,223070,solution-integration,,수석연구원,,,B23008,b23008@hanmaceng.co.kr
|
||||
jskim1@samaneng.com,김진선,010-7415-8300,user,rnd-saman,,,,,223158,solution-dev,,선임연구원,,,B23033,b23033@hanmaceng.co.kr
|
||||
hyma@samaneng.com,마희연,010-8213-7601,user,rnd-saman,,,,,223089,design-planning,,선임연구원,,,B23015,b23015@hanmaceng.co.kr
|
||||
dwjung@samaneng.com,정두휘,010-5521-6160,user,rnd-saman,,,,,223099,design-planning,,연구원,,,B23014,b23014@hanmaceng.co.kr
|
||||
gshong@samaneng.com,홍길수,010-6641-0857,user,rnd-saman,,,,,223100,modeler,,연구원,,,B23019,b23019@hanmaceng.co.kr
|
||||
marco@samaneng.com,마르코,010-6662-1599,user,rnd-saman,,,,,223105,strana,,선임연구원,,,B23020,b23020@hanmaceng.co.kr
|
||||
hjjeong1@samaneng.com,정호진,010-7332-8456,user,rnd-saman,,,,,223114,strana,,연구원,,,B23022,b23022@hanmaceng.co.kr
|
||||
yjlee2@samaneng.com,이예진,010-9262-7530,user,rnd-saman,,,,,223123,design-planning,,선임연구원,,,B23028,b23028@hanmaceng.co.kr
|
||||
swpark@samaneng.com,박승우,010-5482-6617,user,rnd-saman,,,,,223195,abut-control,,연구원,,,B23038,b23038@hanmaceng.co.kr
|
||||
hwji@samaneng.com,지현욱,010-9228-8426,user,rnd-saman,,,,,223134,water-resources,,책임연구원,,,B23025,b23025@hanmaceng.co.kr
|
||||
swseo@samaneng.com,서승완,010-3245-1363,user,rnd-saman,,,,,223135,erp,,선임연구원,,,B23030,b23030@hanmaceng.co.kr
|
||||
jykim4@samaneng.com,김주영,010-3855-2839,user,rnd-saman,,,,,223138,structural-design,,선임연구원,,,B23031,b23031@hanmaceng.co.kr
|
||||
jglee1@samaneng.com,이정곤,010-3958-4115,user,rnd-saman,,,,,223184,cost-estimate,,책임연구원,,,B23036,b23036@hanmaceng.co.kr
|
||||
hmin@samaneng.com,민홍,010-8654-5461,user,rnd-saman,,,,,223313,gsim,,선임연구원,,,B23055,b23055@hanmaceng.co.kr
|
||||
hwan@samaneng.com,안효원,010-3358-4260,user,rnd-saman,,,,,223228,infra-bim1,,선임연구원,,,B23040,b23040@hanmaceng.co.kr
|
||||
sihan@samaneng.com,한성일,010-4322-1100,user,rnd-saman,,,,,223226,abut-control,,책임연구원,,,B23042,b23042@hanmaceng.co.kr
|
||||
jhkim25@samaneng.com,김재환,010-8962-3743,user,rnd-saman,,,,,223229,structural-design,,책임연구원,,,B23041,b23041@hanmaceng.co.kr
|
||||
gylee1@samaneng.com,이가연,010-2430-5102,user,rnd-saman,,,,,223269,slope-structures,,연구원,,,B23047,b23047@hanmaceng.co.kr
|
||||
yskim3@samaneng.com,김예서,010-9167-6132,user,rnd-saman,,,,,223280,land-map-cell,,연구원,,,B23051,b23051@hanmaceng.co.kr
|
||||
jhpyo@samaneng.com,표재학,010-2522-4984,user,rnd-saman,,,,,223281,primal-plan,,연구원,,,B23052,b23052@hanmaceng.co.kr
|
||||
sjkim6@samaneng.com,김신지,010-7667-8256,user,rnd-saman,,,,,223361,tech-planning,,연구원,,,B23064,b23064@hanmaceng.co.kr
|
||||
jschoi@samaneng.com,최지수,010-3557-3726,user,rnd-saman,,,,,223385,water-sewer,,연구원,,,B23068,b23068@hanmaceng.co.kr
|
||||
jsuhm@samaneng.com,엄지숙,010-5399-9030,user,rnd-saman,,,,,224048,eg-bim-draw,,책임연구원,,,B23072,b23072@hanmaceng.co.kr
|
||||
kbpark@samaneng.com,박경빈,010-9811-7018,user,rnd-saman,,,,,224053,watch-bim,,연구원,,,B24004,b24004@hanmaceng.co.kr
|
||||
hkyoon@samaneng.com,윤현경,010-4947-0798,user,rnd-saman,,,,,224057,structure-planning,,선임연구원,,,B24005,b24005@hanmaceng.co.kr
|
||||
jepark1@samaneng.com,박지은,010-3738-7186,user,rnd-saman,,,,,224058,project-management,,연구원,,,B24006,b24006@hanmaceng.co.kr
|
||||
kmlee1@samaneng.com,이경민,010-3409-1237,user,rnd-saman,,,,,224069,tech-planning,,선임연구원,,,B24009,b24009@hanmaceng.co.kr
|
||||
sylim1@samaneng.com,임성엽,010-5702-1213,user,rnd-saman,,,,,224070,land-map-cell,,선임연구원,,,B24011,b24011@hanmaceng.co.kr
|
||||
jgjeon@samaneng.com,전제경,010-3343-5898,user,rnd-saman,,,,,224091,cheonjijin-cell,,연구원,,,B24013,b24013@hanmaceng.co.kr
|
||||
hgjang@samaneng.com,장한규,010-7561-3369,user,rnd-saman,,,,,224080,dfma,,연구원,,,B24010,b24010@hanmaceng.co.kr
|
||||
dwham@samaneng.com,함도원,010-7557-2285,user,rnd-saman,,,,,224106,infra-bim3,,연구원,,,B24018,b24018@hanmaceng.co.kr
|
||||
grmin@samaneng.com,민경록,010-3272-0097,user,rnd-saman,,,,,224234,hmeg,,연구원,,,B24033,b24033@hanmaceng.co.kr
|
||||
hklee2@samaneng.com,이현경,010-2687-3453,user,rnd-saman,,,,,224265,site-design-dev,,연구원,,,B24035,b24035@hanmaceng.co.kr
|
||||
hsjin@samaneng.com,진희성,010-6773-0063,user,rnd-saman,,,,,224291,infra-bim1,,연구원,,,B24039,b24039@hanmaceng.co.kr
|
||||
gakim@samaneng.com,김근아,010-6301-3072,user,rnd-saman,,,,,224286,site-design-dev,,연구원,,,B24038,b24038@hanmaceng.co.kr
|
||||
jgbyun@samaneng.com,변정안,010-2499-5922,user,rnd-saman,,,,,224361,dfma,,선임연구원,,,B24046,b24046@hanmaceng.co.kr
|
||||
mspark@samaneng.com,박민선,010-3716-3845,user,rnd-saman,,,,,224353,tunnel,,연구원,,,B24044,b24044@hanmaceng.co.kr
|
||||
hyhwang@samaneng.com,황호연,010-4927-3201,user,rnd-saman,,,,,224363,water-resources,,연구원,,,B24047,b24047@hanmaceng.co.kr
|
||||
smlee2@samaneng.com,이상목,010-3470-9973,user,rnd-saman,,,,,224371,tunnel,,연구원,,,B24048,b24048@hanmaceng.co.kr
|
||||
dhhan1@samaneng.com,한동현,010-3606-0738,user,rnd-saman,,,,,224385,infra-bim2,,연구원,,,B24052,b24052@hanmaceng.co.kr
|
||||
jhchoi6@samaneng.com,최준호,010-9174-3191,user,rnd-saman,,,,,224394,gsim,,연구원,,,B24057,b24057@hanmaceng.co.kr
|
||||
mjlee@samaneng.com,이민지,010-3904-5527,user,rnd-saman,,,,,224392,substructure,,연구원,,,B24054,b24054@hanmaceng.co.kr
|
||||
mjjeong2@samaneng.com,정미정,010-4299-6544,user,rnd-saman,,,,,224391,structure-planning,,연구원,,,B24055,b24055@hanmaceng.co.kr
|
||||
mklee@samaneng.com,이민규,010-6243-3767,user,rnd-saman,,,,,224398,abut-control,,연구원,,,B24058,b24058@hanmaceng.co.kr
|
||||
anlee@samaneng.com,이에녹,010-3301-7191,user,rnd-saman,,,,,224402,infra-bim2,,연구원,,,B24060,b24060@hanmaceng.co.kr
|
||||
bshan@samaneng.com,한반석,010-5052-1706,user,rnd-saman,,,,,225025,infra-bim3,,연구원,,,B25002,b25002@hanmaceng.co.kr
|
||||
hckim4@samaneng.com,김희철,010-5012-8456,user,rnd-saman,,,,,225083,water-resources,,연구원,,,B25004,b25004@hanmaceng.co.kr
|
||||
swpark2@samaneng.com,박성원,010-5672-0355,user,rnd-saman,,,,,225084,infra-bim2,,연구원,,,B25003,b25003@hanmaceng.co.kr
|
||||
yjsung@samaneng.com,성유정,010-8976-2264,user,rnd-saman,,,,,225099,infra-bim1,,연구원,,,B25009,b25009@hanmaceng.co.kr
|
||||
sjyou1@samaneng.com,유서진,010-8703-8014,user,rnd-saman,,,,,225100,infra-bim3,,연구원,,,B25010,b25010@hanmaceng.co.kr
|
||||
gukim@samaneng.com,김건우A,010-6643-0460,user,rnd-saman,,,,,225105,gsim,,연구원,,,B25013,b25013@hanmaceng.co.kr
|
||||
sykim3@samaneng.com,김성엽,010-3818-8608,user,rnd-saman,,,,,225110,infra-bim3,,선임연구원,,,B25011,b25011@hanmaceng.co.kr
|
||||
jskwon@samaneng.com,권장승,010-7176-7142,user,rnd-saman,,,,,225111,infra-bim1,,연구원,,,B25014,b25014@hanmaceng.co.kr
|
||||
jyjung1@samaneng.com,정지윤,010-7132-6329,user,rnd-saman,,,,,225140,design-planning,,연구원,,,B25017,b25017@hanmaceng.co.kr
|
||||
jwjeong1@samaneng.com,정진우,010-5438-6084,user,rnd-saman,,,,,225122,hmeg,,연구원,,,B25016,b25016@hanmaceng.co.kr
|
||||
cwshin@samaneng.com,신찬웅,010-5538-6590,user,rnd-saman,,,,,225141,watch-bim,,연구원,,,B25018,b25018@hanmaceng.co.kr
|
||||
jskim2@samaneng.com,김종석,010-9458-1138,user,rnd-saman,,,,,225156,site-design-dev,,선임연구원,,,B25020,b25020@hanmaceng.co.kr
|
||||
shpark10@samaneng.com,박석현,010-9252-6709,user,rnd-saman,,,,,225161,infra-bim1,,연구원,,,B25021,b25021@hanmaceng.co.kr
|
||||
hjjung1@samaneng.com,정학재,010-9285-9318,user,rnd-saman,,,,,225162,infra-bim2,,연구원,,,B25022,b25022@hanmaceng.co.kr
|
||||
hrlee1@samaneng.com,이해랑,010-8628-0094,user,rnd-saman,,,,,225175,modeler,,연구원,,,B25023,b25023@hanmaceng.co.kr
|
||||
jhsim@samaneng.com,심재훈,010-6633-3366,user,rnd-saman,,,,,225183,tunnel,,수석연구원,,,B25025,b25025@hanmaceng.co.kr
|
||||
shkim4@samaneng.com,김수현,010-5645-5153,user,rnd-saman,,,,,225215,design-planning,,선임연구원,,,B25027,b25027@hanmaceng.co.kr
|
||||
smbaek@samaneng.com,백승민,010-7156-8542,user,rnd-saman,,,,,225319,hmeg,,책임연구원,,,B25035,b25035@hanmaceng.co.kr
|
||||
swpark3@samaneng.com,박상원,010-4794-0148,user,rnd-saman,,,,,225336,cm-planning,,연구원,,,B25036,b25036@hanmaceng.co.kr
|
||||
smyoun@samaneng.com,윤석무,010-9780-8901,user,rnd-saman,,,,,226049,solution-dev,,연구원,,,B26002,b26002@hanmaceng.co.kr
|
||||
jhpark4@samaneng.com,박종혁,010-4211-2090,user,rnd-saman,,,,,226072,infra-bim2,,연구원,,,B26003,b26003@hanmaceng.co.kr
|
||||
dhhong@samaneng.com,홍덕현,010-5360-7314,user,rnd-saman,,,,,226073,structural-design,,연구원,,,B26004,b26004@hanmaceng.co.kr
|
||||
twchung@hanmaceng.co.kr,정태원,010-2362-3668,user,rnd-hanmac,,,,,twchung,tdc,,사장,,,M21201,ctw@hanmaceng.co.kr
|
||||
shkim13@hanmaceng.co.kr,김승호,010-4753-3240,user,rnd-hanmac,,,,,shkim13,substructure,,수석연구원,,,M02248,soo98soo@hanmaceng.co.kr
|
||||
jhkim32@hanmaceng.co.kr,김정훈,010-9152-7409,user,rnd-hanmac,,,,,jhkim32,infra-solution,,수석연구원,디비전장,,M04308,hunsing@hanmaceng.co.kr
|
||||
khseok@hanmaceng.co.kr,곽현석,010-3280-3609,user,rnd-hanmac,,,,,khseok,structure-planning,,수석연구원,,,M06309,hyunss97@hanmaceng.co.kr
|
||||
eshwang1@hanmaceng.co.kr,황은식,010-8792-9303,user,rnd-hanmac,,,,,eshwang1,infra-bim1,,수석연구원,팀장,,M07302,bobos1101@hanmaceng.co.kr
|
||||
jjpyo@hanmaceng.co.kr,표종진,010-6406-1225,user,rnd-hanmac,,,,,jjpyo,infra-bim2,,수석연구원,,,M08301,piossy@hanmaceng.co.kr
|
||||
hslee5@hanmaceng.co.kr,이호성,010-8622-3403,user,rnd-hanmac,,,,,hslee5,gsim-dev,,수석연구원,팀장,,M08303,jpsaviola@hanmaceng.co.kr
|
||||
hylee4@hanmaceng.co.kr,이화영,010-4720-8841,user,rnd-hanmac,,,,,hylee4,tunnel,,수석연구원,팀장,,M12205,leehy@hanmaceng.co.kr
|
||||
bjshin@hanmaceng.co.kr,신봉진,010-7189-4043,user,rnd-hanmac,,,,,bjshin,cheonjijin-cell,,수석연구원,,,M17203,bjshin@hanmaceng.co.kr
|
||||
mjkang4@hanmaceng.co.kr,강명진,010-5158-3696,user,rnd-hanmac,,,,,mjkang4,cheonjijin,,수석연구원,팀장,,M17205,mjkang@hanmaceng.co.kr
|
||||
msoh1@hanmaceng.co.kr,오문성,010-3319-7853,user,rnd-hanmac,,,,,msoh1,cost-estimate,,수석연구원,,,M18201,ohmunseong@hanmaceng.co.kr
|
||||
swkim3@pre-cast.co.kr,김상욱,010-4857-3636,user,rnd-baron,,,,,swkim3,structural-design,,수석연구원,팀장,,P11202,p11202@hanmaceng.co.kr
|
||||
yhkim8@brsw.kr,김윤하,010-3322-7515,user,rnd-baron,,,,,yhkim8,web-solutions,,수석연구원,팀장,,T03225,kyh@hanmaceng.co.kr
|
||||
mnyoun@hanmaceng.co.kr,문남연,010-4534-4443,user,rnd-hanmac,,,,,mnyoun,infra-solution-dev,,수석연구원,팀장,,T04306,ace97@hanmaceng.co.kr
|
||||
jgchoi@hanmaceng.co.kr,최정균,010-6737-9212,user,rnd-hanmac,,,,,jgchoi,construction-bim,,책임연구원,,,M26013,b21366@hanmaceng.co.kr
|
||||
jwkim9@hanmaceng.co.kr,김지웅,010-4714-8160,user,rnd-hanmac,,,,,jwkim9,structural-software,,책임연구원,,,B13301,b13301@hanmaceng.co.kr
|
||||
jychoi4@hanmaceng.co.kr,최준영,010-3156-1423,user,rnd-hanmac,,,,,jychoi4,eg-bim-draw,,책임연구원,,,B17314,cjy627@hanmaceng.co.kr
|
||||
sykim5@brsw.kr,김세열,010-9122-6487,user,rnd-baron,,,,,sykim5,structural-software,,책임연구원,,,J15306,j15306@hanmaceng.co.kr
|
||||
ktlee1@hanmaceng.co.kr,이광태,010-9863-1108,user,rnd-hanmac,,,,,ktlee1,infra-bim1,,책임연구원,,,M13301,ktqoqo@hanmaceng.co.kr
|
||||
jykim7@pre-cast.co.kr,김지영,010-7412-1729,user,rnd-baron,,,,,jykim7,infra-bim3,,책임연구원,팀장,,M17208,jykim@hanmaceng.co.kr
|
||||
ysmun@pre-cast.co.kr,문영석,010-2833-5718,user,rnd-baron,,,,,ysmun,hmeg,,선임연구원,,,B20309,munyeongseok@hanmaceng.co.kr
|
||||
ghkim4@brsw.kr,김근형,010-2622-0967,user,rnd-baron,,,,,ghkim4,eg-bim-draw,,선임연구원,,,B20311,rmsgud1202@hanmaceng.co.kr
|
||||
jkson@brsw.kr,손제근,010-6421-8791,user,rnd-baron,,,,,jkson,project-management,,선임연구원,,,B24022,b24022@hanmaceng.co.kr
|
||||
jhmoon2@brsw.kr,문준혁,010-2345-3362,user,rnd-baron,,,,,jhmoon2,infra-bim1,,선임연구원,,,B25028,b25028@hanmaceng.co.kr
|
||||
bslee2@brsw.kr,이배승,010-7583-8440,user,rnd-baron,,,,,bslee2,infra-bim1,,선임연구원,,,B25031,b25031@hanmaceng.co.kr
|
||||
dhseo@brsw.kr,서동해,010-6289-9590,user,rnd-baron,,,,,dhseo,eg-bim-draw,,선임연구원,,,B24023,b24023@hanmaceng.co.kr
|
||||
ybkim1@brsw.kr,김영배,010-6371-1318,user,rnd-baron,,,,,ybkim1,primal-plan,,선임연구원,,,B20327,b20327@hanmaceng.co.kr
|
||||
jhchoi10@hanmaceng.co.kr,최정혁,010-4800-2603,user,rnd-hanmac,,,,,jhchoi10,tunnel,,선임연구원,,,M20212,jhchoi@hanmaceng.co.kr
|
||||
hgkim5@hanmaceng.co.kr,김한결,010-8009-6172,user,rnd-hanmac,,,,,hgkim5,erp,,선임연구원,,,M22014,hgk121@hanmaceng.co.kr
|
||||
cypark2@brsw.kr,박채영,010-4508-4006,user,rnd-baron,,,,,cypark2,watch-bim,,연구원,,,B24026,b24026@hanmaceng.co.kr
|
||||
jylee8@brsw.kr,이지율,010-8652-9029,user,rnd-baron,,,,,jylee8,modeler,,연구원,,,B24021,b24021@hanmaceng.co.kr
|
||||
shkang2@brsw.kr,강성호,010-2736-7419,user,rnd-baron,,,,,shkang2,way-draw,,연구원,,,B24024,b24024@hanmaceng.co.kr
|
||||
yclee1@hanmaceng.co.kr,이예찬,010-4748-6225,user,rnd-hanmac,,,,,yclee1,primal-plan,,연구원,,,M24059,m24059@hanmaceng.co.kr
|
||||
dgkwak@hanmaceng.co.kr,곽동권,010-6878-1926,user,rnd-hanmac,,,,,dgkwak,infra-bim2,,연구원,,,M24083,m24083@hanmaceng.co.kr
|
||||
huyoon1@brsw.kr,윤현욱,010-7134-5068,user,rnd-baron,,,,,huyoon1,infra-bim1,,연구원,,,B25030,b25030@hanmaceng.co.kr
|
||||
lhkim1@brsw.kr,김이훈,010-8778-0797,user,rnd-baron,,,,,lhkim1,infra-bim1,,연구원,,,B25032,b25032@hanmaceng.co.kr
|
||||
ykshin@hanmaceng.co.kr,신영교,010-7567-2528,user,rnd-hanmac,,,,,ykshin,infra-bim2,,연구원,,,M24068,m24068@hanmaceng.co.kr
|
||||
jtchoi@brsw.kr,최진태,010-6808-0921,user,rnd-baron,,,,,jtchoi,solution-dev,,연구원,,,B24032,b24032@hanmaceng.co.kr
|
||||
myyang@brsw.kr,양미연,010-5523-5072,user,rnd-baron,,,,,myyang,web-design,,연구원,,,B25015,b25015@hanmaceng.co.kr
|
||||
ymjo@brsw.kr,조용민,010-9490-9522,user,rnd-baron,,,,,ymjo,infra-bim1,,연구원,,,B25019,b25019@hanmaceng.co.kr
|
||||
bwlee1@hanmaceng.co.kr,이병욱A,010-3286-4086,user,rnd-hanmac,,,,,bwlee1,infra-bim2,,연구원,,,M25013,m25013@hanmaceng.co.kr
|
||||
bglee2@brsw.kr,이병권,010-5097-7600,user,rnd-baron,,,,,bglee2,erp,,연구원,,,B21369,b21369@hanmaceng.co.kr
|
||||
jcjang@hanmaceng.co.kr,장종찬,010-5463-1677,user,rnd-hanmac,,,,,jcjang,gpd,,사장,,,M02210,jcjang67@hanmaceng.co.kr
|
||||
hjkwon@brsw.kr,권혁진,010-8721-7453,user,rnd-baron,,,,,hjkwon,solution-integration,,수석연구원,,,B20304,cozyjin@hanmaceng.co.kr
|
||||
thcho@brsw.kr,조태희,010-7588-8031,user,rnd-baron,,,,,thcho,talent-growth,,수석연구원,팀장,,B22040,b22040@hanmaceng.co.kr
|
||||
wjkim@brsw.kr,김우진A,010-3218-8381,user,rnd-baron,,,,,wjkim,management-planning,,수석연구원,팀장,,J08305,j08305@hanmaceng.co.kr
|
||||
hisung@hanmaceng.co.kr,성형일,010-2356-6633,user,rnd-hanmac,,,,,hisung,collaboration,,수석연구원,,,M06203,guddlf12@hanmaceng.co.kr
|
||||
wgkim2@hanmaceng.co.kr,김원기,010-6283-6786,user,rnd-hanmac,,,,,wgkim2,tech-planning,,수석연구원,팀장,,M07318,kwongi79@hanmaceng.co.kr
|
||||
hsryu2@brsw.kr,류호성,010-3371-5649,user,rnd-baron,,,,,hsryu2,erp-planning,,수석연구원,팀장,,M20331,m20331@hanmaceng.co.kr
|
||||
wskim3@hanmaceng.co.kr,김원식,010-8755-6171,user,rnd-hanmac,,,,,wskim3,gpd,,전무이사,,,M19202,kws69@hanmaceng.co.kr
|
||||
jhpark13@hanmaceng.co.kr,박주한,010-8955-3850,user,rnd-hanmac,,,,,jhpark13,collaboration,,책임연구원,,,M22006,m22006@hanmaceng.co.kr
|
||||
hsmoon@hanmaceng.co.kr,문형석,010-9136-5338,user,rnd-hanmac,,,,,hsmoon,erp-planning,,책임연구원,,,M21420,moon79@hanmaceng.co.kr
|
||||
smhan@hanmaceng.co.kr,한승민,010-3189-1514,user,rnd-hanmac,,,,,smhan,collaboration,,선임연구원,,,B23070,b23070@hanmaceng.co.kr
|
||||
disong@brsw.kr,송대일,010-8627-0921,user,rnd-baron,,,,,disong,erp-planning,,선임연구원,,,B24014,b24014@hanmaceng.co.kr
|
||||
wjryu@brsw.kr,류원준,010-9191-7771,user,rnd-baron,,,,,wjryu,talent-growth,,선임연구원,,,B24063,b24063@hanmaceng.co.kr
|
||||
jykim8@hanmaceng.co.kr,김지영A,010-6389-0426,user,rnd-hanmac,,,,,jykim8,solution-integration,,선임연구원,,,M21430,kjy0426@hanmaceng.co.kr
|
||||
jypark7@hanmaceng.co.kr,박지영,010-9055-4775,user,rnd-hanmac,,,,,jypark7,design-planning,,선임연구원,,,M21438,b23046@hanmaceng.co.kr
|
||||
hrguk@pre-cast.co.kr,국혜림,010-6477-9711,user,rnd-baron,,,,,hrguk,management-planning,,선임연구원,,,B22038,b22038@hanmaceng.co.kr
|
||||
hhchoi@brsw.kr,최현호,010-2279-3954,user,rnd-baron,,,,,hhchoi,tech-planning,,선임연구원,,,B22064,b22064@hanmaceng.co.kr
|
||||
dhhwang@hanmaceng.co.kr,황동환,010-4242-6652,user,rnd-hanmac,,,,,dhhwang,tech-planning,,선임연구원,,,M19314,dhh12@hanmaceng.co.kr
|
||||
khchoi4@brsw.kr,최근혜,010-3637-0646,user,rnd-baron,,,,,khchoi4,talent-growth,,선임연구원,,,B24008,b24008@hanmaceng.co.kr
|
||||
biyun@brsw.kr,윤봄이,010-8482-2633,user,rnd-baron,,,,,biyun,design-planning,,선임연구원,,,B24016,b24016@hanmaceng.co.kr
|
||||
mylee2@brsw.kr,이미영A,010-3007-3044,user,rnd-baron,,,,,mylee2,management-planning,,선임연구원,,,B22041,b22041@hanmaceng.co.kr
|
||||
ojkwon1@hanmaceng.co.kr,권오재,010-9114-3943,user,rnd-hanmac,,,,,ojkwon1,erp-planning,,선임연구원,,,M24031,m24031@hanmaceng.co.kr
|
||||
huchoi@pre-cast.co.kr,최혜은,010-3453-2360,user,rnd-baron,,,,,huchoi,design-planning,,선임연구원,,,B23060,b23060@hanmaceng.co.kr
|
||||
sychae@brsw.kr,채선영,010-9523-0055,user,rnd-baron,,,,,sychae,design-planning,,선임연구원,,,B24027,b24027@hanmaceng.co.kr
|
||||
yjkim7@hanmaceng.co.kr,김윤재,010-9747-9838,user,rnd-hanmac,,,,,yjkim7,management-planning,,선임연구원,,,M22047,gh.kim@hanmaceng.co.kr
|
||||
yhchoi3@hanmaceng.co.kr,최영환,010-2905-0933,user,rnd-hanmac,,,,,yhchoi3,design-planning,,선임연구원,,,B16302,cyhwan0933@hanmaceng.co.kr
|
||||
cyjo@brsw.kr,조찬영,010-6671-2879,user,rnd-baron,,,,,cyjo,tech-planning,,연구원,,,B24028,b24028@hanmaceng.co.kr
|
||||
yykim@brsw.kr,김용연,010-2777-4695,user,rnd-baron,,,,,yykim,tech-planning,,연구원,,,B24053,b24053@hanmaceng.co.kr
|
||||
sblee5@brsw.kr,이새봄,010-5704-9685,user,rnd-baron,,,,,sblee5,erp-planning,,연구원,,,B23018,b23018@hanmaceng.co.kr
|
||||
shjeong@brsw.kr,정성호,010-5201-9028,user,rnd-baron,,,,,shjeong,talent-growth,,연구원,,,B24064,b24064@hanmaceng.co.kr
|
||||
wgjoo@brsw.kr,주완기,010-4247-0144,user,rnd-baron,,,,,wgjoo,talent-growth,,연구원,,,B22067,b22067@hanmaceng.co.kr
|
||||
syyang@brsw.kr,양숙영,010-7371-7662,user,rnd-baron,,,,,syyang,design-planning,,연구원,,,B24012,b24012@hanmaceng.co.kr
|
||||
jskim12@brsw.kr,김정석,010-5209-7757,user,rnd-baron,,,,,jskim12,design-planning,,연구원,,,B24049,b24049@hanmaceng.co.kr
|
||||
|
Reference in New Issue
Block a user