[as] feat: 검토인력관리 페이지 구현

This commit is contained in:
2026-06-05 14:32:14 +09:00
parent 0947b76a6e
commit acaf2dd9f4
17 changed files with 845 additions and 126 deletions
@@ -0,0 +1,4 @@
export { collectExpandedOgnzSnIds, filterOgnzUserTree } from "./lib/filter-ognz-user-tree";
export { type SelectedOgnzUser, toSelectedOgnzUser } from "./model/selected-ognz-user";
export { OgnzUserTree } from "./ui/ognz-user-tree";
export { OgnzUserTreeNode } from "./ui/ognz-user-tree-node";
@@ -0,0 +1,44 @@
import type { OgnzUserTreeResponseDto } from "@/entities/user-organization";
export function filterOgnzUserTree(
nodes: OgnzUserTreeResponseDto[],
keyword: string,
): OgnzUserTreeResponseDto[] {
const q = keyword.trim().toLowerCase();
if (!q) return nodes;
return nodes
.map((node) => {
const ognzMatches = node.ognzNm.toLowerCase().includes(q);
const filteredUsers = ognzMatches
? node.users
: node.users.filter((user) => user.userNm.toLowerCase().includes(q));
const filteredChildren = filterOgnzUserTree(node.children ?? [], keyword);
if (ognzMatches || filteredUsers.length > 0 || filteredChildren.length > 0) {
return {
...node,
users: filteredUsers,
children: filteredChildren,
};
}
return null;
})
.filter((node): node is OgnzUserTreeResponseDto => node !== null);
}
export function collectExpandedOgnzSnIds(nodes: OgnzUserTreeResponseDto[]): Set<number> {
const ids = new Set<number>();
function walk(list: OgnzUserTreeResponseDto[]) {
for (const node of list) {
const hasContent = (node.users?.length ?? 0) > 0 || (node.children?.length ?? 0) > 0;
if (hasContent) ids.add(node.ognzSn);
walk(node.children ?? []);
}
}
walk(nodes);
return ids;
}
@@ -0,0 +1,24 @@
import type { OgnzUserTreeResponseDto, OgnzUserTreeUserDto } from "@/entities/user-organization";
export interface SelectedOgnzUser {
userUuid: string;
userNm: string;
ognzSn: number;
ognzNm: string;
jbgdSeCd?: string;
jbpsSeCd?: string;
}
export function toSelectedOgnzUser(
user: OgnzUserTreeUserDto,
node: Pick<OgnzUserTreeResponseDto, "ognzSn" | "ognzNm">,
): SelectedOgnzUser {
return {
userUuid: user.userUnqId,
userNm: user.userNm,
ognzSn: node.ognzSn,
ognzNm: node.ognzNm,
jbgdSeCd: user.jbgdSeCd || undefined,
jbpsSeCd: user.jbpsSeCd || undefined,
};
}
@@ -0,0 +1,121 @@
"use client";
import { ChevronRight } from "lucide-react";
import type { OgnzUserTreeResponseDto } from "@/entities/user-organization";
import {
Button,
cn,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@common/components";
import { type SelectedOgnzUser, toSelectedOgnzUser } from "../model/selected-ognz-user";
interface OgnzUserTreeNodeProps {
node: OgnzUserTreeResponseDto;
depth?: number;
expandedOgnzSnIds: Set<number>;
selectedUserId?: string;
showSelectButton?: boolean;
onToggle: (ognzSn: number, open: boolean) => void;
onSelect: (user: SelectedOgnzUser) => void;
}
function OgnzUserTreeNode({
node,
depth = 0,
expandedOgnzSnIds,
selectedUserId,
showSelectButton = false,
onToggle,
onSelect,
}: OgnzUserTreeNodeProps) {
const hasUsers = (node.users?.length ?? 0) > 0;
const hasChildren = (node.children?.length ?? 0) > 0;
const isExpandable = hasUsers || hasChildren;
const isOpen = expandedOgnzSnIds.has(node.ognzSn);
const indent = depth * 16 + 8;
if (!isExpandable) {
return (
<div style={{ paddingInlineStart: indent }} className="py-1">
<span className="text-[1.4rem] font-medium text-label">{node.ognzNm}</span>
</div>
);
}
return (
<Collapsible
open={isOpen}
onOpenChange={(open) => onToggle(node.ognzSn, open)}
className="w-full"
>
<div style={{ paddingInlineStart: indent }}>
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-2 rounded-sm p-2 text-left hover:bg-accent"
>
<ChevronRight
className={cn(
"size-5 shrink-0 text-label transition-transform",
isOpen && "rotate-90",
)}
/>
<span className="text-[1.4rem] font-medium text-label">{node.ognzNm}</span>
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
{node.users.map((user) => {
const isSelected = selectedUserId === user.userUnqId;
const selectedUser = toSelectedOgnzUser(user, node);
return (
<div
key={user.userUnqId}
style={{ paddingInlineStart: indent + 24 }}
className={cn(
"group flex cursor-pointer items-center justify-between gap-2 rounded-sm p-2 hover:bg-accent",
isSelected && "!border !border-primary !bg-primary-transparent",
)}
onClick={() => onSelect(selectedUser)}
>
<span className="text-[1.4rem] text-label">{user.userNm}</span>
{showSelectButton ? (
<Button
type="button"
size="sm"
onClick={(e) => {
e.stopPropagation();
onSelect(selectedUser);
}}
>
선택
</Button>
) : null}
</div>
);
})}
{node.children.map((child) => (
<OgnzUserTreeNode
key={child.ognzSn}
node={child}
depth={depth + 1}
expandedOgnzSnIds={expandedOgnzSnIds}
selectedUserId={selectedUserId}
showSelectButton={showSelectButton}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</CollapsibleContent>
</Collapsible>
);
}
export { OgnzUserTreeNode };
@@ -0,0 +1,76 @@
"use client";
import type { OgnzUserTreeResponseDto } from "@/entities/user-organization";
import { Skeleton } from "@common/components";
import type { SelectedOgnzUser } from "../model/selected-ognz-user";
import { OgnzUserTreeNode } from "./ognz-user-tree-node";
interface OgnzUserTreeProps {
nodes: OgnzUserTreeResponseDto[];
isPending?: boolean;
isError?: boolean;
isSearchActive?: boolean;
expandedOgnzSnIds: Set<number>;
selectedUserId?: string;
showSelectButton?: boolean;
onToggle: (ognzSn: number, open: boolean) => void;
onSelect: (user: SelectedOgnzUser) => void;
}
function OgnzUserTree({
nodes,
isPending,
isError,
isSearchActive = false,
expandedOgnzSnIds,
selectedUserId,
showSelectButton = false,
onToggle,
onSelect,
}: OgnzUserTreeProps) {
if (isPending) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-3/4" />
</div>
);
}
if (isError) {
return (
<p className="py-8 text-center text-lg text-muted-foreground">
조직 사용자 목록을 불러오지 못했습니다.
</p>
);
}
if (nodes.length === 0) {
return (
<p className="py-8 text-center text-lg text-muted-foreground">
{isSearchActive ? "조회된 인원이 없습니다." : "등록된 조직/사용자가 없습니다."}
</p>
);
}
return (
<div className="flex flex-col">
{nodes.map((node) => (
<OgnzUserTreeNode
key={node.ognzSn}
node={node}
expandedOgnzSnIds={expandedOgnzSnIds}
selectedUserId={selectedUserId}
showSelectButton={showSelectButton}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</div>
);
}
export { OgnzUserTree };