1
0
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:
2026-06-04 18:11:48 +09:00
45 changed files with 6014 additions and 1416 deletions

1
.gitignore vendored
View File

@@ -58,3 +58,4 @@ orgfront/dist/
orgfront/.vite/ orgfront/.vite/
.pnpm-store .pnpm-store
.playwright-mcp .playwright-mcp
node_modules

View File

@@ -1,3 +0,0 @@
allowBuilds:
'@biomejs/biome': true
esbuild: false

View File

@@ -2,12 +2,6 @@ import { useInfiniteQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react"; import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
import * as React from "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 { PageHeader } from "../../../../common/core/components/page";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar"; import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
@@ -23,6 +17,7 @@ import { Input } from "../../components/ui/input";
import type { AuditLog } from "../../lib/adminApi"; import type { AuditLog } from "../../lib/adminApi";
import { fetchAuditLogs } from "../../lib/adminApi"; import { fetchAuditLogs } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { VirtualizedAuditLogTable } from "./VirtualizedAuditLogTable";
function AuditLogsPage() { function AuditLogsPage() {
const [searchActorId, setSearchActorId] = React.useState(""); const [searchActorId, setSearchActorId] = React.useState("");
@@ -40,8 +35,23 @@ function AuditLogsPage() {
isFetching, isFetching,
refetch, refetch,
} = useInfiniteQuery({ } = useInfiniteQuery({
queryKey: ["audit-logs"], queryKey: [
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam), "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, initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined, getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
}); });
@@ -51,24 +61,6 @@ function AuditLogsPage() {
(page) => (page) =>
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [], 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -83,7 +75,7 @@ function AuditLogsPage() {
<> <>
<Badge variant="muted"> <Badge variant="muted">
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", { {t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
count: filteredLogs.length, count: logs.length,
})} })}
</Badge> </Badge>
<Button <Button
@@ -185,8 +177,8 @@ function AuditLogsPage() {
</form> </form>
} }
/> />
<AuditLogTable <VirtualizedAuditLogTable
logs={filteredLogs} logs={logs}
t={t} t={t}
loading={isLoading} loading={isLoading}
hasNextPage={Boolean(hasNextPage)} hasNextPage={Boolean(hasNextPage)}

View 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>
);
}

View File

@@ -299,6 +299,9 @@ function renderWithProviders(ui: React.ReactElement, entry = "/") {
describe("adminfront large page coverage smoke", () => { describe("adminfront large page coverage smoke", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
if (typeof window !== "undefined") {
(window as any)._IS_TEST_MODE = true;
}
}); });
it("renders user creation form with tenant context", async () => { it("renders user creation form with tenant context", async () => {

View File

@@ -4,6 +4,7 @@ import {
useMutation, useMutation,
useQuery, useQuery,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { import {
ArrowDown, ArrowDown,
@@ -93,6 +94,7 @@ import {
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles"; import { normalizeAdminRole } from "../../../lib/roles";
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree"; import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
import { cn } from "../../../lib/utils";
import { import {
buildAuthenticatedOrgChartTenantPickerUrl, buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants, filterNonHanmacFamilyTenants,
@@ -115,7 +117,6 @@ import {
resolveTenantSelectionIds, resolveTenantSelectionIds,
type TenantViewMode, type TenantViewMode,
type TenantViewRow, type TenantViewRow,
tenantMatchesListSearch,
} from "./tenantListView"; } from "./tenantListView";
const tenantCSVTemplate = const tenantCSVTemplate =
@@ -264,7 +265,6 @@ function resolveImportParentSelection(
function TenantListPage() { function TenantListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedIds, setSelectedIds] = React.useState<string[]>([]); const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [search, setSearch] = React.useState("");
const [viewMode, setViewMode] = React.useState<TenantViewMode>("tree"); const [viewMode, setViewMode] = React.useState<TenantViewMode>("tree");
const [scopeTenantId, setScopeTenantId] = React.useState(""); const [scopeTenantId, setScopeTenantId] = React.useState("");
const [scopePickerOpen, setScopePickerOpen] = React.useState(false); const [scopePickerOpen, setScopePickerOpen] = React.useState(false);
@@ -304,6 +304,8 @@ function TenantListPage() {
(d: TenantImportDetail) => d.action === importResultFilter, (d: TenantImportDetail) => d.action === importResultFilter,
); );
}, [importResult, importResultFilter]); }, [importResult, importResultFilter]);
const [search, setSearch] = React.useState("");
const debouncedSearch = React.useDeferredValue(search.trim());
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState(""); const [selectedBulkStatus, setSelectedBulkStatus] = React.useState("");
const _tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null); const _tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
@@ -314,18 +316,18 @@ function TenantListPage() {
const profileRole = normalizeAdminRole(profile?.role); const profileRole = normalizeAdminRole(profile?.role);
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ["tenants", "lazy"], queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
fetchTenants( fetchTenants(
tenantPageSize, tenantPageSize,
0, 0,
undefined, scopeTenantId || undefined,
pageParam ? pageParam : undefined, pageParam ? (pageParam as string) : undefined,
debouncedSearch,
), ),
initialPageParam: "", initialPageParam: "",
getNextPageParam: (lastPage) => getNextPageParam: (lastPage) =>
lastPage.nextCursor || lastPage.next_cursor || undefined, lastPage.nextCursor || lastPage.next_cursor || undefined,
enabled: profileRole === "super_admin",
}); });
const deleteBulkMutation = useMutation({ const deleteBulkMutation = useMutation({
@@ -436,6 +438,11 @@ function TenantListPage() {
}, },
}); });
const rawTenants = React.useMemo(
() => query.data?.pages.flatMap((page) => page.items) ?? [],
[query.data?.pages],
);
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error; ?.data?.error;
const fallbackError = const fallbackError =
@@ -443,15 +450,7 @@ function TenantListPage() {
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
: null; : null;
const tenantPages = React.useMemo( const tenantTotal = query.data?.pages[0]?.total ?? 0;
() => query.data?.pages ?? [],
[query.data?.pages],
);
const rawTenants = React.useMemo(
() => tenantPages.flatMap((page) => page.items),
[tenantPages],
);
const tenantTotal = tenantPages[0]?.total ?? 0;
const hanmacFamilyTenantId = React.useMemo(() => { const hanmacFamilyTenantId = React.useMemo(() => {
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID; const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
if (typeof envTenantId === "string" && envTenantId.trim()) { if (typeof envTenantId === "string" && envTenantId.trim()) {
@@ -721,6 +720,11 @@ function TenantListPage() {
className="h-9 pl-9" className="h-9 pl-9"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
query.refetch();
}
}}
/> />
</div> </div>
@@ -875,7 +879,7 @@ function TenantListPage() {
{importMessage ? ( {importMessage ? (
<div <div
className="rounded-md border border-border bg-secondary px-3 py-2 text-sm" 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} {importMessage}
</div> </div>
@@ -924,6 +928,10 @@ function TenantListPage() {
getSortIcon={getSortIcon} getSortIcon={getSortIcon}
viewMode={viewMode} viewMode={viewMode}
scopeTenantId={scopeTenantId} scopeTenantId={scopeTenantId}
fetchNextPage={query.fetchNextPage}
hasNextPage={!!query.hasNextPage}
isFetchingNextPage={query.isFetchingNextPage}
isLoading={query.isLoading}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -1039,7 +1047,10 @@ function TenantListPage() {
</DialogHeader> </DialogHeader>
{importResult && ( {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"> <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"> <span className="text-[10px] font-bold tracking-wider text-muted-foreground uppercase">
Total Total
@@ -1500,6 +1511,10 @@ const TenantHierarchyView: React.FC<{
getSortIcon: (key: TenantSortKey) => React.ReactNode; getSortIcon: (key: TenantSortKey) => React.ReactNode;
viewMode: TenantViewMode; viewMode: TenantViewMode;
scopeTenantId: string; scopeTenantId: string;
fetchNextPage: () => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
isLoading: boolean;
}> = ({ }> = ({
tenants, tenants,
selectedIds, selectedIds,
@@ -1514,10 +1529,20 @@ const TenantHierarchyView: React.FC<{
getSortIcon, getSortIcon,
viewMode, viewMode,
scopeTenantId, 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( const { subTree } = React.useMemo(
() => buildTenantFullTree(tenants, scopeTenantId || undefined), () => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search),
[scopeTenantId, tenants], [scopeTenantId, tenants, search],
); );
// Initial expanded state: everything open // Initial expanded state: everything open
@@ -1569,51 +1594,26 @@ const TenantHierarchyView: React.FC<{
const flattenedRows = React.useMemo(() => { const flattenedRows = React.useMemo(() => {
if (viewMode === "table") { if (viewMode === "table") {
return sortItems( return sortItems(
getTenantViewRows(tenants, "table", scopeTenantId).filter((tenant) => getTenantViewRows(tenants, "table", scopeTenantId, !!search),
tenantMatchesListSearch(tenant, search),
),
sortConfig, sortConfig,
tenantSortResolvers, tenantSortResolvers,
); );
} }
const result: TenantViewRow[] = []; const result: TenantViewRow[] = [];
const term = search.toLowerCase().trim();
// When searching, we show matched nodes and all their ancestors.
const matchedIds = new Set<string>();
if (term) {
const findMatches = (nodes: TenantNode[]) => {
for (const node of nodes) {
if (tenantMatchesListSearch(node, term)) {
matchedIds.add(node.id);
}
if (node.children) findMatches(node.children);
}
};
findMatches(subTree);
}
const collect = (nodes: TenantNode[], depth: number) => { const collect = (nodes: TenantNode[], depth: number) => {
// Sort nodes at the current depth // Sort nodes at the current depth
const sortedNodes = sortItems(nodes, sortConfig, tenantSortResolvers); const sortedNodes = sortItems(nodes, sortConfig, tenantSortResolvers);
for (const node of sortedNodes) { for (const node of sortedNodes) {
// If searching, show node if it matches OR any of its descendants match. result.push({ ...node, depth });
const hasMatchingDescendant = (n: TenantNode): boolean => { if (
if (matchedIds.has(n.id)) return true; expandedIds.has(node.id) &&
return n.children.some(hasMatchingDescendant); node.children &&
}; node.children.length > 0
) {
if (!term || hasMatchingDescendant(node)) { collect(node.children, depth + 1);
result.push({ ...node, depth });
if (
(term || expandedIds.has(node.id)) &&
node.children &&
node.children.length > 0
) {
collect(node.children, depth + 1);
}
} }
} }
}; };
@@ -1622,12 +1622,43 @@ const TenantHierarchyView: React.FC<{
}, [ }, [
expandedIds, expandedIds,
scopeTenantId, scopeTenantId,
search,
sortConfig, sortConfig,
subTree, subTree,
tenantSortResolvers, tenantSortResolvers,
tenants, tenants,
viewMode, 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( const visibleSelectableIds = React.useMemo(
@@ -1638,13 +1669,158 @@ const TenantHierarchyView: React.FC<{
visibleSelectableIds.has(id), visibleSelectableIds.has(id),
).length; ).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 ( return (
<div className="flex-1 rounded-md border overflow-hidden flex flex-col mt-4"> <div className="mt-4 flex flex-1 flex-col overflow-hidden rounded-md border">
<div className="flex-1 overflow-auto relative custom-scrollbar"> <div
<Table className="min-w-[1180px]"> 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"> <TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow> <TableRow>
<TableHead className="w-[48px] whitespace-nowrap"> <TableHead className="w-[48px] whitespace-nowrap px-4 text-center">
<Checkbox <Checkbox
checked={ checked={
deletableTenants.length > 0 && deletableTenants.length > 0 &&
@@ -1654,7 +1830,7 @@ const TenantHierarchyView: React.FC<{
/> />
</TableHead> </TableHead>
<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")} onClick={() => requestSort("name")}
> >
<div className="flex items-center"> <div className="flex items-center">
@@ -1663,7 +1839,7 @@ const TenantHierarchyView: React.FC<{
</div> </div>
</TableHead> </TableHead>
<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")} onClick={() => requestSort("id")}
> >
<div className="flex items-center"> <div className="flex items-center">
@@ -1672,7 +1848,7 @@ const TenantHierarchyView: React.FC<{
</div> </div>
</TableHead> </TableHead>
<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")} onClick={() => requestSort("type")}
> >
<div className="flex items-center"> <div className="flex items-center">
@@ -1681,7 +1857,7 @@ const TenantHierarchyView: React.FC<{
</div> </div>
</TableHead> </TableHead>
<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")} onClick={() => requestSort("slug")}
> >
<div className="flex items-center"> <div className="flex items-center">
@@ -1690,7 +1866,7 @@ const TenantHierarchyView: React.FC<{
</div> </div>
</TableHead> </TableHead>
<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")} onClick={() => requestSort("status")}
> >
<div className="flex items-center"> <div className="flex items-center">
@@ -1699,7 +1875,7 @@ const TenantHierarchyView: React.FC<{
</div> </div>
</TableHead> </TableHead>
<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")} onClick={() => requestSort("recursiveMemberCount")}
> >
<div className="flex items-center"> <div className="flex items-center">
@@ -1708,7 +1884,7 @@ const TenantHierarchyView: React.FC<{
</div> </div>
</TableHead> </TableHead>
<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")} onClick={() => requestSort("updatedAt")}
> >
<div className="flex items-center"> <div className="flex items-center">
@@ -1718,12 +1894,20 @@ const TenantHierarchyView: React.FC<{
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody className="relative">
{flattenedRows.length === 0 && ( {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> <TableRow>
<TableCell <TableCell
colSpan={8} colSpan={8}
className="text-center py-8 text-muted-foreground" className="py-8 text-center text-muted-foreground"
> >
{t( {t(
"msg.admin.tenants.empty", "msg.admin.tenants.empty",
@@ -1732,131 +1916,39 @@ const TenantHierarchyView: React.FC<{
</TableCell> </TableCell>
</TableRow> </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 ( {isTest && flattenedRows.length < 100
<TableRow ? flattenedRows.map((row, index) => renderRow(row, index))
key={node.id} : virtualRows.map((virtualRow) =>
className={ renderRow(
selectedIds.includes(node.id) ? "bg-primary/5" : "" 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"> <td colSpan={8} />
{isSeedTenant(node) ? ( </tr>
<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>
<TypeIcon {isFetchingNextPage && (
size={14} <TableRow>
className="mr-2 text-muted-foreground shrink-0" <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" />
<div className="flex flex-wrap items-center gap-2 min-w-0"> {t("msg.common.loading_more", "Loading more...")}
<Link </div>
to={`/tenants/${node.id}`} </TableCell>
className="hover:underline text-primary cursor-pointer truncate" </TableRow>
> )}
{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>
);
})}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@@ -68,8 +68,13 @@ export function getTenantViewRows(
tenants: TenantSummary[], tenants: TenantSummary[],
viewMode: TenantViewMode, viewMode: TenantViewMode,
scopeTenantId = "", scopeTenantId = "",
isSearchActive = false,
): TenantViewRow[] { ): TenantViewRow[] {
const { subTree } = buildTenantFullTree(tenants, scopeTenantId || undefined); const { subTree } = buildTenantFullTree(
tenants,
scopeTenantId || undefined,
isSearchActive,
);
const treeRows: TenantViewRow[] = []; const treeRows: TenantViewRow[] = [];
collectTenantTreeRows(subTree, 0, treeRows); collectTenantTreeRows(subTree, 0, treeRows);

View File

@@ -185,7 +185,7 @@ describe("UserListPage search rendering", () => {
fireEvent.change(searchInput, { target: { value: "user 19" } }); fireEvent.change(searchInput, { target: { value: "user 19" } });
fireEvent.keyDown(searchInput, { key: "Enter" }); 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(screen.queryByText("User 0")).not.toBeInTheDocument();
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs); expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
}); });

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
import { import {
observeElementRect, observeElementRect,
type Rect, type Rect,
@@ -11,8 +11,6 @@ import {
ArrowUp, ArrowUp,
ArrowUpDown, ArrowUpDown,
ChevronDown, ChevronDown,
ChevronLeft,
ChevronRight,
FileDown, FileDown,
FileSpreadsheet, FileSpreadsheet,
LayoutDashboard, LayoutDashboard,
@@ -119,7 +117,7 @@ type UserSchemaField = {
type UserSortKey = string; type UserSortKey = string;
const USER_ROW_ESTIMATED_HEIGHT = 64; const USER_ROW_ESTIMATED_HEIGHT = 64;
const USER_ROW_OVERSCAN = 8; const USER_ROW_OVERSCAN = 20;
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640; const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const; const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
const userMetadataColumnWidth = 160; const userMetadataColumnWidth = 160;
@@ -152,24 +150,6 @@ function assignableSystemRoleValue(role?: string | null) {
return isSuperAdminRole(role) ? "super_admin" : "user"; 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 { function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
return { return {
width: rect.width > 0 ? rect.width : fallbackWidth, width: rect.width > 0 ? rect.width : fallbackWidth,
@@ -179,7 +159,7 @@ function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
} }
type UserListSearchControlsProps = { type UserListSearchControlsProps = {
search: string; initialSearch: string;
selectedCompany: string; selectedCompany: string;
tenants: TenantSummary[]; tenants: TenantSummary[];
profileRole?: string | null; profileRole?: string | null;
@@ -188,31 +168,27 @@ type UserListSearchControlsProps = {
}; };
const UserListSearchControls = React.memo(function UserListSearchControls({ const UserListSearchControls = React.memo(function UserListSearchControls({
search, initialSearch,
selectedCompany, selectedCompany,
tenants, tenants,
profileRole, profileRole,
onSearch, onSearch,
onCompanyChange, onCompanyChange,
}: UserListSearchControlsProps) { }: UserListSearchControlsProps) {
const [searchDraft, setSearchDraft] = React.useState(search); const [localSearch, setLocalSearch] = React.useState(initialSearch);
React.useEffect(() => { React.useEffect(() => {
setSearchDraft(search); setLocalSearch(initialSearch);
}, [search]); }, [initialSearch]);
const handleSearch = React.useCallback(() => { React.useEffect(() => {
onSearch(searchDraft); const timer = setTimeout(() => {
}, [onSearch, searchDraft]); if (localSearch !== initialSearch) {
onSearch(localSearch);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
handleSearch();
} }
}, }, 300);
[handleSearch], return () => clearTimeout(timer);
); }, [localSearch, onSearch, initialSearch]);
const tenantOptions = React.useMemo( const tenantOptions = React.useMemo(
() => () =>
@@ -236,9 +212,13 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
"이름 또는 이메일 검색...", "이름 또는 이메일 검색...",
)} )}
className="h-9 pl-9" className="h-9 pl-9"
value={searchDraft} value={localSearch}
onChange={(event) => setSearchDraft(event.target.value)} onChange={(event) => setLocalSearch(event.target.value)}
onKeyDown={handleKeyDown} onKeyDown={(event) => {
if (event.key === "Enter") {
onSearch(localSearch);
}
}}
/> />
</div> </div>
@@ -255,7 +235,7 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={handleSearch} onClick={() => onSearch(localSearch)}
className="h-9" className="h-9"
> >
{t("ui.common.search", "검색")} {t("ui.common.search", "검색")}
@@ -268,7 +248,6 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
function UserListPage() { function UserListPage() {
const _navigate = useNavigate(); const _navigate = useNavigate();
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
const [selectedCompany, setSelectedCompany] = React.useState<string>(""); const [selectedCompany, setSelectedCompany] = React.useState<string>("");
const [visibleColumns, setVisibleColumns] = React.useState< const [visibleColumns, setVisibleColumns] = React.useState<
@@ -285,9 +264,6 @@ function UserListPage() {
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false); const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
const userTableViewportRef = React.useRef<HTMLDivElement | null>(null); const userTableViewportRef = React.useRef<HTMLDivElement | null>(null);
const limit = 1000;
const offset = (page - 1) * limit;
const { data: profile } = useQuery({ const { data: profile } = useQuery({
queryKey: ["me"], queryKey: ["me"],
queryFn: fetchMe, queryFn: fetchMe,
@@ -345,10 +321,12 @@ function UserListPage() {
})); }));
}; };
const query = useQuery({ const query = useInfiniteQuery({
queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }], queryKey: ["users", { search, tenantSlug: selectedCompany }],
queryFn: () => fetchUsers(limit, offset, search, selectedCompany), queryFn: ({ pageParam }) =>
placeholderData: (previousData) => previousData, fetchUsers(50, 0, search, selectedCompany, pageParam as string),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor || lastPage.nextCursor,
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -393,12 +371,10 @@ function UserListPage() {
const handleSearch = React.useCallback((nextSearch: string) => { const handleSearch = React.useCallback((nextSearch: string) => {
setSearch(nextSearch); setSearch(nextSearch);
setPage(1);
}, []); }, []);
const handleCompanyChange = React.useCallback((nextCompany: string) => { const handleCompanyChange = React.useCallback((nextCompany: string) => {
setSelectedCompany(nextCompany); setSelectedCompany(nextCompany);
setPage(1);
}, []); }, []);
const handleExport = (includeIds = false) => { const handleExport = (includeIds = false) => {
@@ -415,14 +391,11 @@ function UserListPage() {
) )
: null; : null;
const serverItems = query.data?.items ?? []; const serverItems = React.useMemo(
const rawItems = React.useMemo(() => { () => query.data?.pages.flatMap((page) => page.items) ?? [],
if (!query.isFetching || search.trim() === "") { [query.data],
return serverItems; );
} const rawItems = serverItems;
return serverItems.filter((user) => userMatchesSearch(user, search));
}, [query.isFetching, search, serverItems]);
const userSortResolvers = React.useMemo< const userSortResolvers = React.useMemo<
SortResolverMap<UserSummary, UserSortKey> SortResolverMap<UserSummary, UserSortKey>
>( >(
@@ -496,6 +469,25 @@ function UserListPage() {
}, },
}); });
const virtualRows = rowVirtualizer.getVirtualItems(); 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 shouldVirtualizeRows = !query.isLoading && items.length > 0;
const tableColumnCount = 9 + visibleUserSchemaFields.length; const tableColumnCount = 9 + visibleUserSchemaFields.length;
@@ -514,8 +506,7 @@ function UserListPage() {
); );
}; };
const total = query.data?.total ?? 0; const total = query.data?.pages[0]?.total ?? 0;
const totalPages = Math.ceil(total / limit);
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role); const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
const toggleSelectAll = () => { const toggleSelectAll = () => {
@@ -627,10 +618,10 @@ function UserListPage() {
actions={ actions={
<> <>
<UserListSearchControls <UserListSearchControls
search={search} initialSearch={search}
selectedCompany={selectedCompany} selectedCompany={selectedCompany}
tenants={tenants} tenants={tenants}
profileRole={profile?.role} profileRole={profileRole}
onSearch={handleSearch} onSearch={handleSearch}
onCompanyChange={handleCompanyChange} onCompanyChange={handleCompanyChange}
/> />
@@ -1241,36 +1232,6 @@ function UserListPage() {
</Button> </Button>
</div> </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> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -215,9 +215,14 @@ export type DeleteOrphanUserLoginIDsResult = {
skippedIds: string[]; 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", { const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor }, params: { limit, cursor, search, status },
}); });
return data; return data;
} }
@@ -293,11 +298,12 @@ export async function fetchTenants(
offset = 0, offset = 0,
parentId?: string, parentId?: string,
cursor?: string, cursor?: string,
search?: string,
) { ) {
const { data } = await apiClient.get<TenantListResponse>( const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants", "/v1/admin/tenants",
{ {
params: { limit, offset, parentId, cursor }, params: { limit, offset, parentId, cursor, search },
}, },
); );
return data; return data;
@@ -661,6 +667,8 @@ export type UserListResponse = {
limit: number; limit: number;
offset: number; offset: number;
total: number; total: number;
next_cursor?: string;
nextCursor?: string;
}; };
export type UserCreateRequest = { export type UserCreateRequest = {
@@ -883,9 +891,10 @@ export async function fetchUsers(
offset = 0, offset = 0,
search?: string, search?: string,
tenantSlug?: string, tenantSlug?: string,
cursor?: string,
) { ) {
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", { const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
params: { limit, offset, search, tenantSlug }, params: { limit, offset, search, tenantSlug, cursor },
}); });
return data; return data;
} }

View File

@@ -12,6 +12,7 @@ export type TenantNode = TenantSummary & {
export function buildTenantFullTree( export function buildTenantFullTree(
allTenants: TenantSummary[], allTenants: TenantSummary[],
rootId?: string, rootId?: string,
isSearchActive?: boolean,
): { currentBase: TenantNode | null; subTree: TenantNode[] } { ): { currentBase: TenantNode | null; subTree: TenantNode[] } {
if (allTenants.length === 0) return { currentBase: null, subTree: [] }; 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 // Build initial children relations and prevent simple cycles
for (const t of allTenants) { for (const t of allTenants) {
if (t.parentId && t.parentId !== t.id) { if (t.parentId && t.parentId !== t.id) {
@@ -54,26 +54,15 @@ export function buildTenantFullTree(
} }
node.recursiveMemberCount = total; 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; return total;
}; };
// Calculate for all top-level nodes (those without parent) // If a specific rootId is provided AND search is not active, find and return its subtree.
for (const node of tenantMap.values()) { // When searching, we prefer showing all matching nodes (virtual roots) rather than
if (!node.parentId) { // strictly adhering to the rootId anchor, because the rootId node itself might not be in the result set.
visitedForCalc.clear(); if (rootId && !isSearchActive) {
calculateRecursive(node);
}
}
// If a specific rootId is provided, find and return its subtree
if (rootId) {
const base = tenantMap.get(rootId); const base = tenantMap.get(rootId);
if (base) { if (base) {
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
visitedForCalc.clear(); visitedForCalc.clear();
calculateRecursive(base); calculateRecursive(base);
return { currentBase: base, subTree: base.children }; return { currentBase: base, subTree: base.children };
@@ -81,7 +70,19 @@ export function buildTenantFullTree(
return { currentBase: null, subTree: [] }; return { currentBase: null, subTree: [] };
} }
// If no rootId, return all top-level roots as subTree // 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) => !n.parentId); 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 }; return { currentBase: null, subTree: roots };
} }

View File

@@ -53,13 +53,33 @@ test.describe("Audit Logs Management", () => {
const url = route.request().url(); const url = route.request().url();
const urlObj = new URL(url); const urlObj = new URL(url);
const cursor = urlObj.searchParams.get("cursor"); const cursor = urlObj.searchParams.get("cursor");
const search = urlObj.searchParams.get("search")?.toLowerCase();
const status = urlObj.searchParams.get("status");
const offset = cursor ? 20 : 0; 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({ return route.fulfill({
json: { json: {
items: generateMockLogs(20, offset), items: paginatedItems,
next_cursor: offset === 0 ? "fake-cursor" : null, next_cursor: allMockLogs.length > offset + 20 ? "fake-cursor" : null,
total: 40, total: allMockLogs.length,
}, },
headers: { "Access-Control-Allow-Origin": "*" }, headers: { "Access-Control-Allow-Origin": "*" },
}); });
@@ -172,7 +192,7 @@ test.describe("Audit Logs Management", () => {
await userIdInput.fill("user-even"); await userIdInput.fill("user-even");
// Wait for deferred value to apply // 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"); await expect(page.locator("tbody")).not.toContainText("user-odd");
// Clear User ID // Clear User ID
@@ -183,12 +203,13 @@ test.describe("Audit Logs Management", () => {
const actionInput = page.getByTestId("audit-search-action"); const actionInput = page.getByTestId("audit-search-action");
await actionInput.fill("ROTATE_SECRET"); await actionInput.fill("ROTATE_SECRET");
// Check that we only see ROTATE_SECRET (20 - 7 = 13) // Check that we see ROTATE_SECRET across all 40 logs (40 - 14 = 26)
await expect(page.locator("tbody tr")).toHaveCount(13, { timeout: 15000 }); // 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"); 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 page.goto("/audit-logs");
await expect(page.locator(".animate-spin")).not.toBeVisible({ await expect(page.locator(".animate-spin")).not.toBeVisible({
timeout: 10000, timeout: 10000,
@@ -201,12 +222,13 @@ test.describe("Audit Logs Management", () => {
// Select "Failure" status // Select "Failure" status
await page.getByTestId("audit-filter-status").selectOption("failure"); await page.getByTestId("audit-filter-status").selectOption("failure");
// ID % 5 === 0 are status "failure" (0, 5, 10, 15) // Total 8 failures in 40 logs
await expect(page.locator("tbody tr")).toHaveCount(4, { timeout: 15000 }); await expect(page.locator("tbody tr")).toHaveCount(8, { timeout: 15000 });
// Select "Success" status // Select "Success" status
await page.getByTestId("audit-filter-status").selectOption("success"); 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 });
}); });
}); });

View File

@@ -61,28 +61,42 @@ test.describe("Tenants Management", () => {
const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea"; const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea";
await page.route("**/api/v1/admin/tenants**", async (route) => { await page.route("**/api/v1/admin/tenants**", async (route) => {
if (route.request().method() === "GET") { if (route.request().method() !== "GET") {
await route.fulfill({ return route.continue();
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();
} }
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"); await page.goto("/tenants");
@@ -115,40 +129,55 @@ test.describe("Tenants Management", () => {
return route.continue(); 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({ await route.fulfill({
json: { json: {
items: [ items: filtered,
{ total: filtered.length,
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,
limit: 500, limit: 500,
offset: 0, offset: 0,
}, },
@@ -162,7 +191,6 @@ test.describe("Tenants Management", () => {
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i) .getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
.fill("team-1"); .fill("team-1");
await expect(page.locator("table")).toContainText("Platform"); await expect(page.locator("table")).toContainText("Platform");
await expect(page.locator("table")).toContainText("Acme");
await page await page
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i) .getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
@@ -188,40 +216,55 @@ test.describe("Tenants Management", () => {
return route.continue(); 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({ await route.fulfill({
json: { json: {
items: [ items: filtered,
{ total: filtered.length,
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,
limit: 500, limit: 500,
offset: 0, offset: 0,
}, },
@@ -239,10 +282,14 @@ test.describe("Tenants Management", () => {
); );
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("team-1"); 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 expect(page.locator("table")).not.toContainText("Acme");
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill(""); await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("");
await page.keyboard.press("Enter");
await page await page
.locator("tbody tr") .locator("tbody tr")
.filter({ hasText: "Acme" }) .filter({ hasText: "Acme" })
@@ -266,24 +313,37 @@ test.describe("Tenants Management", () => {
} }
const url = new URL(route.request().url()); const url = new URL(route.request().url());
const cursor = url.searchParams.get("cursor"); const cursor = url.searchParams.get("cursor");
const search = url.searchParams.get("search")?.toLowerCase();
_requestCount += 1; _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) { if (!cursor) {
return route.fulfill({ return route.fulfill({
json: { json: {
items: Array.from({ length: 500 }, (_, index) => ({ items: filtered.slice(0, 500),
id: `tenant-${String(index + 1).padStart(3, "0")}`, total: filtered.length,
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,
limit: 500, limit: 500,
offset: 0, offset: 0,
nextCursor: "next-page", nextCursor: filtered.length > 500 ? "next-page" : undefined,
}, },
headers: { "Access-Control-Allow-Origin": "*" }, headers: { "Access-Control-Allow-Origin": "*" },
}); });
@@ -291,18 +351,8 @@ test.describe("Tenants Management", () => {
return route.fulfill({ return route.fulfill({
json: { json: {
items: [ items: filtered.slice(500),
{ total: filtered.length,
id: "tenant-501",
name: "Tenant 501",
slug: "tenant-501",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 501,
limit: 500, limit: 500,
offset: 0, offset: 0,
}, },
@@ -322,9 +372,10 @@ test.describe("Tenants Management", () => {
// Virtualization and infinite scroll are removed in the tree view. // 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. // 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. // 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 await expect
.poll(async () => page.locator("tbody tr").count()) .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 // Skip the scroll to load more check because the infinite scroll handler was removed
// expect(requestCount).toBe(2); // expect(requestCount).toBe(2);
@@ -372,54 +423,68 @@ test.describe("Tenants Management", () => {
return; 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({ await route.fulfill({
json: { json: {
items: [ items: filtered,
{ total: filtered.length,
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,
limit: 1000, limit: 1000,
offset: 0, offset: 0,
}, },
@@ -493,9 +558,30 @@ test.describe("Tenants Management", () => {
]; ];
await page.route("**/api/v1/admin/tenants**", async (route) => { 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": "*" }; 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({ return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 }, json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers, headers,
}); });
}); });
@@ -569,12 +655,23 @@ test.describe("Tenants Management", () => {
await page.route("**/api/v1/admin/tenants**", async (route) => { await page.route("**/api/v1/admin/tenants**", async (route) => {
const method = route.request().method(); 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": "*" }; const headers = { "Access-Control-Allow-Origin": "*" };
if (method === "GET") { 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({ return route.fulfill({
json: { json: {
items: tenants, items: filtered,
total: tenants.length, total: filtered.length,
limit: 1000, limit: 1000,
offset: 0, offset: 0,
}, },
@@ -705,21 +802,33 @@ test.describe("Tenants Management", () => {
} }
if (method === "GET") { 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({ return route.fulfill({
json: { json: {
items: [ items: filtered,
{ total: filtered.length,
id: "tenant-alpha-id",
name: "Tenant Alpha",
slug: "tenant-alpha",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 1,
limit: 1000, limit: 1000,
offset: 0, offset: 0,
}, },
@@ -846,21 +955,33 @@ test.describe("Tenants Management", () => {
} }
if (method === "GET") { 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({ return route.fulfill({
json: { json: {
items: [ items: filtered,
{ total: filtered.length,
id: "staging-existing-id",
name: "Existing Parent",
slug: "parent-local",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 1,
limit: 1000, limit: 1000,
offset: 0, offset: 0,
}, },
@@ -907,7 +1028,7 @@ test.describe("Tenants Management", () => {
(button as HTMLButtonElement).click(); (button as HTMLButtonElement).click();
}); });
await expect(page.getByTestId("tenant-import-result")).toContainText( await expect(page.getByTestId("tenant-import-summary")).toContainText(
/생성 2|Created 2/i, /생성 2|Created 2/i,
); );
@@ -979,8 +1100,24 @@ test.describe("Tenants Management", () => {
headers: { "Access-Control-Allow-Origin": "*" }, headers: { "Access-Control-Allow-Origin": "*" },
}); });
} else { } 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({ 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": "*" }, headers: { "Access-Control-Allow-Origin": "*" },
}); });
} }
@@ -1051,8 +1188,24 @@ test.describe("Tenants Management", () => {
if (url.includes(`/admin/tenants/${parentId}`)) { if (url.includes(`/admin/tenants/${parentId}`)) {
return route.fulfill({ json: mockTenants[0], headers }); 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({ return route.fulfill({
json: { items: mockTenants, total: 2, limit: 1000, offset: 0 }, json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers, headers,
}); });
}); });
@@ -1093,8 +1246,25 @@ test.describe("Tenants Management", () => {
if (url.includes(`/admin/tenants/${tenantUuid}`)) { if (url.includes(`/admin/tenants/${tenantUuid}`)) {
return route.fulfill({ json: tenant, headers }); 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({ return route.fulfill({
json: { items: [tenant], total: 1, limit: 1000, offset: 0 }, json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers, headers,
}); });
}); });
@@ -1152,8 +1322,24 @@ test.describe("Tenants Management", () => {
if (url.includes("/admin/tenants/team-1")) { if (url.includes("/admin/tenants/team-1")) {
return route.fulfill({ json: tenants[2], headers }); 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({ return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 }, json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers, headers,
}); });
}); });

View File

@@ -90,7 +90,7 @@ test.describe("Tenants CSV live E2E", () => {
await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인"); await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인");
await page.getByTestId("tenant-import-confirm-btn").click(); 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, /생성 1|Created 1/i,
); );

View File

@@ -235,7 +235,7 @@ func (h *AdminHandler) countTenants(ctx context.Context) int64 {
if h == nil || h.TenantRepo == nil { if h == nil || h.TenantRepo == nil {
return 0 return 0
} }
_, total, err := h.TenantRepo.List(ctx, 1, 0, "") _, total, err := h.TenantRepo.List(ctx, 1, 0, "", "")
if err != nil { if err != nil {
return 0 return 0
} }

View File

@@ -629,7 +629,7 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
} }
// 3. List and Filter Tenants // 3. List and Filter Tenants
tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "") tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "", "")
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch tenants") return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch tenants")
} }

View File

@@ -108,8 +108,8 @@ func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) (
return nil, nil return nil, nil
} }
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { 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 return nil, 0, "", nil
} }
func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { 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 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 return nil, 0, nil
} }
@@ -236,6 +236,18 @@ func (m *AsyncMockTenantService) ListTenantAdmins(ctx context.Context, tenantID
return nil, nil 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 { type AsyncMockKetoService struct {
mock.Mock mock.Mock
} }
@@ -357,16 +369,3 @@ func TestSignup_AsyncDB_Isolation(t *testing.T) {
mockUserRepo.AssertExpectations(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)
}

View File

@@ -91,7 +91,7 @@ func (m *MockTenantServiceForConsent) GetTenantByDomain(ctx context.Context, dom
return nil, nil 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 return nil, 0, nil
} }

View File

@@ -189,8 +189,8 @@ func (r *passwordLoginUserRepo) ListByTenant(ctx context.Context, tenantID strin
return nil, nil return nil, nil
} }
func (r *passwordLoginUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { 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 return nil, 0, "", nil
} }
func (r *passwordLoginUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { func (r *passwordLoginUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {

View File

@@ -3571,7 +3571,7 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
} }
if role == domain.RoleSuperAdmin { if role == domain.RoleSuperAdmin {
tenants, _, err := h.TenantSvc.ListTenants(c.Context(), 100, 0, "") tenants, _, err := h.TenantSvc.ListTenants(c.Context(), 100, 0, "", "")
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to list tenants") return errorJSON(c, fiber.StatusInternalServerError, "failed to list tenants")
} }

View File

@@ -113,7 +113,7 @@ func (h *UserHandler) resolveHanmacEmailScope(ctx context.Context) (*hanmacEmail
return nil, nil return nil, nil
} }
tenants, _, err := h.TenantService.ListTenants(ctx, 10000, 0, "") tenants, _, err := h.TenantService.ListTenants(ctx, 10000, 0, "", "")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -269,7 +269,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
if role != domain.RoleSuperAdmin { if role != domain.RoleSuperAdmin {
// Not a super admin: Only return the entire tree(s) of the tenants they belong to // Not a super admin: Only return the entire tree(s) of the tenants they belong to
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "") allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
@@ -343,13 +343,13 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
} else { } else {
// Super Admin case // Super Admin case
if cursorRaw != "" && h.DB != nil { if cursorRaw != "" && h.DB != nil {
tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw) tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw, "")
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor") return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
} }
offset = 0 offset = 0
} else { } else {
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId) tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, "")
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
@@ -382,7 +382,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}) })
} }
func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, parentID string, cursorRaw string) ([]domain.Tenant, int64, string, error) { func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, parentID string, cursorRaw string, search string) ([]domain.Tenant, int64, string, error) {
cursor, err := pagination.Decode(cursorRaw) cursor, err := pagination.Decode(cursorRaw)
if err != nil { if err != nil {
return nil, 0, "", err return nil, 0, "", err
@@ -395,6 +395,12 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
pageQuery = pageQuery.Where("parent_id = ?", parentID) pageQuery = pageQuery.Where("parent_id = ?", parentID)
} }
if search != "" {
searchTerm := "%" + strings.ToLower(search) + "%"
countQuery = countQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
pageQuery = pageQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
}
var total int64 var total int64
if err := countQuery.Count(&total).Error; err != nil { if err := countQuery.Count(&total).Error; err != nil {
return nil, 0, "", err return nil, 0, "", err
@@ -422,7 +428,7 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error { func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
parentID := strings.TrimSpace(c.Query("parentId")) parentID := strings.TrimSpace(c.Query("parentId"))
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "") allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
@@ -566,7 +572,7 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
tenantIDBySlug := make(map[string]string) tenantIDBySlug := make(map[string]string)
if h.Service != nil { if h.Service != nil {
if tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, ""); err == nil { if tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", ""); err == nil {
for _, tenant := range tenants { for _, tenant := range tenants {
tenantIDBySlug[strings.ToLower(tenant.Slug)] = tenant.ID tenantIDBySlug[strings.ToLower(tenant.Slug)] = tenant.ID
} }
@@ -2336,7 +2342,7 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusServiceUnavailable, "tenant service is not configured") return errorJSON(c, fiber.StatusServiceUnavailable, "tenant service is not configured")
} }
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "") allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
@@ -2410,7 +2416,7 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te
if err != nil { if err != nil {
return nil, err return nil, err
} }
usersByAppointment, _, err := h.UserRepo.List(ctx, 0, 10000, "", "") usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", []string{}, "")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -2741,7 +2747,7 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusUnauthorized, err.Error()) return errorJSON(c, fiber.StatusUnauthorized, err.Error())
} }
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "") allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }

View File

@@ -72,8 +72,8 @@ func (m *MockTenantService) GetTenant(ctx context.Context, id string) (*domain.T
return args.Get(0).(*domain.Tenant), args.Error(1) 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) { 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) args := m.Called(ctx, limit, offset, parentID, search)
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2) 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 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 { for _, call := range m.ExpectedCalls {
if call.Method == "List" { if call.Method == "List" {
args := m.Called(ctx, offset, limit, search, tenantSlug) args := m.Called(ctx, offset, limit, search, tenantIDs, cursor)
return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2) 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) { func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
@@ -274,7 +274,7 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
tenants := []domain.Tenant{ tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, {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("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants). mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once() Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
@@ -313,7 +313,7 @@ func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *te
tenants := []domain.Tenant{ tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, {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() mockProjection.On("IsReady", mock.Anything).Return(false, nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) 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 // 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("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants). mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once() 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)}, {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("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, 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) 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("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool { mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team") return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
@@ -512,7 +512,7 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
}) })
app.Get("/tenants", h.ListTenants) 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("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool { mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child") 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("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("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) req := httptest.NewRequest(http.MethodGet, "/org-context", nil)
resp, err := app.Test(req) 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}, {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("FindByTenantIDs", mock.Anything, []string{"company-hanmac"}).Return(users, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, 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: "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}, {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("FindByTenantIDs", mock.Anything, []string{"company-hanmac", "dept-platform"}).Return([]domain.User{}, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac", "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{ tenants := []domain.Tenant{
{ID: "t1", Name: "Tenant A", Slug: "slug-a"}, {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() mockProjection.On("IsReady", mock.Anything).Return(false, errors.New("projection state query failed")).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) 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"}, {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("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants). mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once() 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) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
resp, _ := app.Test(req) 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) req := httptest.NewRequest("GET", "/tenants/export?includeIds=false", nil)
resp, _ := app.Test(req) 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}, {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) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
resp, _ := app.Test(req) 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) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true&parentId="+parentID, nil)
resp, _ := app.Test(req) resp, _ := app.Test(req)
@@ -1146,7 +1146,7 @@ func TestTenantHandler_ExportTenantsCSV_HidesPrivateSubtreeForUnauthorizedUser(t
}) })
app.Get("/tenants/export", h.ExportTenantsCSV) 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) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
resp, _ := app.Test(req) resp, _ := app.Test(req)
@@ -1175,7 +1175,7 @@ func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.NoError(t, writer.Close()) 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( mockSvc.On(
"RegisterTenant", "RegisterTenant",
mock.Anything, mock.Anything,
@@ -1219,7 +1219,7 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
assert.NoError(t, writer.Close()) assert.NoError(t, writer.Close())
parentID := "parent-id" 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( mockSvc.On(
"RegisterTenant", "RegisterTenant",
mock.Anything, mock.Anything,
@@ -1276,7 +1276,7 @@ func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(
assert.NoError(t, err) assert.NoError(t, err)
assert.NoError(t, writer.Close()) 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( mockSvc.On(
"RegisterTenant", "RegisterTenant",
mock.Anything, mock.Anything,

View File

@@ -486,7 +486,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
// Expand manageableSlugs to the entire tenant tree (root + all descendants) // Expand manageableSlugs to the entire tenant tree (root + all descendants)
if h.TenantService != nil && len(baseTenantIDs) > 0 { if h.TenantService != nil && len(baseTenantIDs) > 0 {
allTenants, _, err := h.TenantService.ListTenants(c.Context(), 10000, 0, "") allTenants, _, err := h.TenantService.ListTenants(c.Context(), 10000, 0, "", "")
if err == nil { if err == nil {
parentMap := make(map[string]string) parentMap := make(map[string]string)
for _, t := range allTenants { for _, t := range allTenants {
@@ -1614,7 +1614,14 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
} }
// 1. Fetch Users using Repo for efficiency // 1. Fetch Users using Repo for efficiency
users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug) 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 { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export") return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
} }

View File

@@ -291,8 +291,8 @@ func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, us
return args.Get(0).([]domain.Tenant), args.Error(1) 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) { 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) args := m.Called(ctx, limit, offset, parentID, search)
if args.Get(0) == nil { if args.Get(0) == nil {
return nil, args.Get(1).(int64), args.Error(2) 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) createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
tenantID := "tenant-uuid" tenantID := "tenant-uuid"
mockRepo.On("List", mock.Anything, 0, 10000, "", "test-tenant"). mockRepo.On("List", mock.Anything, 0, 10000, "", []string(nil), "").
Return([]domain.User{ Return([]domain.User{
{ {
ID: "u-1", ID: "u-1",
@@ -349,7 +349,7 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
JobTitle: "플랫폼 운영", JobTitle: "플랫폼 운영",
CreatedAt: createdAt, CreatedAt: createdAt,
}, },
}, int64(1), nil).Maybe() }, int64(1), "", nil).Maybe()
req := httptest.NewRequest("GET", "/users/export?tenantSlug=test-tenant&includeIds=true", nil) req := httptest.NewRequest("GET", "/users/export?tenantSlug=test-tenant&includeIds=true", nil)
resp, err := app.Test(req) 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) createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
tenantID := "tenant-uuid" tenantID := "tenant-uuid"
mockRepo.On("List", mock.Anything, 0, 10000, "", ""). mockRepo.On("List", mock.Anything, 0, 10000, "", mock.Anything, "").
Return([]domain.User{ Return([]domain.User{
{ {
ID: "user-uuid", ID: "user-uuid",
@@ -395,7 +395,7 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
JobTitle: "플랫폼 운영", JobTitle: "플랫폼 운영",
CreatedAt: createdAt, CreatedAt: createdAt,
}, },
}, int64(1), nil).Maybe() }, int64(1), "", nil).Maybe()
req := httptest.NewRequest("GET", "/users/export?includeIds=false", nil) req := httptest.NewRequest("GET", "/users/export?includeIds=false", nil)
resp, err := app.Test(req) resp, err := app.Test(req)
@@ -1049,7 +1049,7 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
Slug: "hanmac", Slug: "hanmac",
ParentID: &rootID, ParentID: &rootID,
}, nil).Maybe() }, 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{ mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID, "external-id"}).Return([]domain.User{
{Email: "cyhan@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID}, {Email: "cyhan@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
{Email: "cyhan1@samaneng.com", 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("GetTenantBySlug", mock.Anything, "h-company").Return(&hTenants[1], nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, hCompanyID).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 { mockRepo.On("FindByTenantIDs", mock.Anything, mock.MatchedBy(func(ids []string) bool {
return slices.Contains(ids, hRootID) || slices.Contains(ids, hCompanyID) return slices.Contains(ids, hRootID) || slices.Contains(ids, hCompanyID)
@@ -1188,7 +1188,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
ID: companyID, ID: companyID,
Slug: "hanmac", Slug: "hanmac",
}, nil).Maybe() }, 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{ mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID}).Return([]domain.User{
{Email: "han@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID}, {Email: "han@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
}, nil).Maybe() }, nil).Maybe()
@@ -2146,7 +2146,7 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
ID: tenantID, ID: tenantID,
Slug: "saman", Slug: "saman",
}, nil) }, 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("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe() mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe()
mockKratos.On("GetIdentity", mock.Anything, "some-id").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "some-id").Return(&service.KratosIdentity{

View File

@@ -45,7 +45,7 @@ func (m *MockTenantServiceForMiddleware) GetTenant(ctx context.Context, id strin
return nil, nil 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 return nil, 0, nil
} }
@@ -53,6 +53,10 @@ func (m *MockTenantServiceForMiddleware) ListManageableTenants(ctx context.Conte
return nil, nil 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) { func (m *MockTenantServiceForMiddleware) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
return false, nil 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 { func (m *MockTenantServiceForMiddleware) ApproveTenant(ctx context.Context, id string) error {
return nil 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) SetKetoService(keto service.KetoService) {}
func (m *MockTenantServiceForMiddleware) DeleteTenantsBulk(ctx context.Context, ids []string) error {
return nil
}
func TestTenantContextMiddleware(t *testing.T) { func TestTenantContextMiddleware(t *testing.T) {
os.Setenv("USERFRONT_URL", "https://sso.hmac.kr") os.Setenv("USERFRONT_URL", "https://sso.hmac.kr")
defer os.Unsetenv("USERFRONT_URL") defer os.Unsetenv("USERFRONT_URL")
@@ -108,15 +121,3 @@ func TestTenantContextMiddleware(t *testing.T) {
mockSvc.AssertExpectations(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
}

View File

@@ -20,7 +20,7 @@ type TenantRepository interface {
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error)
AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error
List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) List(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error)
ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error)
DeleteBulk(ctx context.Context, ids []string) error DeleteBulk(ctx context.Context, ids []string) error
} }
@@ -124,7 +124,7 @@ func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domai
return r.db.WithContext(ctx).Create(&td).Error return r.db.WithContext(ctx).Create(&td).Error
} }
func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
var tenants []domain.Tenant var tenants []domain.Tenant
var total int64 var total int64
db := r.db.WithContext(ctx).Model(&domain.Tenant{}) db := r.db.WithContext(ctx).Model(&domain.Tenant{})
@@ -133,6 +133,11 @@ func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID
db = db.Where("parent_id = ?", parentID) db = db.Where("parent_id = ?", parentID)
} }
if search != "" {
searchTerm := "%" + strings.ToLower(search) + "%"
db = db.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
}
if err := db.Count(&total).Error; err != nil { if err := db.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }

View File

@@ -2,6 +2,7 @@ package repository
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/pagination"
"context" "context"
"fmt" "fmt"
"strings" "strings"
@@ -17,7 +18,7 @@ type UserRepository interface {
FindByID(ctx context.Context, id string) (*domain.User, error) FindByID(ctx context.Context, id string) (*domain.User, error)
FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error)
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error)
CountByTenant(ctx context.Context, tenantID string) (int64, error) CountByTenant(ctx context.Context, tenantID string) (int64, error)
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error)
@@ -215,14 +216,13 @@ func lowerStrings(arr []string) []string {
return res return res
} }
func (r *userRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { func (r *userRepository) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursorRaw string) ([]domain.User, int64, string, error) {
var users []domain.User var users []domain.User
var total int64 var total int64
db := r.db.WithContext(ctx).Model(&domain.User{}) db := r.db.WithContext(ctx).Model(&domain.User{})
if tenantSlug != "" { if len(tenantIDs) > 0 {
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id"). db = db.Where("tenant_id IN ?", tenantIDs)
Where("tenants.slug = ?", tenantSlug)
} }
if search != "" { 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 { if err := db.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, "", err
} }
if err := db.Offset(offset).Limit(limit).Preload("Tenant").Find(&users).Error; err != nil { if cursorRaw != "" {
return nil, 0, err cursor, err := pagination.Decode(cursorRaw)
if err != nil {
return nil, 0, "", err
}
db = pagination.ApplyCreatedAtIDCursor(db, cursor, "created_at", "id")
} else {
db = db.Offset(offset)
} }
return users, total, nil if err := db.Order("created_at desc, id desc").Limit(limit + 1).Preload("Tenant").Find(&users).Error; err != nil {
return nil, 0, "", err
}
var items []domain.User
var nextCursor string
if len(users) > limit {
items = users[:limit]
last := items[limit-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
} else {
items = users
}
return items, total, nextCursor, nil
} }
func (r *userRepository) Delete(ctx context.Context, id string) error { func (r *userRepository) Delete(ctx context.Context, id string) error {

View File

@@ -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: "alice@test.com", Name: "Alice", Role: "user"})
_ = repo.Create(ctx, &domain.User{Email: "bob@test.com", Name: "Bob", 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.NoError(t, err)
assert.True(t, total >= 1) assert.True(t, total >= 1)
assert.Equal(t, "Alice", users[0].Name) assert.Equal(t, "Alice", users[0].Name)

View File

@@ -18,7 +18,7 @@ type TenantService interface {
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
GetTenant(ctx context.Context, id string) (*domain.Tenant, error) GetTenant(ctx context.Context, id string) (*domain.Tenant, error)
ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error)
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
IsDomainAllowed(ctx context.Context, domainName string) (bool, error) IsDomainAllowed(ctx context.Context, domainName string) (bool, error)
@@ -314,8 +314,8 @@ func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*doma
return s.repo.FindBySlug(ctx, slug) return s.repo.FindBySlug(ctx, slug)
} }
func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
return s.repo.List(ctx, limit, offset, parentID) return s.repo.List(ctx, limit, offset, parentID, search)
} }
func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) { func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {

View File

@@ -60,9 +60,9 @@ func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, d
return m.Called(ctx, tenantID, domainName, verified).Error(0) 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) { 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) args := m.Called(ctx, limit, offset, parentID, search)
return args.Get(0).([]domain.Tenant), int64(args.Int(1)), args.Error(2) 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) { 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 return nil, nil
} }
func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { 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 return nil, 0, "", nil
} }
func (m *MockUserRepoForTenant) CountByTenant(ctx context.Context, tenantID string) (int64, error) { func (m *MockUserRepoForTenant) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
@@ -335,9 +335,9 @@ func TestTenantService_ListTenants(t *testing.T) {
ctx := context.Background() ctx := context.Background()
tenants := []domain.Tenant{{ID: "t1", Name: "Tenant 1"}} 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.NoError(t, err)
assert.Equal(t, int64(1), total) assert.Equal(t, int64(1), total)
assert.Equal(t, tenants, result) assert.Equal(t, tenants, result)

View File

@@ -84,8 +84,8 @@ func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string)
return nil, nil return nil, nil
} }
func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { 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 return nil, 0, "", nil
} }
func (m *MockUserRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) { 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 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 return nil, 0, nil
} }

View File

@@ -938,7 +938,7 @@ func (s *worksmobileSyncService) hanmacRoot(ctx context.Context, tenantID string
} }
func (s *worksmobileSyncService) hanmacSubtree(ctx context.Context, rootID string) ([]domain.Tenant, error) { func (s *worksmobileSyncService) hanmacSubtree(ctx context.Context, rootID string) ([]domain.Tenant, error) {
all, _, err := s.tenantService.ListTenants(ctx, 10000, 0, "") all, _, err := s.tenantService.ListTenants(ctx, 10000, 0, "", "")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -1978,7 +1978,7 @@ func (f *fakeWorksmobileTenantService) GetTenant(ctx context.Context, id string)
return &tenant, nil 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 return f.list, int64(len(f.list)), nil
} }
@@ -2033,8 +2033,8 @@ func (f *fakeWorksmobileUserRepo) ListByTenant(ctx context.Context, tenantID str
return nil, nil return nil, nil
} }
func (f *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { 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 return nil, 0, "", nil
} }
func (f *fakeWorksmobileUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { func (f *fakeWorksmobileUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {

View File

@@ -1,9 +1,7 @@
import { ChevronDown, ChevronUp, Copy } from "lucide-react"; import { ChevronDown, ChevronUp, Copy } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { import { getCommonBadgeClasses } from "../../../ui/badge";
type CommonBadgeVariant, import type { CommonBadgeVariant } from "../../../ui/badge";
getCommonBadgeClasses,
} from "../../../ui/badge";
import { getCommonButtonClasses } from "../../../ui/button"; import { getCommonButtonClasses } from "../../../ui/button";
import { import {
commonStickyTableHeaderClass, commonStickyTableHeaderClass,
@@ -48,7 +46,20 @@ function cx(...classNames: Array<string | false | null | undefined>) {
} }
function statusVariant(status: string): CommonBadgeVariant { 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({ export function AuditLogTable({
@@ -73,356 +84,324 @@ export function AuditLogTable({
return ( return (
<div className={cx(commonTableShellClass, className)}> <div className={cx(commonTableShellClass, className)}>
<div className={commonTableViewportClass}> <div className={cx(commonTableViewportClass, "flex-1")}>
<div className={commonTableWrapperClass}> <div className={commonTableWrapperClass}>
<table className={cx(commonTableClass, "table-fixed")}> <Table className={commonTableClass}>
<thead <TableHeader className={commonTableHeaderClass}>
className={cx( <TableRow className={cx(commonTableRowClass, commonStickyTableHeaderClass)}>
commonTableHeaderClass, <TableHead className={cx(commonTableHeadClass, "w-[190px]")}>
commonStickyTableHeaderClass,
)}
>
<tr className={commonTableRowClass}>
<th className={cx(commonTableHeadClass, "w-[190px]")}>
{t("ui.common.audit.table.time", "Time")} {t("ui.common.audit.table.time", "Time")}
</th> </TableHead>
<th className={cx(commonTableHeadClass, "w-[180px]")}> <TableHead className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.user_id", "User ID")} {t("ui.common.audit.table.user_id", "User ID")}
</th> </TableHead>
<th className={cx(commonTableHeadClass, "w-[180px]")}> <TableHead className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.action", "Action")} {t("ui.common.audit.table.action", "Action")}
</th> </TableHead>
<th className={cx(commonTableHeadClass, "w-[260px]")}> <TableHead className={cx(commonTableHeadClass, "w-[260px]")}>
{t("ui.common.audit.table.client_id", "Client ID")} {t("ui.common.audit.table.client_id", "Client ID")}
</th> </TableHead>
<th className={cx(commonTableHeadClass, "w-[120px]")}> <TableHead className={cx(commonTableHeadClass, "w-[120px]")}>
{t("ui.common.audit.table.status", "Status")} {t("ui.common.audit.table.status", "Status")}
</th> </TableHead>
<th className={cx(commonTableHeadClass, "w-[80px]")} /> <TableHead className={cx(commonTableHeadClass, "w-[80px]")} />
</tr> </TableRow>
</thead> </TableHeader>
<tbody className={commonTableBodyClass}> <TableBody className={commonTableBodyClass}>
{loading && logs.length === 0 ? ( {logs.map((log, index) => {
<tr className={commonTableRowClass}> const details = parseAuditDetails(log.details);
<td const actorLabel = resolveAuditActor(log, details);
colSpan={6} const actionLabel = resolveAuditAction(log, details);
className={cx( const targetLabel = resolveAuditTarget(details);
commonTableCellClass, const rowKey = `${log.event_id}-${log.timestamp}-${index}`;
"py-8 text-center text-muted-foreground", 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>
)} )}
> </React.Fragment>
{t("msg.common.audit.loading", "Loading audit logs...")} );
</td> })}
</tr> {logs.length === 0 && !loading && (
) : logs.length === 0 ? ( <TableRow className={commonTableRowClass}>
<tr className={commonTableRowClass}> <TableCell
<td
colSpan={6} colSpan={6}
className={cx( className={cx(
commonTableCellClass, commonTableCellClass,
"text-center text-muted-foreground", "text-center text-muted-foreground py-8",
)} )}
> >
{t("msg.common.audit.empty", "No audit logs found.")} {t("msg.common.audit.empty", "No audit logs found.")}
</td> </TableCell>
</tr> </TableRow>
) : (
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>
);
})
)} )}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</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 ? ( {hasNextPage ? (
<button <div className="flex flex-col items-center gap-2">
type="button" {isFetchingNextPage && (
className={getCommonButtonClasses({ variant: "outline" })} <span className="text-xs text-muted-foreground animate-pulse">
onClick={onLoadMore} {t("msg.common.loading", "Loading more...")}
disabled={isFetchingNextPage} </span>
> )}
{isFetchingNextPage <button
? t("msg.common.loading", "Loading...") type="button"
: t("ui.common.audit.load_more", "Load more")} className={getCommonButtonClasses({
</button> 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"> <span className="text-xs text-muted-foreground">
{t("msg.common.audit.end", "End of audit feed")} {t("msg.common.audit.end", "End of audit feed")}
</span> </span>
)} ) : null}
</div> </div>
</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>;
}

View File

@@ -1,4 +1,5 @@
[msg.common] [msg.common]
loading_more = "Loading more logs..."
copied = "Copied." copied = "Copied."
error = "Error" error = "Error"
forbidden = "Access denied." forbidden = "Access denied."
@@ -141,6 +142,7 @@ target = "Client ID · {{value}}"
title = "Audit registry" title = "Audit registry"
[ui.common.audit.table] [ui.common.audit.table]
no_logs = "No logs to display."
action = "Action" action = "Action"
actor = "User ID" actor = "User ID"
client_id = "Client ID" client_id = "Client ID"

View File

@@ -1,4 +1,5 @@
[msg.common] [msg.common]
loading_more = "추가 로그를 불러오는 중..."
copied = "복사되었습니다." copied = "복사되었습니다."
error = "오류가 발생했습니다." error = "오류가 발생했습니다."
forbidden = "접근 권한이 없습니다." forbidden = "접근 권한이 없습니다."
@@ -141,7 +142,8 @@ target = "클라이언트 ID · {{value}}"
title = "감사 로그 레지스트리" title = "감사 로그 레지스트리"
[ui.common.audit.table] [ui.common.audit.table]
action = "액션" no_logs = "표시할 로그가 없습니다."
action = "작업"
actor = "사용자 ID" actor = "사용자 ID"
client_id = "클라이언트 ID" client_id = "클라이언트 ID"
user_id = "사용자 ID" user_id = "사용자 ID"

View File

@@ -1,4 +1,5 @@
[msg.common] [msg.common]
loading_more = ""
copied = "" copied = ""
error = "" error = ""
forbidden = "" forbidden = ""
@@ -141,6 +142,7 @@ target = ""
title = "" title = ""
[ui.common.audit.table] [ui.common.audit.table]
no_logs = ""
action = "" action = ""
actor = "" actor = ""
client_id = "" client_id = ""

View File

@@ -1,6 +0,0 @@
packages:
- "../adminfront"
- "../devfront"
- "../orgfront"
allowBuilds:
'@biomejs/biome': false

12
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,7 @@
packages:
- "adminfront"
- "devfront"
- "orgfront"
- "common"
allowBuilds:
'@biomejs/biome': false

View File

@@ -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,
1 tenant_id name type parent_tenant_id parent_tenant_slug slug memo email_domain visibility org_unit_type
2 cd1ebc22-4b5e-4242-bb87-eb88db32286c 업무 ORGANIZATION a16f49c4-6828-4fde-a164-43099c4560c4 planning operations public
3 0e13d342-d3cf-46b5-8096-4e7883b79b01 서산시자원회수시설 ORGANIZATION d7379c32-0b79-482e-9c4d-d83ad425c3fc halla-operation-sites ops-seosan-recovery public
4 41118f16-7f5c-4209-bd83-183822bc00ed 안성제4차산업단지폐수처리 ORGANIZATION d7379c32-0b79-482e-9c4d-d83ad425c3fc halla-operation-sites ops-anseong-wwtp public
5 ad6f20e9-7928-4322-932c-7c3cb2a313cb 온산바이오 ORGANIZATION d7379c32-0b79-482e-9c4d-d83ad425c3fc halla-operation-sites ops-onsan-bio public
6 03d8cf87-4b40-4784-a6cf-fcc11371f40f 울산민자소각 ORGANIZATION d7379c32-0b79-482e-9c4d-d83ad425c3fc halla-operation-sites ops-ulsan-incineration public
7 d7379c32-0b79-482e-9c4d-d83ad425c3fc 운영사업소 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f halla halla-operation-sites public
8 551991d8-1f74-4ad0-a0c5-bc5a11968398 부산항 신항 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-busan-new-port public
9 e77b4bf1-a126-4b4e-a18a-8d905e958873 수도권광역급행철도B 제4공구 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-gtx-b-4 public
10 3b5151f6-1a01-484a-bfb7-2e60d2aa0b49 경산시 국도대체 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-gyeongsan-road public
11 44c6e400-daf0-42a2-90df-945921788f99 인덕원 동탄 복선전철 제7공구 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-indeokwon-dongtan-7 public
12 2bc22118-9a70-4d5b-8a3f-cf65432a8bbb 인덕원 동탄 복선전철 제3공구 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-indeokwon-dongtan-3 public
13 1134fa6a-9b0b-4702-a1e7-39948c8c451a 제주공공하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-jeju-sewage public
14 36aec47e-90fc-42cb-8229-3e20423d0424 성남시생활폐기물처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-seongnam-waste public
15 5b0b806c-f189-46ea-8771-ebdafcf45afa 광탄공공하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-gwangtan-sewage public
16 d2323d9a-c959-48c0-831b-4bb71e48b2e5 인천국제공항 화물 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-incheon-air-cargo public
17 32a83ce1-03f1-4daa-b60f-8e64fad83ac6 수도권매립지 제2매립장 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-sudokwon-landfill-2 public
18 e39ba0af-c3e9-429b-91ec-0a3453a5692e 온산하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-onsan-sewage public
19 25f51047-3108-4ff3-98f4-b7f5bce334c5 신천공공하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-sincheon-sewage public
20 f0ae9e81-65a5-4bab-a98d-79349bbaa501 장량공공하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-jangnyang-sewage public
21 b662cfdb-aae3-48b7-b1d3-2ef050dce027 아포공공하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-apo-sewage public
22 76808046-cd35-4813-b240-c323291fa2d8 광주공공폐수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-gwangju-wastewater public
23 bda54a49-8282-4f91-9773-645e6a1f2a3b 도척 실촌간 도로 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-docheok-silchon-road public
24 02a9e89b-a0a0-4202-adde-870194c35351 여주부평천 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-yeoju-bupyeongcheon public
25 9b1fb915-f50b-49b9-a9f9-a9089a825b1f 옥정 공공하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-okjeong-sewage public
26 29d9fa54-6d8d-49f5-98ca-2c720aced55e 부천시 굴포천 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a halla-construction-sites site-bucheon-gulpocheon public
27 99199302-f04f-47ad-9f9f-2afe2db9826a 시공현장 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f halla halla-construction-sites public
28 2dbbdbf8-26b2-461e-bbd7-1fe09b4e36ee 안전관리팀 ORGANIZATION 4b81d408-d81c-43c7-9f87-b6c806db4d7b halla-safety-hq halla-safety-team public
29 4b81d408-d81c-43c7-9f87-b6c806db4d7b 안전관리본부 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f halla halla-safety-hq public
30 69aa9667-4997-41f8-9898-e470cfc778e5 기술영업팀 ORGANIZATION 1512e429-fb95-4c0d-9409-f0a3286061f2 halla-tech-sales-hq halla-tech-sales-team public
31 1512e429-fb95-4c0d-9409-f0a3286061f2 기술영업본부 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f halla halla-tech-sales-hq public
32 6f9e45f7-63fb-464e-b47c-915fa25f782f 설계팀 ORGANIZATION 2ea01ea1-6d09-4997-ba67-73cbae7aa7dd halla-env-plant-hq halla-env-plant-design public
33 69d6d246-b281-4da7-be83-fede8e3dc5bd 사업관리팀 ORGANIZATION 2ea01ea1-6d09-4997-ba67-73cbae7aa7dd halla-env-plant-hq halla-env-project-mgmt public
34 2ea01ea1-6d09-4997-ba67-73cbae7aa7dd 환경플랜트사업본부 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f halla halla-env-plant-hq public
35 45519f6d-ba67-42e2-9b54-f80f1a950a8c 사업관리팀 ORGANIZATION 1d5da961-7f32-4032-a86c-26e8edbcb8ee halla-infra-business-hq halla-infra-project-mgmt public
36 1d5da961-7f32-4032-a86c-26e8edbcb8ee 기반사업본부 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f halla halla-infra-business-hq public
37 03af0690-af91-468a-9892-0152c7309a4b 운영사업실 ORGANIZATION d656c134-a50b-43b9-8c2d-fb3738dd0f9f halla-mgmt-support-hq halla-operations-office public
38 6e9b627f-5304-4e7d-99fc-77fc2328d004 경영지원팀 ORGANIZATION d656c134-a50b-43b9-8c2d-fb3738dd0f9f halla-mgmt-support-hq halla-mgmt-support public
39 43c0fb29-84dd-49d2-a2b8-33b6659f4607 사업지원팀 ORGANIZATION d656c134-a50b-43b9-8c2d-fb3738dd0f9f halla-mgmt-support-hq halla-business-support public
40 d656c134-a50b-43b9-8c2d-fb3738dd0f9f 경영지원본부 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f halla halla-mgmt-support-hq public
41 940cc09c-32f5-4a02-8213-fb02521189d0 영업총괄 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f halla halla-general-sales public
42 57496cae-a081-4836-a20e-75c78b62257f 업무총괄 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f halla halla-general-business public
43 81e94e6c-e27a-4e36-b0f9-bf8823c96493 임원실 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f halla halla-executive public
44 786dd00c-b0c1-4db9-b25b-1afecd6a7a41 안전관리 ORGANIZATION 94c23f79-a213-4a9e-9c5d-7751777f2fe8 js-construction-hq js-safety-management public
45 5fbf6f2c-6b12-4124-a457-d1064dbb8677 현장 ORGANIZATION 94c23f79-a213-4a9e-9c5d-7751777f2fe8 js-construction-hq js-site public
46 dd82bb7b-43d8-4744-ab65-9b47ea492ac4 공무 ORGANIZATION 94c23f79-a213-4a9e-9c5d-7751777f2fe8 js-construction-hq js-construction-admin public
47 94c23f79-a213-4a9e-9c5d-7751777f2fe8 건설본부 ORGANIZATION b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 jangheon-sanup js-construction-hq public
48 4738ed53-51cc-4dcf-9885-44c9feb26760 견적 ORGANIZATION c6ff3238-f001-4cdd-b00c-0d137a3baff8 js-tech-sales-hq js-estimation public
49 063a7d31-4aa4-4904-9f32-9b092116145e 기술지원 ORGANIZATION c6ff3238-f001-4cdd-b00c-0d137a3baff8 js-tech-sales-hq js-tech-support public
50 91063309-efb3-48b7-b55b-a6e79c9eb202 영업 ORGANIZATION c6ff3238-f001-4cdd-b00c-0d137a3baff8 js-tech-sales-hq js-sales public
51 c6ff3238-f001-4cdd-b00c-0d137a3baff8 기술영업본부 ORGANIZATION b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 jangheon-sanup js-tech-sales-hq public
52 64973a3e-102e-4efd-8147-5be720b89c36 임원실 ORGANIZATION b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 jangheon-sanup jangheon-sanup-executive public
53 6f6f64d3-a555-4680-9b37-a276688b2dfa 설계팀 ORGANIZATION e57cb22c-383e-4489-8c2f-0c5431917e86 ptc ptc-design public
54 f617365d-2d1a-4d1a-a743-82b3599c8946 시공팀 ORGANIZATION e57cb22c-383e-4489-8c2f-0c5431917e86 ptc ptc-construction public
55 e629fa7d-c945-4952-b79f-1e23ecf9e7cb 사업관리팀 ORGANIZATION e57cb22c-383e-4489-8c2f-0c5431917e86 ptc ptc-project-management public
56 f19677d0-0e91-4da7-a4a4-57f3b2815154 영업팀 ORGANIZATION e57cb22c-383e-4489-8c2f-0c5431917e86 ptc ptc-sales public
57 94be067d-f369-4f6b-a6ec-2d063694c929 임원실 ORGANIZATION e57cb22c-383e-4489-8c2f-0c5431917e86 ptc ptc-executive public
58 07ef09e2-530b-498b-9f25-610500d4acb3 업무지원팀 ORGANIZATION c18a8284-0008-48aa-9cdf-9f47ab79a2a9 jangheon jangheon-business-support public
59 50b42506-f10c-4cb8-af7b-5b7c6aa63276 품질팀 ORGANIZATION e83f9477-5a8d-4168-ab3b-93508ef9cbb3 jangheon-production jangheon-quality public
60 f01cc7b9-aaa0-40c1-9124-9e81c28a6e0d 제작2팀 ORGANIZATION e83f9477-5a8d-4168-ab3b-93508ef9cbb3 jangheon-production jangheon-fab-2 public
61 b6cf39a4-6d2d-4de8-9bad-7ecbb54f477e 제작1팀 ORGANIZATION e83f9477-5a8d-4168-ab3b-93508ef9cbb3 jangheon-production jangheon-fab-1 public
62 3dbd0b03-51f5-4d1e-8271-14ff09258dad 철근팀 ORGANIZATION e83f9477-5a8d-4168-ab3b-93508ef9cbb3 jangheon-production jangheon-rebar public
63 8c74588f-d755-4fd7-b923-26bad7ff0d14 공무팀 ORGANIZATION e83f9477-5a8d-4168-ab3b-93508ef9cbb3 jangheon-production jangheon-production-admin public
64 e83f9477-5a8d-4168-ab3b-93508ef9cbb3 생산부 ORGANIZATION c18a8284-0008-48aa-9cdf-9f47ab79a2a9 jangheon jangheon-production public
65 a5b70b22-a7fc-4d01-a7e2-2cc022e808ee 바론컨설턴트 COMPANY 96369f12-6b66-4b2a-a916-d1c99d326f02 baron-group baroncs public
66 4fda8bb7-d6c4-44b7-8da9-e36f2b487732 경영지원부 ORGANIZATION b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 jangheon-sanup js-mgmt-support public
67 6b687038-180a-4141-b8d2-62647068a8ad 안전진단부 ORGANIZATION 369c1843-56af-4344-9c21-0e01197ab861 hanmac safety-diagnosis public
68 36ed0b11-6b5f-4ecd-b5b6-d009d1a8a5f9 건설사업부 ORGANIZATION d255e6da-4298-4e67-a7cc-45c53d7cdb61 construction-management-h construction-business public
69 d255e6da-4298-4e67-a7cc-45c53d7cdb61 건설사업관리본부 ORGANIZATION 369c1843-56af-4344-9c21-0e01197ab861 hanmac construction-management-h public
70 e5c94be2-a3ce-4044-8b6c-623a45abb428 상하수도부 ORGANIZATION e76d0596-c4cc-4a1d-b596-f824823a17b4 land-environment-hq water-sewerage public
71 d149e48b-cdd8-4ebc-9880-7108a599b938 수자원부 ORGANIZATION e76d0596-c4cc-4a1d-b596-f824823a17b4 land-environment-hq land-env-water-resources public
72 40be0d19-b61a-424a-97d6-a4fa14e342f1 도시계획부 ORGANIZATION e76d0596-c4cc-4a1d-b596-f824823a17b4 land-environment-hq land-env-urban-planning public
73 1c89dcbc-ebe5-41c8-b1a6-71b1dda7db19 환경평가부 ORGANIZATION e76d0596-c4cc-4a1d-b596-f824823a17b4 land-environment-hq land-env-assessment public
74 e76d0596-c4cc-4a1d-b596-f824823a17b4 국토환경사업본부 ORGANIZATION 369c1843-56af-4344-9c21-0e01197ab861 hanmac land-environment-hq public
75 32f33163-e613-4f21-b285-449afb31346e 지반터널부 ORGANIZATION c31bcf86-26da-4cd7-8ef3-371f606cbb72 infrastructure-hq infra-geotech-tunnel public
76 1ccb0859-7eda-4f54-8217-3f781ed036ef 구조부 ORGANIZATION c31bcf86-26da-4cd7-8ef3-371f606cbb72 infrastructure-hq infra-structures public
77 f8ffcb83-b709-48fc-a993-c1caefc1a648 교통부 ORGANIZATION c31bcf86-26da-4cd7-8ef3-371f606cbb72 infrastructure-hq traffic public
78 cf1818e2-689f-449b-8c99-0ae25953e576 도로부 ORGANIZATION c31bcf86-26da-4cd7-8ef3-371f606cbb72 infrastructure-hq infra-road public
79 c31bcf86-26da-4cd7-8ef3-371f606cbb72 인프라사업본부 ORGANIZATION 369c1843-56af-4344-9c21-0e01197ab861 hanmac infrastructure-hq public
80 1d784637-6926-4671-975b-3e8e455939a2 안전관리부 ORGANIZATION 369c1843-56af-4344-9c21-0e01197ab861 hanmac safety-management public
81 a2252cac-b321-493a-b177-2e702da0d77d 영업지원 ORGANIZATION 369c1843-56af-4344-9c21-0e01197ab861 hanmac sales-support public
82 445ee8ad-41e3-42fe-902f-e27d937f09c8 인프라 BIM3 ORGANIZATION d8b41c30-fe0c-4318-9ae3-cae7dcf6c891 infra-solution infra-bim3 public
83 52f06c97-9d6f-4819-971b-43303062e193 인프라 BIM2 ORGANIZATION d8b41c30-fe0c-4318-9ae3-cae7dcf6c891 infra-solution infra-bim2 public
84 432b5261-421b-4e5f-914f-32d7d22fd01f 인프라 BIM1 ORGANIZATION d8b41c30-fe0c-4318-9ae3-cae7dcf6c891 infra-solution infra-bim1 public
85 d8b41c30-fe0c-4318-9ae3-cae7dcf6c891 인프라솔루션 ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc infra-solution public 디비전
86 96f9d9de-e187-4a27-941e-0f57f3b1851a 네이버웍스관리용(바론그룹) ORGANIZATION 96369f12-6b66-4b2a-a916-d1c99d326f02 baron-group su4 private
87 d7205737-e7bd-4926-9b76-39f447ff809e 네이버웍스관리용(한맥) ORGANIZATION 369c1843-56af-4344-9c21-0e01197ab861 hanmac su2 private
88 a6343ba4-6062-4150-87b7-a9bec4ca34a0 네이버웍스관리용(삼안) ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman su1 private
89 818c856b-9545-442f-b827-d1c569f200b0 기술개발센터(조직도용) ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman rnd-center public
90 93e1f3dc-503f-40f7-8342-31974b5dd33c 해외사업부 ORGANIZATION ec325e6f-4333-43ae-a52a-77b742eef7ed overseas-headquarters overseas-business public
91 ec325e6f-4333-43ae-a52a-77b742eef7ed 해외사업본부 ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman overseas-headquarters public
92 5b531a28-a222-44de-87f4-8a0560685a25 수력부 ORGANIZATION f60d0b66-e0c8-46be-88dc-e8bc93b9e149 water-resources-hq hydropower public
93 6c1ed0b6-8141-4ff8-96fb-a84efbe3a2ce 수자원2부 ORGANIZATION f60d0b66-e0c8-46be-88dc-e8bc93b9e149 water-resources-hq water-resources-2 public
94 d59651ea-41d3-45ee-b519-537fc6a97e65 수자원1부 ORGANIZATION f60d0b66-e0c8-46be-88dc-e8bc93b9e149 water-resources-hq water-resources-1 public
95 f60d0b66-e0c8-46be-88dc-e8bc93b9e149 수자원본부 ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman water-resources-hq public
96 1596d8d3-e92d-4c95-8e3e-56c824991ee9 물환경3부 ORGANIZATION 9b9b8b70-b058-4f22-a0c3-e63bc0c7e1b8 water-environment-hq water-environment-3 public
97 9ff11813-07ae-41d2-b2af-ac4038dafde2 물환경2부 ORGANIZATION 9b9b8b70-b058-4f22-a0c3-e63bc0c7e1b8 water-environment-hq water-environment-2 public
98 2e4d1c4f-f859-4bdc-8531-8c2f29d74cc0 물환경1부 ORGANIZATION 9b9b8b70-b058-4f22-a0c3-e63bc0c7e1b8 water-environment-hq water-environment-1 public
99 9b9b8b70-b058-4f22-a0c3-e63bc0c7e1b8 물환경본부 ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman water-environment-hq public
100 25e9f426-8012-43bd-8d2f-35ad76f0236a 환경평가부 ORGANIZATION 7ee20c20-01a7-4e39-9a58-a2ffdad153dd railway-headquarters environment-assessment public
101 a4722e17-c3f9-49d9-8535-4c92d03ccaae 철도2부 ORGANIZATION 7ee20c20-01a7-4e39-9a58-a2ffdad153dd railway-headquarters railway-2 public
102 c826ecf7-c2f9-49a1-ab72-0413216999af 철도1부 ORGANIZATION 7ee20c20-01a7-4e39-9a58-a2ffdad153dd railway-headquarters railway-1 public
103 7ee20c20-01a7-4e39-9a58-a2ffdad153dd 철도본부 ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman railway-headquarters public
104 a1bca214-1f8a-403d-8f0b-c6c3aa71bb5a 안전진단팀 ORGANIZATION 967f657c-04e2-49c1-9cb9-f6afba27c0fa structures safety-inspection public
105 967f657c-04e2-49c1-9cb9-f6afba27c0fa 구조부 ORGANIZATION dfdc84b7-87a1-4383-8df7-5e656193e79c road-headquarters structures public
106 c172de4d-1e61-47d5-9446-7bb36ea50063 교통계획부 ORGANIZATION dfdc84b7-87a1-4383-8df7-5e656193e79c road-headquarters transport-planning public
107 6760a731-d172-4bc4-8ed4-1b625752c4a2 지반터널부 ORGANIZATION dfdc84b7-87a1-4383-8df7-5e656193e79c road-headquarters geotech-tunnel public
108 0b07f990-3ddb-45fa-b3d1-6233e3580f71 도로부 ORGANIZATION dfdc84b7-87a1-4383-8df7-5e656193e79c road-headquarters road public
109 dfdc84b7-87a1-4383-8df7-5e656193e79c 도로본부 ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman road-headquarters public
110 953a07f6-5ff9-46ed-9a16-bd3066ccec12 조경레저부 ORGANIZATION 95d10fc3-b5d1-4613-9b0a-f9e6cae90d83 land-development landscape-leisure public
111 ed9290e8-7296-47fc-b72f-6ccc45765d4e 도시개발부 ORGANIZATION 95d10fc3-b5d1-4613-9b0a-f9e6cae90d83 land-development urban-development public
112 02ab05a1-caa8-41c9-8c9a-91de614724fd 도시계획부 ORGANIZATION 95d10fc3-b5d1-4613-9b0a-f9e6cae90d83 land-development urban-planning public
113 95d10fc3-b5d1-4613-9b0a-f9e6cae90d83 국토개발본부 ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman land-development public
114 f36e2211-8cfd-4813-8618-34e606fe73ac 항만부 ORGANIZATION cd10e627-808b-4c98-8fdd-d47c84ce57d6 plant-headquarters harbor public
115 fec713bf-ac20-4480-bd74-686a2e6d92b3 플랜트2부 ORGANIZATION cd10e627-808b-4c98-8fdd-d47c84ce57d6 plant-headquarters plant-2 public
116 ef3adb8e-3405-4027-a0f8-5d3d5bf11e84 플랜트1부 ORGANIZATION cd10e627-808b-4c98-8fdd-d47c84ce57d6 plant-headquarters plant-1 public
117 cd10e627-808b-4c98-8fdd-d47c84ce57d6 플랜트본부 ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman plant-headquarters public
118 99735f38-47d6-490b-ad54-8090b3bef91f 호남지역총괄본부 ORGANIZATION 8bbd9aad-1b53-4504-befb-e8ae8792340f cm-division honam-headquarters public
119 8bbd9aad-1b53-4504-befb-e8ae8792340f CM사업부 ORGANIZATION 92b4fd3c-91ac-41d2-8757-9344861b97aa cm-headquarters cm-division public
120 92b4fd3c-91ac-41d2-8757-9344861b97aa CM본부 ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman cm-headquarters public
121 fe58cad4-1fa6-4b87-a2eb-51b9ac41320e 사업개발실 ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman business-development public
122 338fb8af-b594-4c41-9984-54ea4b37637b 안전품질관리실 ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman safety-quality public
123 1edc196d-020c-4519-9ec4-3d23b99076e6 자산경영실 ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman asset-management public
124 7adb550b-1756-49f6-b6cc-55b7b426ed52 인사총무부 ORGANIZATION 9bf67270-e15e-4278-b407-02dec5672876 business-strategy hr-admin public
125 01fcbee1-df33-4ee9-bf2b-6d9eb81917d9 대외협력팀 ORGANIZATION a16f49c4-6828-4fde-a164-43099c4560c4 planning external-relations public
126 cdc40c0b-f985-461a-be18-f8c8e82f31e8 재무회계팀 ORGANIZATION a16f49c4-6828-4fde-a164-43099c4560c4 planning finance public
127 c6aa2133-ded0-451c-b51b-27faa8b56507 PQ팀 ORGANIZATION a16f49c4-6828-4fde-a164-43099c4560c4 planning pq-team public
128 ca54cffe-ad30-4f9e-983a-88a85c70404d 업무팀 ORGANIZATION d656c134-a50b-43b9-8c2d-fb3738dd0f9f halla-mgmt-support-hq halla-operations public
129 a16f49c4-6828-4fde-a164-43099c4560c4 기획부 ORGANIZATION 9bf67270-e15e-4278-b407-02dec5672876 business-strategy planning public
130 9bf67270-e15e-4278-b407-02dec5672876 경영전략본부 ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman business-strategy public
131 896da8ab-50b7-4a63-abbc-c85037b63acc 시공BIM ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc construction-bim public
132 410a25b1-cd84-46d4-b4d6-8627b397ca42 스마트건설 ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc smart-construction public
133 68c85ffe-5942-42e7-9785-b2c29b18ecb9 수자원 ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc water-resources public
134 27683cb5-98ac-49cf-ac57-8157d7d2a663 PM ORGANIZATION 9e5919f5-0839-4d98-b2e8-dee2ef518b1d gsim-dev project-management public
135 39ad464a-8468-4acb-8f05-29bc828a1576 GSIM ORGANIZATION 9e5919f5-0839-4d98-b2e8-dee2ef518b1d gsim-dev gsim public
136 2501e4a7-bf41-48df-afae-76676e0169f1 bCMf ORGANIZATION 9e5919f5-0839-4d98-b2e8-dee2ef518b1d gsim-dev bcmf public
137 9e5919f5-0839-4d98-b2e8-dee2ef518b1d GSIM개발 ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc gsim-dev public
138 b83c124a-8d5b-4b14-9347-9d1dca2b42fa 웹디자인 ORGANIZATION f8366e19-d767-46db-a789-49e36d88a8fc web-solutions web-design public
139 2403109e-1e43-4390-8d2a-6f0566be028e ERP ORGANIZATION f8366e19-d767-46db-a789-49e36d88a8fc web-solutions erp public
140 54986461-8cf1-4791-b130-8fecbff5f5a2 솔루션개발 ORGANIZATION f8366e19-d767-46db-a789-49e36d88a8fc web-solutions solution-dev public
141 f8366e19-d767-46db-a789-49e36d88a8fc 웹솔루션 ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc web-solutions public
142 ec003372-962a-4d7b-b90a-edf9dfbb6eea Abut&시공통합관제 ORGANIZATION d394dcf2-d474-4059-9cbb-0fca343ec38c graphics abut-control public
143 52266543-a90b-4441-99c6-51f454b6059a EG-BIM Draw ORGANIZATION d394dcf2-d474-4059-9cbb-0fca343ec38c graphics eg-bim-draw public
144 1d74bebb-c5a1-49d4-bec4-90f0c89ad21f HmEG ORGANIZATION d394dcf2-d474-4059-9cbb-0fca343ec38c graphics hmeg public
145 87831866-07de-4e3c-b158-28188a3c1edb Modeler ORGANIZATION d394dcf2-d474-4059-9cbb-0fca343ec38c graphics modeler public
146 d394dcf2-d474-4059-9cbb-0fca343ec38c 그래픽스 ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc graphics public
147 d43eed1b-3a36-4624-a040-7f2827016df6 Strana ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc strana public
148 78f251f6-d35b-422d-92ab-7fabd80bef85 구조물S/W ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc structural-software public
149 b06b8821-b74c-44d5-89c4-3a94e9979161 Watch BIM ORGANIZATION 9b211775-e6b6-4caf-8c98-91249186e3d9 infra-solution-dev watch-bim public
150 79923c99-1aab-4950-b69e-6312636f544f Primal 평면 ORGANIZATION 9b211775-e6b6-4caf-8c98-91249186e3d9 infra-solution-dev primal-plan public
151 28fba7fd-6af5-43c1-807b-fb97d6eafada Way Draw ORGANIZATION 9b211775-e6b6-4caf-8c98-91249186e3d9 infra-solution-dev way-draw public
152 0cec58e8-efbe-4f93-a734-d1b9cc7747b7 비탈면/구조물 ORGANIZATION 9b211775-e6b6-4caf-8c98-91249186e3d9 infra-solution-dev slope-structures public
153 9b211775-e6b6-4caf-8c98-91249186e3d9 인프라솔루션 개발 ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc infra-solution-dev public
154 f39ef0c8-0ad0-49cd-97ff-b0672591cfe3 단지설계 개발 ORGANIZATION d015f4fc-6ce1-4258-be37-6c531cf75c6c cheonjijin site-design-dev public
155 35cc1fdf-6c0e-4b0e-8ce8-1adc918b8cbf 용지도셀 ORGANIZATION d015f4fc-6ce1-4258-be37-6c531cf75c6c cheonjijin land-map-cell public
156 c3e59dbc-a315-47f9-8787-2a792a311e32 천지인셀 ORGANIZATION d015f4fc-6ce1-4258-be37-6c531cf75c6c cheonjijin cheonjijin-cell public
157 d015f4fc-6ce1-4258-be37-6c531cf75c6c 천지인 ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc cheonjijin public
158 61e9ed21-e100-475a-a1e6-bdb2a302db95 상하수도 ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc water-sewer public
159 0e206c1f-8e9b-43c2-91ce-a17bf62854c8 단가산출 ORGANIZATION 0471c240-7080-4648-86a6-5fdecff9e148 cost-control cost-estimate public
160 bc903928-61cb-45a8-9b4a-794f62a9f8a6 공정관리 ORGANIZATION 0471c240-7080-4648-86a6-5fdecff9e148 cost-control schedule-control public
161 0471c240-7080-4648-86a6-5fdecff9e148 CC ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc cost-control public
162 fcb4c9d3-ea2c-48c6-92cc-63706f53fa21 터널 ORGANIZATION 582972ce-6b06-48c8-aa30-31a5285e160e structural-division tunnel public
163 fab2c4ca-4081-4f64-924d-f32bdf2e61b4 CM기획 ORGANIZATION 582972ce-6b06-48c8-aa30-31a5285e160e structural-division cm-planning public
164 1e1a999a-38f0-40f1-ae89-62e6031a29e0 하부구조 ORGANIZATION 582972ce-6b06-48c8-aa30-31a5285e160e structural-division substructure public
165 9eb73493-8aa1-43d4-99a8-424b1a7d60be 구조물계획 ORGANIZATION 582972ce-6b06-48c8-aa30-31a5285e160e structural-division structure-planning public
166 3d147a08-00b9-47c7-940a-d75c36a6ce81 일반구조물 ORGANIZATION 582972ce-6b06-48c8-aa30-31a5285e160e structural-division structural-design public
167 1d39617e-8e50-4081-bcd5-551e7842f7b3 DfMA ORGANIZATION 582972ce-6b06-48c8-aa30-31a5285e160e structural-division dfma public
168 582972ce-6b06-48c8-aa30-31a5285e160e 일반구조물 div ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc structural-division public
169 56cd0fd7-b62a-43c0-8db9-74a30468d7cb 기술개발센터 ORGANIZATION 5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee gpdtdc tdc internal
170 59b83b98-b604-4621-8527-b872be47accc 네이버웍스관리용(총센) ORGANIZATION 5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee gpdtdc su3 private
171 586434c5-3460-458b-b2e5-488b0e77fa21 솔루션통합 ORGANIZATION 761a8725-9c19-442c-986c-0319e33a5b1e gpd solution-integration public
172 df6a504d-75b9-4a21-a67e-1522cc18111c 협업증진 ORGANIZATION 761a8725-9c19-442c-986c-0319e33a5b1e gpd collaboration public
173 6f420ad4-2345-43b5-9c2d-7ba0dae8cae4 디자인기획 ORGANIZATION 761a8725-9c19-442c-986c-0319e33a5b1e gpd design-planning public
174 88d1d1ee-795a-4b67-a8e7-bb0d4d7ac49f ERP기획 ORGANIZATION 761a8725-9c19-442c-986c-0319e33a5b1e gpd erp-planning public
175 c6b1266c-564b-4543-baba-d78807a3d1b4 경영기획 ORGANIZATION 761a8725-9c19-442c-986c-0319e33a5b1e gpd management-planning public
176 3a660456-eceb-472b-a9a9-f2a5b0ce972b 기술기획 ORGANIZATION 761a8725-9c19-442c-986c-0319e33a5b1e gpd tech-planning public
177 556798a6-b45e-4822-b6f1-42046b6d0001 전산관리TF ORGANIZATION 761a8725-9c19-442c-986c-0319e33a5b1e gpd it-admin-tf internal
178 539f598d-e6f1-4fe2-be48-466448d8d803 인재성장 ORGANIZATION 761a8725-9c19-442c-986c-0319e33a5b1e gpd talent-growth public
179 761a8725-9c19-442c-986c-0319e33a5b1e 총괄기획실 ORGANIZATION 5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee gpdtdc gpd public
180 e57cb22c-383e-4489-8c2f-0c5431917e86 PTC COMPANY 96369f12-6b66-4b2a-a916-d1c99d326f02 baron-group ptc public
181 9607eb7b-04d2-42ab-80fe-780fe21c7e8f Personal PERSONAL personal 개인 사용자 기본 루트 테넌트 public
182 5a03efd2-e62f-4243-800d-58334bf48b2f 한라산업개발 ORGANIZATION 96369f12-6b66-4b2a-a916-d1c99d326f02 baron-group halla public
183 b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 장헌산업 ORGANIZATION 96369f12-6b66-4b2a-a916-d1c99d326f02 baron-group jangheon-sanup public
184 c18a8284-0008-48aa-9cdf-9f47ab79a2a9 (주)장헌 ORGANIZATION 96369f12-6b66-4b2a-a916-d1c99d326f02 baron-group jangheon public
185 96369f12-6b66-4b2a-a916-d1c99d326f02 바론그룹 COMPANY_GROUP 038326b6-954a-48a7-a85f-efd83f62b82a hanmac-family baron-group 네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID public
186 5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee 총괄기획&기술개발센터 COMPANY 038326b6-954a-48a7-a85f-efd83f62b82a hanmac-family gpdtdc 네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID baroncs.co.kr public
187 369c1843-56af-4344-9c21-0e01197ab861 한맥기술 COMPANY 038326b6-954a-48a7-a85f-efd83f62b82a hanmac-family hanmac 네이버웍스 한맥 HANMAC_DOMAIN_ID hanmaceng.co.kr public
188 9caf62e1-297d-4e8f-870b-61780998bbeb 삼안 COMPANY 038326b6-954a-48a7-a85f-efd83f62b82a hanmac-family saman 네이버웍스 삼안 SAMAN_DOMAIN_ID samaneng.com public
189 038326b6-954a-48a7-a85f-efd83f62b82a 한맥가족 COMPANY_GROUP hanmac-family 한맥가족 기본 루트 테넌트 public

11
test.sh
View File

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

View File

@@ -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
1 email name phone role tenant_slug department grade position jobTitle employee_id tenant_slug1 department1 grade1 position1 jobTitle1 employee_id1 sub_email
2 cyhan@samaneng.com 한치영 01041585840 super rnd-saman 224382 tech-planning 책임연구원 b24051 b24051@hanmaceng.co.kr
3 jhshin@samaneng.com 신지호 010-9268-7509 user rnd-saman 209171 erp 책임연구원 M20329 m20329@hanmaceng.co.kr
4 swbae@samaneng.com 배상우 010-4716-5624 user rnd-saman 215032 water-sewer 선임연구원 B22062 b22062@hanmaceng.co.kr
5 hspark1@samaneng.com 박현수 010-3898-1757 user rnd-saman 207241 water-sewer 수석연구원 팀장 B19206 b19206@hanmaceng.co.kr
6 smyoo@samaneng.com 유승민 010-9242-2912 user rnd-saman 222244 strana 선임연구원 B22058 b22058@hanmaceng.co.kr
7 mjjeong1@samaneng.com 정명준 010-3062-2026 user rnd-saman 216070 solution-dev 책임연구원 M20330 m20330@hanmaceng.co.kr
8 hjkim3@samaneng.com 김형준 010-4850-8649 user rnd-saman 216121 tdc 수석연구원 B16212 hjkim3@hanmaceng.co.kr
9 ypshim@samaneng.com 심영표 010-3296-1788 user rnd-saman 216164 dfma 수석연구원 팀장 B16216 ypshim@hanmaceng.co.kr
10 jnoh@samaneng.com 노준 010-9177-0523 user rnd-saman 217155 slope-structures 수석연구원 B17206 jnoh@hanmaceng.co.kr
11 dwahn@samaneng.com 안대욱 010-6424-1980 user rnd-saman 217157 cheonjijin-cell 책임연구원 B10201 dw6092@hanmaceng.co.kr
12 kwjeong@samaneng.com 정계완 010-2743-8814 user rnd-saman 218001 structural-software 수석연구원 팀장 B17203 kyewan@hanmaceng.co.kr
13 mskim7@samaneng.com 김민성 010-7730-8174 user rnd-saman 218002 graphics 수석연구원 B16213 mskim@hanmaceng.co.kr
14 sjyou@samaneng.com 유석준 010-2067-4875 user rnd-saman 218003 smart-construction 수석연구원 B16214 sjyou@hanmaceng.co.kr
15 kjkim1@samaneng.com 김경종 010-9644-7401 user rnd-saman 218005 strana 선임연구원 B17315 kjkim@hanmaceng.co.kr
16 iwlee@samaneng.com 이인우 010-5001-5305 user rnd-saman 218007 structural-software 책임연구원 B16305 inwoo772@hanmaceng.co.kr
17 gbkim@samaneng.com 김규범 010-3341-8624 user rnd-saman 218008 land-map-cell 선임연구원 B17308 gyubeom627@hanmaceng.co.kr
18 yjlee3@samaneng.com 이연재 010-5276-3376 user rnd-saman 218009 structural-software 선임연구원 B17309 yeonjae52@hanmaceng.co.kr
19 itkim@samaneng.com 김일태 010-6500-6873 user rnd-saman 218027 structure-planning 수석연구원 팀장 B18206 itkim@hanmaceng.co.kr
20 jychoi1@samaneng.com 최진영 010-8070-0952 user rnd-saman 218118 hmeg 선임연구원 B18311 jy_choi@hanmaceng.co.kr
21 bjkim2@samaneng.com 김병조 010-8592-7983 user rnd-saman 218128 infra-bim2 수석연구원 팀장 B18212 bjkim@hanmaceng.co.kr
22 hklee@samaneng.com 이호경 010-4748-1103 user rnd-saman 218141 strana 수석연구원 팀장 B18215 hklee@hanmaceng.co.kr
23 hsryu1@samaneng.com 류한솔 010-9955-1825 user rnd-saman 218144 primal-plan 책임연구원 B18213 hansol.ryu@hanmaceng.co.kr
24 hyshin@samaneng.com 신혜영 010-3595-3511 user rnd-saman 218145 design-planning 수석연구원 팀장 B18214 shy0622@hanmaceng.co.kr
25 hsyu@samaneng.com 유효식 010-8885-1095 user rnd-saman 218151 schedule-control 책임연구원 B18313 hyosik914@hanmaceng.co.kr
26 hikim@samaneng.com 김현일 010-9491-7161 user rnd-saman 219001 substructure 수석연구원 팀장 B19201 kajm77@hanmaceng.co.kr
27 bhyang1@samaneng.com 양병홍 010-6201-0523 user rnd-saman 219018 tdc 부사장 센터장 B18202 b18202@hanmaceng.co.kr
28 eklee1@samaneng.com 이은구 010-5672-7889 user rnd-saman 219072 water-resources 책임연구원 팀장 B19203 lek@hanmaceng.co.kr
29 wtshin@samaneng.com 신원태 010-2726-0728 user rnd-saman 219080 schedule-control 책임연구원 B19204 panic7ka@hanmaceng.co.kr
30 dwlee2@samaneng.com 이동원 010-2910-3133 user rnd-saman 219152 structural-division 수석연구원 디비전장 B19309 dwlee2@hanmaceng.co.kr
31 mskim@samaneng.com 김명식 010-2289-5257 user rnd-saman 219154 hmeg 선임연구원 B19310 myungsik@hanmaceng.co.kr
32 wison@samaneng.com 손원일 010-2430-4219 user rnd-saman 219155 site-design-dev 책임연구원 B19311 wison@hanmaceng.co.kr
33 dhlee@samaneng.com 이동호 010-8708-6817 user rnd-saman 220047 infra-bim2 선임연구원 B22056 b22056@hanmaceng.co.kr
34 ysjang1@samaneng.com 장용섭 010-4701-1006 user rnd-saman 220147 way-draw 책임연구원 B20202 yongseop@hanmaceng.co.kr
35 jahan@samaneng.com 한지아 010-2584-3790 user rnd-saman 222057 web-design 책임연구원 B22001 b22001@hanmaceng.co.kr
36 shkwon@samaneng.com 권순호 010-4432-4117 user rnd-saman 222059 design-planning 연구원 B22003 b22003@hanmaceng.co.kr
37 dlyoo@samaneng.com 유달리 010-9007-9064 user rnd-saman 220227 infra-bim3 책임연구원 B20205 b20205@hanmaceng.co.kr
38 yhjung2@samaneng.com 정요한 010-8867-6046 user rnd-saman 220234 cost-control 수석연구원 팀장 B20326 b20326@hanmaceng.co.kr
39 ygkim1@samaneng.com 김윤권 010-4131-1369 user rnd-saman 220266 schedule-control 책임연구원 B20333 b20333@hanmaceng.co.kr
40 jwlee1@samaneng.com 이재원 010-7766-4757 user rnd-saman 220271 modeler 선임연구원 B20336 b20336@hanmaceng.co.kr
41 jhlee2@samaneng.com 이주형 010-7511-5468 user rnd-saman 221022 infra-bim2 선임연구원 B21315 b21315@hanmaceng.co.kr
42 jslee1@samaneng.com 이진수 010-6409-6442 user rnd-saman 221040 land-map-cell 선임연구원 B21306 b21306@hanmaceng.co.kr
43 yski@samaneng.com 기윤서 010-6289-9782 user rnd-saman 221052 bcmf 수석연구원 M21309 m21309@hanmaceng.co.kr
44 kakang@samaneng.com 강근아 010-3066-9589 user rnd-saman 221054 eg-bim-draw 선임연구원 M21318 m21318@hanmaceng.co.kr
45 jwpark8@samaneng.com 박정우 010-4794-0596 user rnd-saman 221055 gsim 선임연구원 B21309 b21309@hanmaceng.co.kr
46 bckim@samaneng.com 김병철 010-3016-7065 user rnd-saman 221064 erp 선임연구원 B21319 b21319@hanmaceng.co.kr
47 jykang1@samaneng.com 강지영 010-3322-6664 user rnd-saman 221067 cm-planning 선임연구원 B21320 b21320@hanmaceng.co.kr
48 ehjung1@samaneng.com 정은혜 010-3378-1154 user rnd-saman 221163 design-planning 책임연구원 B21339 b21339@hanmaceng.co.kr
49 alhong@samaneng.com 홍아름 010-4070-1948 user rnd-saman 221184 tech-planning 수석연구원 B21344 b21344@hanmaceng.co.kr
50 thlee3@samaneng.com 이태훈 010-4527-8434 user rnd-saman 221270 tech-planning 선임연구원 B21364 b21364@hanmaceng.co.kr
51 jsyun@samaneng.com 윤준수 010-9877-8748 user rnd-saman 221293 solution-integration 선임연구원 B21367 b21367@hanmaceng.co.kr
52 sphwang@samaneng.com 황선필 010-5035-5239 user rnd-saman 221292 cm-planning 선임연구원 B21368 b21368@hanmaceng.co.kr
53 jwchoi3@samaneng.com 최정우 010-8963-5736 user rnd-saman 221337 water-sewer 책임연구원 B22055 b21316@hanmaceng.co.kr
54 ngkim@samaneng.com 김남걸 010-2262-5708 user rnd-saman 222004 schedule-control 수석연구원 B21372 b21372@hanmaceng.co.kr
55 yhchoi@samaneng.com 최용혁 010-8513-1451 user rnd-saman 222010 structure-planning 선임연구원 B21370 b21370@hanmaceng.co.kr
56 skkang@samaneng.com 강상구 010-9291-0264 user rnd-saman 222060 cm-planning 선임연구원 B22004 b22004@hanmaceng.co.kr
57 unhuh@samaneng.com 허유나 010-8870-9345 user rnd-saman 222073 design-planning 선임연구원 B22011 b22011@hanmaceng.co.kr
58 chlee@samaneng.com 이창효 010-8725-3372 user rnd-saman 222078 dfma 선임연구원 B22019 b22019@hanmaceng.co.kr
59 mkim2@samaneng.com 임민경 010-8209-9929 user rnd-saman 222087 management-planning 책임연구원 B22015 b21365@hanmaceng.co.kr
60 cichoi@samaneng.com 최창인 010-4645-2808 user rnd-saman 222089 substructure 책임연구원 B22016 b22016@hanmaceng.co.kr
61 hikim2@samaneng.com 김혜인 010-9510-3760 user rnd-saman 222123 tech-planning 선임연구원 B22027 b22027@hanmaceng.co.kr
62 sclee@samaneng.com 이수창 010-7622-2729 user rnd-saman 222150 infra-bim1 선임연구원 B22031 b22031@hanmaceng.co.kr
63 dhkim3@samaneng.com 김도현 010-9396-6726 user rnd-saman 222152 bcmf 선임연구원 B22039 b22039@hanmaceng.co.kr
64 sdjo@samaneng.com 조선두 010-2009-9705 user rnd-saman 222155 cm-planning 책임연구원 팀장 B22042 b22042@hanmaceng.co.kr
65 sachoi@samaneng.com 최선아 010-6460-2728 user rnd-saman 222156 management-planning 책임연구원 B22036 b22036@hanmaceng.co.kr
66 yjahn2@samaneng.com 안용주 010-5433-0545 user rnd-saman 222157 dfma 책임연구원 B22037 b22037@hanmaceng.co.kr
67 smlee@samaneng.com 이수문 010-9229-3480 user rnd-saman 222158 dfma 수석연구원 B22035 b22035@hanmaceng.co.kr
68 tskim@samaneng.com 김태식A 010-9965-9940 user rnd-saman 222182 design-planning 책임연구원 B22046 b22046@hanmaceng.co.kr
69 jhkang@samaneng.com 강정훈 010-9891-8798 user rnd-saman 222212 strana 연구원 B22048 b22048@hanmaceng.co.kr
70 jhkim14@samaneng.com 김재현 010-2534-7837 user rnd-saman 222231 watch-bim 수석연구원 B22051 b22051@hanmaceng.co.kr
71 yjchoi1@samaneng.com 최윤진 010-2349-6687 user rnd-saman 222240 way-draw 연구원 B22052 b22052@hanmaceng.co.kr
72 wkkim@samaneng.com 김원기 010-4727-8530 user rnd-saman 222242 infra-bim1 책임연구원 B22057
73 jhlee@samaneng.com 이준호 010-2514-6898 user rnd-saman 223046 structural-software 연구원 B23003 b23003@hanmaceng.co.kr
74 jhchoi3@samaneng.com 최진헌 010-8638-8079 user rnd-saman 222272 strana 선임연구원 B22063 b22063@hanmaceng.co.kr
75 hulee1@samaneng.com 이한울 010-9271-8997 user rnd-saman 222294 web-design 연구원 B22069 b22069@hanmaceng.co.kr
76 dwkim3@samaneng.com 김도우 010-5008-6104 user rnd-saman 223004 cost-estimate 연구원 B22073 b22073@hanmaceng.co.kr
77 mskim8@samaneng.com 김민수 010-4570-0179 user rnd-saman 223006 construction-bim 책임연구원 B22074 b22074@hanmaceng.co.kr
78 jhjeong1@samaneng.com 정주현 010-7566-8314 user rnd-saman 223007 cheonjijin-cell 연구원 B22076 b22076@hanmaceng.co.kr
79 scbaek@samaneng.com 백순철 010-9619-0437 user rnd-saman 223045 cheonjijin-cell 연구원 B23002 b23002@hanmaceng.co.kr
80 shyeom1@samaneng.com 염승호 010-8835-0501 user rnd-saman 223070 solution-integration 수석연구원 B23008 b23008@hanmaceng.co.kr
81 jskim1@samaneng.com 김진선 010-7415-8300 user rnd-saman 223158 solution-dev 선임연구원 B23033 b23033@hanmaceng.co.kr
82 hyma@samaneng.com 마희연 010-8213-7601 user rnd-saman 223089 design-planning 선임연구원 B23015 b23015@hanmaceng.co.kr
83 dwjung@samaneng.com 정두휘 010-5521-6160 user rnd-saman 223099 design-planning 연구원 B23014 b23014@hanmaceng.co.kr
84 gshong@samaneng.com 홍길수 010-6641-0857 user rnd-saman 223100 modeler 연구원 B23019 b23019@hanmaceng.co.kr
85 marco@samaneng.com 마르코 010-6662-1599 user rnd-saman 223105 strana 선임연구원 B23020 b23020@hanmaceng.co.kr
86 hjjeong1@samaneng.com 정호진 010-7332-8456 user rnd-saman 223114 strana 연구원 B23022 b23022@hanmaceng.co.kr
87 yjlee2@samaneng.com 이예진 010-9262-7530 user rnd-saman 223123 design-planning 선임연구원 B23028 b23028@hanmaceng.co.kr
88 swpark@samaneng.com 박승우 010-5482-6617 user rnd-saman 223195 abut-control 연구원 B23038 b23038@hanmaceng.co.kr
89 hwji@samaneng.com 지현욱 010-9228-8426 user rnd-saman 223134 water-resources 책임연구원 B23025 b23025@hanmaceng.co.kr
90 swseo@samaneng.com 서승완 010-3245-1363 user rnd-saman 223135 erp 선임연구원 B23030 b23030@hanmaceng.co.kr
91 jykim4@samaneng.com 김주영 010-3855-2839 user rnd-saman 223138 structural-design 선임연구원 B23031 b23031@hanmaceng.co.kr
92 jglee1@samaneng.com 이정곤 010-3958-4115 user rnd-saman 223184 cost-estimate 책임연구원 B23036 b23036@hanmaceng.co.kr
93 hmin@samaneng.com 민홍 010-8654-5461 user rnd-saman 223313 gsim 선임연구원 B23055 b23055@hanmaceng.co.kr
94 hwan@samaneng.com 안효원 010-3358-4260 user rnd-saman 223228 infra-bim1 선임연구원 B23040 b23040@hanmaceng.co.kr
95 sihan@samaneng.com 한성일 010-4322-1100 user rnd-saman 223226 abut-control 책임연구원 B23042 b23042@hanmaceng.co.kr
96 jhkim25@samaneng.com 김재환 010-8962-3743 user rnd-saman 223229 structural-design 책임연구원 B23041 b23041@hanmaceng.co.kr
97 gylee1@samaneng.com 이가연 010-2430-5102 user rnd-saman 223269 slope-structures 연구원 B23047 b23047@hanmaceng.co.kr
98 yskim3@samaneng.com 김예서 010-9167-6132 user rnd-saman 223280 land-map-cell 연구원 B23051 b23051@hanmaceng.co.kr
99 jhpyo@samaneng.com 표재학 010-2522-4984 user rnd-saman 223281 primal-plan 연구원 B23052 b23052@hanmaceng.co.kr
100 sjkim6@samaneng.com 김신지 010-7667-8256 user rnd-saman 223361 tech-planning 연구원 B23064 b23064@hanmaceng.co.kr
101 jschoi@samaneng.com 최지수 010-3557-3726 user rnd-saman 223385 water-sewer 연구원 B23068 b23068@hanmaceng.co.kr
102 jsuhm@samaneng.com 엄지숙 010-5399-9030 user rnd-saman 224048 eg-bim-draw 책임연구원 B23072 b23072@hanmaceng.co.kr
103 kbpark@samaneng.com 박경빈 010-9811-7018 user rnd-saman 224053 watch-bim 연구원 B24004 b24004@hanmaceng.co.kr
104 hkyoon@samaneng.com 윤현경 010-4947-0798 user rnd-saman 224057 structure-planning 선임연구원 B24005 b24005@hanmaceng.co.kr
105 jepark1@samaneng.com 박지은 010-3738-7186 user rnd-saman 224058 project-management 연구원 B24006 b24006@hanmaceng.co.kr
106 kmlee1@samaneng.com 이경민 010-3409-1237 user rnd-saman 224069 tech-planning 선임연구원 B24009 b24009@hanmaceng.co.kr
107 sylim1@samaneng.com 임성엽 010-5702-1213 user rnd-saman 224070 land-map-cell 선임연구원 B24011 b24011@hanmaceng.co.kr
108 jgjeon@samaneng.com 전제경 010-3343-5898 user rnd-saman 224091 cheonjijin-cell 연구원 B24013 b24013@hanmaceng.co.kr
109 hgjang@samaneng.com 장한규 010-7561-3369 user rnd-saman 224080 dfma 연구원 B24010 b24010@hanmaceng.co.kr
110 dwham@samaneng.com 함도원 010-7557-2285 user rnd-saman 224106 infra-bim3 연구원 B24018 b24018@hanmaceng.co.kr
111 grmin@samaneng.com 민경록 010-3272-0097 user rnd-saman 224234 hmeg 연구원 B24033 b24033@hanmaceng.co.kr
112 hklee2@samaneng.com 이현경 010-2687-3453 user rnd-saman 224265 site-design-dev 연구원 B24035 b24035@hanmaceng.co.kr
113 hsjin@samaneng.com 진희성 010-6773-0063 user rnd-saman 224291 infra-bim1 연구원 B24039 b24039@hanmaceng.co.kr
114 gakim@samaneng.com 김근아 010-6301-3072 user rnd-saman 224286 site-design-dev 연구원 B24038 b24038@hanmaceng.co.kr
115 jgbyun@samaneng.com 변정안 010-2499-5922 user rnd-saman 224361 dfma 선임연구원 B24046 b24046@hanmaceng.co.kr
116 mspark@samaneng.com 박민선 010-3716-3845 user rnd-saman 224353 tunnel 연구원 B24044 b24044@hanmaceng.co.kr
117 hyhwang@samaneng.com 황호연 010-4927-3201 user rnd-saman 224363 water-resources 연구원 B24047 b24047@hanmaceng.co.kr
118 smlee2@samaneng.com 이상목 010-3470-9973 user rnd-saman 224371 tunnel 연구원 B24048 b24048@hanmaceng.co.kr
119 dhhan1@samaneng.com 한동현 010-3606-0738 user rnd-saman 224385 infra-bim2 연구원 B24052 b24052@hanmaceng.co.kr
120 jhchoi6@samaneng.com 최준호 010-9174-3191 user rnd-saman 224394 gsim 연구원 B24057 b24057@hanmaceng.co.kr
121 mjlee@samaneng.com 이민지 010-3904-5527 user rnd-saman 224392 substructure 연구원 B24054 b24054@hanmaceng.co.kr
122 mjjeong2@samaneng.com 정미정 010-4299-6544 user rnd-saman 224391 structure-planning 연구원 B24055 b24055@hanmaceng.co.kr
123 mklee@samaneng.com 이민규 010-6243-3767 user rnd-saman 224398 abut-control 연구원 B24058 b24058@hanmaceng.co.kr
124 anlee@samaneng.com 이에녹 010-3301-7191 user rnd-saman 224402 infra-bim2 연구원 B24060 b24060@hanmaceng.co.kr
125 bshan@samaneng.com 한반석 010-5052-1706 user rnd-saman 225025 infra-bim3 연구원 B25002 b25002@hanmaceng.co.kr
126 hckim4@samaneng.com 김희철 010-5012-8456 user rnd-saman 225083 water-resources 연구원 B25004 b25004@hanmaceng.co.kr
127 swpark2@samaneng.com 박성원 010-5672-0355 user rnd-saman 225084 infra-bim2 연구원 B25003 b25003@hanmaceng.co.kr
128 yjsung@samaneng.com 성유정 010-8976-2264 user rnd-saman 225099 infra-bim1 연구원 B25009 b25009@hanmaceng.co.kr
129 sjyou1@samaneng.com 유서진 010-8703-8014 user rnd-saman 225100 infra-bim3 연구원 B25010 b25010@hanmaceng.co.kr
130 gukim@samaneng.com 김건우A 010-6643-0460 user rnd-saman 225105 gsim 연구원 B25013 b25013@hanmaceng.co.kr
131 sykim3@samaneng.com 김성엽 010-3818-8608 user rnd-saman 225110 infra-bim3 선임연구원 B25011 b25011@hanmaceng.co.kr
132 jskwon@samaneng.com 권장승 010-7176-7142 user rnd-saman 225111 infra-bim1 연구원 B25014 b25014@hanmaceng.co.kr
133 jyjung1@samaneng.com 정지윤 010-7132-6329 user rnd-saman 225140 design-planning 연구원 B25017 b25017@hanmaceng.co.kr
134 jwjeong1@samaneng.com 정진우 010-5438-6084 user rnd-saman 225122 hmeg 연구원 B25016 b25016@hanmaceng.co.kr
135 cwshin@samaneng.com 신찬웅 010-5538-6590 user rnd-saman 225141 watch-bim 연구원 B25018 b25018@hanmaceng.co.kr
136 jskim2@samaneng.com 김종석 010-9458-1138 user rnd-saman 225156 site-design-dev 선임연구원 B25020 b25020@hanmaceng.co.kr
137 shpark10@samaneng.com 박석현 010-9252-6709 user rnd-saman 225161 infra-bim1 연구원 B25021 b25021@hanmaceng.co.kr
138 hjjung1@samaneng.com 정학재 010-9285-9318 user rnd-saman 225162 infra-bim2 연구원 B25022 b25022@hanmaceng.co.kr
139 hrlee1@samaneng.com 이해랑 010-8628-0094 user rnd-saman 225175 modeler 연구원 B25023 b25023@hanmaceng.co.kr
140 jhsim@samaneng.com 심재훈 010-6633-3366 user rnd-saman 225183 tunnel 수석연구원 B25025 b25025@hanmaceng.co.kr
141 shkim4@samaneng.com 김수현 010-5645-5153 user rnd-saman 225215 design-planning 선임연구원 B25027 b25027@hanmaceng.co.kr
142 smbaek@samaneng.com 백승민 010-7156-8542 user rnd-saman 225319 hmeg 책임연구원 B25035 b25035@hanmaceng.co.kr
143 swpark3@samaneng.com 박상원 010-4794-0148 user rnd-saman 225336 cm-planning 연구원 B25036 b25036@hanmaceng.co.kr
144 smyoun@samaneng.com 윤석무 010-9780-8901 user rnd-saman 226049 solution-dev 연구원 B26002 b26002@hanmaceng.co.kr
145 jhpark4@samaneng.com 박종혁 010-4211-2090 user rnd-saman 226072 infra-bim2 연구원 B26003 b26003@hanmaceng.co.kr
146 dhhong@samaneng.com 홍덕현 010-5360-7314 user rnd-saman 226073 structural-design 연구원 B26004 b26004@hanmaceng.co.kr
147 twchung@hanmaceng.co.kr 정태원 010-2362-3668 user rnd-hanmac twchung tdc 사장 M21201 ctw@hanmaceng.co.kr
148 shkim13@hanmaceng.co.kr 김승호 010-4753-3240 user rnd-hanmac shkim13 substructure 수석연구원 M02248 soo98soo@hanmaceng.co.kr
149 jhkim32@hanmaceng.co.kr 김정훈 010-9152-7409 user rnd-hanmac jhkim32 infra-solution 수석연구원 디비전장 M04308 hunsing@hanmaceng.co.kr
150 khseok@hanmaceng.co.kr 곽현석 010-3280-3609 user rnd-hanmac khseok structure-planning 수석연구원 M06309 hyunss97@hanmaceng.co.kr
151 eshwang1@hanmaceng.co.kr 황은식 010-8792-9303 user rnd-hanmac eshwang1 infra-bim1 수석연구원 팀장 M07302 bobos1101@hanmaceng.co.kr
152 jjpyo@hanmaceng.co.kr 표종진 010-6406-1225 user rnd-hanmac jjpyo infra-bim2 수석연구원 M08301 piossy@hanmaceng.co.kr
153 hslee5@hanmaceng.co.kr 이호성 010-8622-3403 user rnd-hanmac hslee5 gsim-dev 수석연구원 팀장 M08303 jpsaviola@hanmaceng.co.kr
154 hylee4@hanmaceng.co.kr 이화영 010-4720-8841 user rnd-hanmac hylee4 tunnel 수석연구원 팀장 M12205 leehy@hanmaceng.co.kr
155 bjshin@hanmaceng.co.kr 신봉진 010-7189-4043 user rnd-hanmac bjshin cheonjijin-cell 수석연구원 M17203 bjshin@hanmaceng.co.kr
156 mjkang4@hanmaceng.co.kr 강명진 010-5158-3696 user rnd-hanmac mjkang4 cheonjijin 수석연구원 팀장 M17205 mjkang@hanmaceng.co.kr
157 msoh1@hanmaceng.co.kr 오문성 010-3319-7853 user rnd-hanmac msoh1 cost-estimate 수석연구원 M18201 ohmunseong@hanmaceng.co.kr
158 swkim3@pre-cast.co.kr 김상욱 010-4857-3636 user rnd-baron swkim3 structural-design 수석연구원 팀장 P11202 p11202@hanmaceng.co.kr
159 yhkim8@brsw.kr 김윤하 010-3322-7515 user rnd-baron yhkim8 web-solutions 수석연구원 팀장 T03225 kyh@hanmaceng.co.kr
160 mnyoun@hanmaceng.co.kr 문남연 010-4534-4443 user rnd-hanmac mnyoun infra-solution-dev 수석연구원 팀장 T04306 ace97@hanmaceng.co.kr
161 jgchoi@hanmaceng.co.kr 최정균 010-6737-9212 user rnd-hanmac jgchoi construction-bim 책임연구원 M26013 b21366@hanmaceng.co.kr
162 jwkim9@hanmaceng.co.kr 김지웅 010-4714-8160 user rnd-hanmac jwkim9 structural-software 책임연구원 B13301 b13301@hanmaceng.co.kr
163 jychoi4@hanmaceng.co.kr 최준영 010-3156-1423 user rnd-hanmac jychoi4 eg-bim-draw 책임연구원 B17314 cjy627@hanmaceng.co.kr
164 sykim5@brsw.kr 김세열 010-9122-6487 user rnd-baron sykim5 structural-software 책임연구원 J15306 j15306@hanmaceng.co.kr
165 ktlee1@hanmaceng.co.kr 이광태 010-9863-1108 user rnd-hanmac ktlee1 infra-bim1 책임연구원 M13301 ktqoqo@hanmaceng.co.kr
166 jykim7@pre-cast.co.kr 김지영 010-7412-1729 user rnd-baron jykim7 infra-bim3 책임연구원 팀장 M17208 jykim@hanmaceng.co.kr
167 ysmun@pre-cast.co.kr 문영석 010-2833-5718 user rnd-baron ysmun hmeg 선임연구원 B20309 munyeongseok@hanmaceng.co.kr
168 ghkim4@brsw.kr 김근형 010-2622-0967 user rnd-baron ghkim4 eg-bim-draw 선임연구원 B20311 rmsgud1202@hanmaceng.co.kr
169 jkson@brsw.kr 손제근 010-6421-8791 user rnd-baron jkson project-management 선임연구원 B24022 b24022@hanmaceng.co.kr
170 jhmoon2@brsw.kr 문준혁 010-2345-3362 user rnd-baron jhmoon2 infra-bim1 선임연구원 B25028 b25028@hanmaceng.co.kr
171 bslee2@brsw.kr 이배승 010-7583-8440 user rnd-baron bslee2 infra-bim1 선임연구원 B25031 b25031@hanmaceng.co.kr
172 dhseo@brsw.kr 서동해 010-6289-9590 user rnd-baron dhseo eg-bim-draw 선임연구원 B24023 b24023@hanmaceng.co.kr
173 ybkim1@brsw.kr 김영배 010-6371-1318 user rnd-baron ybkim1 primal-plan 선임연구원 B20327 b20327@hanmaceng.co.kr
174 jhchoi10@hanmaceng.co.kr 최정혁 010-4800-2603 user rnd-hanmac jhchoi10 tunnel 선임연구원 M20212 jhchoi@hanmaceng.co.kr
175 hgkim5@hanmaceng.co.kr 김한결 010-8009-6172 user rnd-hanmac hgkim5 erp 선임연구원 M22014 hgk121@hanmaceng.co.kr
176 cypark2@brsw.kr 박채영 010-4508-4006 user rnd-baron cypark2 watch-bim 연구원 B24026 b24026@hanmaceng.co.kr
177 jylee8@brsw.kr 이지율 010-8652-9029 user rnd-baron jylee8 modeler 연구원 B24021 b24021@hanmaceng.co.kr
178 shkang2@brsw.kr 강성호 010-2736-7419 user rnd-baron shkang2 way-draw 연구원 B24024 b24024@hanmaceng.co.kr
179 yclee1@hanmaceng.co.kr 이예찬 010-4748-6225 user rnd-hanmac yclee1 primal-plan 연구원 M24059 m24059@hanmaceng.co.kr
180 dgkwak@hanmaceng.co.kr 곽동권 010-6878-1926 user rnd-hanmac dgkwak infra-bim2 연구원 M24083 m24083@hanmaceng.co.kr
181 huyoon1@brsw.kr 윤현욱 010-7134-5068 user rnd-baron huyoon1 infra-bim1 연구원 B25030 b25030@hanmaceng.co.kr
182 lhkim1@brsw.kr 김이훈 010-8778-0797 user rnd-baron lhkim1 infra-bim1 연구원 B25032 b25032@hanmaceng.co.kr
183 ykshin@hanmaceng.co.kr 신영교 010-7567-2528 user rnd-hanmac ykshin infra-bim2 연구원 M24068 m24068@hanmaceng.co.kr
184 jtchoi@brsw.kr 최진태 010-6808-0921 user rnd-baron jtchoi solution-dev 연구원 B24032 b24032@hanmaceng.co.kr
185 myyang@brsw.kr 양미연 010-5523-5072 user rnd-baron myyang web-design 연구원 B25015 b25015@hanmaceng.co.kr
186 ymjo@brsw.kr 조용민 010-9490-9522 user rnd-baron ymjo infra-bim1 연구원 B25019 b25019@hanmaceng.co.kr
187 bwlee1@hanmaceng.co.kr 이병욱A 010-3286-4086 user rnd-hanmac bwlee1 infra-bim2 연구원 M25013 m25013@hanmaceng.co.kr
188 bglee2@brsw.kr 이병권 010-5097-7600 user rnd-baron bglee2 erp 연구원 B21369 b21369@hanmaceng.co.kr
189 jcjang@hanmaceng.co.kr 장종찬 010-5463-1677 user rnd-hanmac jcjang gpd 사장 M02210 jcjang67@hanmaceng.co.kr
190 hjkwon@brsw.kr 권혁진 010-8721-7453 user rnd-baron hjkwon solution-integration 수석연구원 B20304 cozyjin@hanmaceng.co.kr
191 thcho@brsw.kr 조태희 010-7588-8031 user rnd-baron thcho talent-growth 수석연구원 팀장 B22040 b22040@hanmaceng.co.kr
192 wjkim@brsw.kr 김우진A 010-3218-8381 user rnd-baron wjkim management-planning 수석연구원 팀장 J08305 j08305@hanmaceng.co.kr
193 hisung@hanmaceng.co.kr 성형일 010-2356-6633 user rnd-hanmac hisung collaboration 수석연구원 M06203 guddlf12@hanmaceng.co.kr
194 wgkim2@hanmaceng.co.kr 김원기 010-6283-6786 user rnd-hanmac wgkim2 tech-planning 수석연구원 팀장 M07318 kwongi79@hanmaceng.co.kr
195 hsryu2@brsw.kr 류호성 010-3371-5649 user rnd-baron hsryu2 erp-planning 수석연구원 팀장 M20331 m20331@hanmaceng.co.kr
196 wskim3@hanmaceng.co.kr 김원식 010-8755-6171 user rnd-hanmac wskim3 gpd 전무이사 M19202 kws69@hanmaceng.co.kr
197 jhpark13@hanmaceng.co.kr 박주한 010-8955-3850 user rnd-hanmac jhpark13 collaboration 책임연구원 M22006 m22006@hanmaceng.co.kr
198 hsmoon@hanmaceng.co.kr 문형석 010-9136-5338 user rnd-hanmac hsmoon erp-planning 책임연구원 M21420 moon79@hanmaceng.co.kr
199 smhan@hanmaceng.co.kr 한승민 010-3189-1514 user rnd-hanmac smhan collaboration 선임연구원 B23070 b23070@hanmaceng.co.kr
200 disong@brsw.kr 송대일 010-8627-0921 user rnd-baron disong erp-planning 선임연구원 B24014 b24014@hanmaceng.co.kr
201 wjryu@brsw.kr 류원준 010-9191-7771 user rnd-baron wjryu talent-growth 선임연구원 B24063 b24063@hanmaceng.co.kr
202 jykim8@hanmaceng.co.kr 김지영A 010-6389-0426 user rnd-hanmac jykim8 solution-integration 선임연구원 M21430 kjy0426@hanmaceng.co.kr
203 jypark7@hanmaceng.co.kr 박지영 010-9055-4775 user rnd-hanmac jypark7 design-planning 선임연구원 M21438 b23046@hanmaceng.co.kr
204 hrguk@pre-cast.co.kr 국혜림 010-6477-9711 user rnd-baron hrguk management-planning 선임연구원 B22038 b22038@hanmaceng.co.kr
205 hhchoi@brsw.kr 최현호 010-2279-3954 user rnd-baron hhchoi tech-planning 선임연구원 B22064 b22064@hanmaceng.co.kr
206 dhhwang@hanmaceng.co.kr 황동환 010-4242-6652 user rnd-hanmac dhhwang tech-planning 선임연구원 M19314 dhh12@hanmaceng.co.kr
207 khchoi4@brsw.kr 최근혜 010-3637-0646 user rnd-baron khchoi4 talent-growth 선임연구원 B24008 b24008@hanmaceng.co.kr
208 biyun@brsw.kr 윤봄이 010-8482-2633 user rnd-baron biyun design-planning 선임연구원 B24016 b24016@hanmaceng.co.kr
209 mylee2@brsw.kr 이미영A 010-3007-3044 user rnd-baron mylee2 management-planning 선임연구원 B22041 b22041@hanmaceng.co.kr
210 ojkwon1@hanmaceng.co.kr 권오재 010-9114-3943 user rnd-hanmac ojkwon1 erp-planning 선임연구원 M24031 m24031@hanmaceng.co.kr
211 huchoi@pre-cast.co.kr 최혜은 010-3453-2360 user rnd-baron huchoi design-planning 선임연구원 B23060 b23060@hanmaceng.co.kr
212 sychae@brsw.kr 채선영 010-9523-0055 user rnd-baron sychae design-planning 선임연구원 B24027 b24027@hanmaceng.co.kr
213 yjkim7@hanmaceng.co.kr 김윤재 010-9747-9838 user rnd-hanmac yjkim7 management-planning 선임연구원 M22047 gh.kim@hanmaceng.co.kr
214 yhchoi3@hanmaceng.co.kr 최영환 010-2905-0933 user rnd-hanmac yhchoi3 design-planning 선임연구원 B16302 cyhwan0933@hanmaceng.co.kr
215 cyjo@brsw.kr 조찬영 010-6671-2879 user rnd-baron cyjo tech-planning 연구원 B24028 b24028@hanmaceng.co.kr
216 yykim@brsw.kr 김용연 010-2777-4695 user rnd-baron yykim tech-planning 연구원 B24053 b24053@hanmaceng.co.kr
217 sblee5@brsw.kr 이새봄 010-5704-9685 user rnd-baron sblee5 erp-planning 연구원 B23018 b23018@hanmaceng.co.kr
218 shjeong@brsw.kr 정성호 010-5201-9028 user rnd-baron shjeong talent-growth 연구원 B24064 b24064@hanmaceng.co.kr
219 wgjoo@brsw.kr 주완기 010-4247-0144 user rnd-baron wgjoo talent-growth 연구원 B22067 b22067@hanmaceng.co.kr
220 syyang@brsw.kr 양숙영 010-7371-7662 user rnd-baron syyang design-planning 연구원 B24012 b24012@hanmaceng.co.kr
221 jskim12@brsw.kr 김정석 010-5209-7757 user rnd-baron jskim12 design-planning 연구원 B24049 b24049@hanmaceng.co.kr