[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
@@ -1,4 +1,4 @@
import type { OgnzUserTreeUserDto } from "@/entities/user-organization";
import type { SelectedOgnzUser } from "@/widgets/ognz-user-tree";
export interface ReviewerOption {
uuid: string;
@@ -6,10 +6,10 @@ export interface ReviewerOption {
deptNm: string;
}
export function toReviewerOption(user: OgnzUserTreeUserDto, ognzNm: string): ReviewerOption {
export function fromSelectedOgnzUser(user: SelectedOgnzUser): ReviewerOption {
return {
uuid: user.userUnqId,
uuid: user.userUuid,
name: user.userNm,
deptNm: ognzNm,
deptNm: user.ognzNm,
};
}
@@ -3,6 +3,12 @@
import { useCallback, useEffect, useState } from "react";
import { useOgnzUserTree } from "@/entities/user-organization";
import {
collectExpandedOgnzSnIds,
filterOgnzUserTree,
OgnzUserTree,
type SelectedOgnzUser,
} from "@/widgets/ognz-user-tree";
import {
Button,
Dialog,
@@ -13,10 +19,7 @@ import {
Input,
} from "@common/components";
import { collectExpandedOgnzSnIds, filterOgnzUserTree } from "../lib/filter-ognz-user-tree";
import type { ReviewerOption } from "../model/reviewer-option";
import { OgnzUserTree } from "./ognz-user-tree";
import { fromSelectedOgnzUser, type ReviewerOption } from "../model/reviewer-option";
interface AprvrPickerDialogProps {
open: boolean;
@@ -29,6 +32,7 @@ function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogPr
const [displayTree, setDisplayTree] = useState<ReturnType<typeof filterOgnzUserTree>>([]);
const [expandedOgnzSnIds, setExpandedOgnzSnIds] = useState<Set<number>>(new Set());
const [selectedUserId, setSelectedUserId] = useState<string>();
const [isSearchActive, setIsSearchActive] = useState(false);
const { data, isPending, isError } = useOgnzUserTree({ enabled: open });
@@ -36,6 +40,7 @@ function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogPr
setKeyword("");
setSelectedUserId(undefined);
setExpandedOgnzSnIds(new Set());
setIsSearchActive(false);
setDisplayTree(data ?? []);
}, [data]);
@@ -44,6 +49,7 @@ function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogPr
setKeyword("");
setSelectedUserId(undefined);
setExpandedOgnzSnIds(new Set());
setIsSearchActive(false);
return;
}
@@ -66,15 +72,16 @@ function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogPr
const filtered = filterOgnzUserTree(data ?? [], keyword);
setDisplayTree(filtered);
setExpandedOgnzSnIds(collectExpandedOgnzSnIds(filtered));
setIsSearchActive(keyword.trim().length > 0);
};
const handleReset = () => {
resetState();
};
const handleSelect = (reviewer: ReviewerOption) => {
setSelectedUserId(reviewer.uuid);
onSelect(reviewer);
const handleSelect = (user: SelectedOgnzUser) => {
setSelectedUserId(user.userUuid);
onSelect(fromSelectedOgnzUser(user));
onOpenChange(false);
};
@@ -108,8 +115,10 @@ function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogPr
nodes={displayTree}
isPending={isPending}
isError={isError}
isSearchActive={isSearchActive}
expandedOgnzSnIds={expandedOgnzSnIds}
selectedUserId={selectedUserId}
showSelectButton
onToggle={handleToggle}
onSelect={handleSelect}
/>
+1
View File
@@ -0,0 +1 @@
export { RvwMnpwMngPage } from "./ui/rvw-mnpw-mng-page";
@@ -0,0 +1,18 @@
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
import type { RvwMnpwBlockConfig } from "../model/rvw-mnpw-block-config";
export function groupRvwMnpwByAtrzSe(
items: RvwMnpwMngResponseDto[],
blocks: RvwMnpwBlockConfig[],
): Record<string, RvwMnpwMngResponseDto[]> {
const grouped = Object.fromEntries(
blocks.map((block) => [block.atrzSeComCd, [] as RvwMnpwMngResponseDto[]]),
);
for (const item of items) {
grouped[item.atrzSeComCd]?.push(item);
}
return grouped;
}
@@ -0,0 +1,16 @@
export interface RvwMnpwBlockConfig {
id: string;
title: string;
atrzSeComCd: string;
}
/** 결재선 관리 validators.ts ATRZ 코드와 동일. ATRZSE / ATRZSECD 배포 환경 코드표 최종 확인 필요 */
export const RVW_MNPW_BLOCKS: RvwMnpwBlockConfig[] = [
{ id: "receiver", title: "접수", atrzSeComCd: "ATRZ00" },
// TBD: ATRZ02(검토2) 별도 블록 필요 여부 — 현재 ATRZ01만 검토 블록으로 사용
{ id: "reviewer", title: "검토", atrzSeComCd: "ATRZ01" },
{ id: "approver", title: "승인", atrzSeComCd: "ATRZ03" },
{ id: "final", title: "최종승인", atrzSeComCd: "ATRZ99" },
];
export const RVW_MNPW_PAGE_SIZE = 500;
@@ -0,0 +1,98 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useOgnzUserTree } from "@/entities/user-organization";
import {
collectExpandedOgnzSnIds,
filterOgnzUserTree,
OgnzUserTree,
type SelectedOgnzUser,
} from "@/widgets/ognz-user-tree";
import { Button, Input } from "@common/components";
interface OgnzPanelProps {
selectedPerson: SelectedOgnzUser | null;
onSelectPerson: (person: SelectedOgnzUser) => void;
}
function OgnzPanel({ selectedPerson, onSelectPerson }: OgnzPanelProps) {
const [keyword, setKeyword] = useState("");
const [displayTree, setDisplayTree] = useState<ReturnType<typeof filterOgnzUserTree>>([]);
const [expandedOgnzSnIds, setExpandedOgnzSnIds] = useState<Set<number>>(new Set());
const [isSearchActive, setIsSearchActive] = useState(false);
const { data, isPending, isError } = useOgnzUserTree();
useEffect(() => {
setDisplayTree(data ?? []);
}, [data]);
const handleToggle = useCallback((ognzSn: number, isExpanded: boolean) => {
setExpandedOgnzSnIds((prev) => {
const next = new Set(prev);
if (isExpanded) next.add(ognzSn);
else next.delete(ognzSn);
return next;
});
}, []);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const filtered = filterOgnzUserTree(data ?? [], keyword);
setDisplayTree(filtered);
setExpandedOgnzSnIds(collectExpandedOgnzSnIds(filtered));
setIsSearchActive(keyword.trim().length > 0);
};
const handleReset = () => {
setKeyword("");
setDisplayTree(data ?? []);
setExpandedOgnzSnIds(new Set());
setIsSearchActive(false);
};
const handleSelect = (person: SelectedOgnzUser) => {
if (selectedPerson?.userUuid === person.userUuid) return;
onSelectPerson(person);
};
return (
<section className="card card-border mx-0 flex h-full flex-col pt-0">
<div className="card-header">
<h3 className="text-2xl font-semibold">조직도</h3>
</div>
<div className="flex flex-col gap-4 p-4">
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<Input
className="!text-[1.3rem]"
placeholder="성명·부서 검색"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<Button type="submit">조회</Button>
<Button type="button" color="white" onClick={handleReset}>
초기화
</Button>
</form>
<div className="min-h-[32rem] flex-1 overflow-auto">
<OgnzUserTree
nodes={displayTree}
isPending={isPending}
isError={isError}
isSearchActive={isSearchActive}
expandedOgnzSnIds={expandedOgnzSnIds}
selectedUserId={selectedPerson?.userUuid}
onToggle={handleToggle}
onSelect={handleSelect}
/>
</div>
</div>
</section>
);
}
export { OgnzPanel };
@@ -0,0 +1,63 @@
"use client";
import { useMemo } from "react";
import { useRvwMnpwMngPage } from "@/entities/rvw-mnpw";
import type { SelectedOgnzUser } from "@/widgets/ognz-user-tree";
import { Skeleton } from "@common/components";
import { groupRvwMnpwByAtrzSe } from "../lib/group-rvw-mnpw-by-atrz-se";
import { RVW_MNPW_BLOCKS, RVW_MNPW_PAGE_SIZE } from "../model/rvw-mnpw-block-config";
import { RvwMnpwBlock } from "./rvw-mnpw-block";
interface RvwMnpwAsmtPanelProps {
selectedPerson: SelectedOgnzUser | null;
}
function RvwMnpwAsmtPanel({ selectedPerson }: RvwMnpwAsmtPanelProps) {
const pageQuery = useRvwMnpwMngPage({
page: 0,
size: RVW_MNPW_PAGE_SIZE,
sort: "atrzSeComCd,asc",
});
const groupedItems = useMemo(
() => groupRvwMnpwByAtrzSe(pageQuery.data?.content ?? [], RVW_MNPW_BLOCKS),
[pageQuery.data?.content],
);
return (
<section className="card card-border mx-0 flex h-full flex-col pt-0">
<div className="card-header">
<h3 className="text-2xl font-semibold">인원 배치</h3>
</div>
<div className="flex flex-col gap-4 p-4">
{pageQuery.isPending ? (
<div className="flex flex-col gap-3">
<Skeleton className="h-[8rem]" />
<Skeleton className="h-[8rem]" />
<Skeleton className="h-[8rem]" />
<Skeleton className="h-[8rem]" />
</div>
) : pageQuery.isError ? (
<p className="py-8 text-center text-lg text-muted-foreground">
인원 배치 목록을 불러오지 못했습니다.
</p>
) : (
RVW_MNPW_BLOCKS.map((block) => (
<RvwMnpwBlock
key={block.id}
block={block}
items={groupedItems[block.atrzSeComCd] ?? []}
selectedPerson={selectedPerson}
/>
))
)}
</div>
</section>
);
}
export { RvwMnpwAsmtPanel };
@@ -0,0 +1,111 @@
"use client";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
import { useDeleteRvwMnpwMng, useSaveRvwMnpwMng } from "@/entities/rvw-mnpw";
import type { SelectedOgnzUser } from "@/widgets/ognz-user-tree";
import { Badge, Button } from "@common/components";
import type { RvwMnpwBlockConfig } from "../model/rvw-mnpw-block-config";
interface RvwMnpwBlockProps {
block: RvwMnpwBlockConfig;
items: RvwMnpwMngResponseDto[];
selectedPerson: SelectedOgnzUser | null;
}
function RvwMnpwBlock({ block, items, selectedPerson }: RvwMnpwBlockProps) {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const saveMutation = useSaveRvwMnpwMng();
const deleteMutation = useDeleteRvwMnpwMng();
const handleAssign = async () => {
if (!selectedPerson) return;
try {
await saveMutation.mutateAsync({
atrzSeComCd: block.atrzSeComCd,
ognzSn: selectedPerson.ognzSn,
userUuid: selectedPerson.userUuid,
});
alert("배치되었습니다.");
} catch (error) {
const message = error instanceof Error ? error.message : "배치에 실패했습니다.";
alert(message);
}
};
const handleRemove = async (item: RvwMnpwMngResponseDto) => {
if (!(await confirm("해당 인원을 배치에서 제거하시겠습니까?"))) return;
try {
await deleteMutation.mutateAsync({
atrzSeComCd: item.atrzSeComCd,
ognzSn: item.ognzSn,
userUuid: item.userUuid,
});
} catch (error) {
const message = error instanceof Error ? error.message : "제거에 실패했습니다.";
alert(message);
}
};
const isAssigning = saveMutation.isPending;
const isRemoving = deleteMutation.isPending;
return (
<section className="rounded-md border border-border bg-background p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h4 className="text-xl font-semibold">{block.title}</h4>
<Badge>{items.length}</Badge>
</div>
<Button
type="button"
size="sm"
color="green"
disabled={!selectedPerson || isAssigning}
onClick={handleAssign}
>
선택 인원 배치
</Button>
</div>
{items.length === 0 ? (
<p className="py-4 text-center text-lg text-muted-foreground">배치된 인원이 없습니다.</p>
) : (
<ul className="grid grid-cols-4 gap-2">
{items.map((item) => (
<li
key={`${item.atrzSeComCd}-${item.ognzSn}-${item.userUuid}`}
className="flex items-center justify-between gap-3 rounded-sm border border-border px-3 py-2"
>
<div className="min-w-0 flex-1">
{/* TBD: userUuidNm @CodeName 미적용 시 백엔드 enrichUserNames 결과만 표시 */}
<p className="truncate text-[1.4rem] font-medium text-label">
{item.userUuidNm || item.userUuid}
</p>
{/* TBD: 응답 DTO에 부서·직급 필드 없음 — ognzSn만 표시 */}
{/* <p className="text-[1.3rem] text-muted-foreground">조직 SN: {item.ognzSn}</p> */}
</div>
<Button
type="button"
size="sm"
color="white"
disabled={isRemoving}
onClick={() => handleRemove(item)}
>
제거
</Button>
</li>
))}
</ul>
)}
</section>
);
}
export { RvwMnpwBlock };
@@ -0,0 +1,24 @@
"use client";
import { useState } from "react";
import type { SelectedOgnzUser } from "@/widgets/ognz-user-tree";
import { OgnzPanel } from "./ognz-panel";
import { RvwMnpwAsmtPanel } from "./rvw-mnpw-asmt-panel";
function RvwMnpwMngPage() {
const [selectedPerson, setSelectedPerson] = useState<SelectedOgnzUser | null>(null);
return (
<div className="space-y-6 px-[1.8rem] pb-6">
{/* TBD: 역할별 CRUD 권한 — 메뉴·API 권한 연동 후 버튼 비활성 처리 */}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(280px,1fr)_minmax(0,2.5fr)]">
<OgnzPanel selectedPerson={selectedPerson} onSelectPerson={setSelectedPerson} />
<RvwMnpwAsmtPanel selectedPerson={selectedPerson} />
</div>
</div>
);
}
export { RvwMnpwMngPage };
@@ -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,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,
};
}
@@ -11,16 +11,16 @@ import {
CollapsibleTrigger,
} from "@common/components";
import type { ReviewerOption } from "../model/reviewer-option";
import { toReviewerOption } from "../model/reviewer-option";
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: (reviewer: ReviewerOption) => void;
onSelect: (user: SelectedOgnzUser) => void;
}
function OgnzUserTreeNode({
@@ -28,6 +28,7 @@ function OgnzUserTreeNode({
depth = 0,
expandedOgnzSnIds,
selectedUserId,
showSelectButton = false,
onToggle,
onSelect,
}: OgnzUserTreeNodeProps) {
@@ -71,6 +72,7 @@ function OgnzUserTreeNode({
<CollapsibleContent>
{node.users.map((user) => {
const isSelected = selectedUserId === user.userUnqId;
const selectedUser = toSelectedOgnzUser(user, node);
return (
<div
@@ -78,21 +80,23 @@ function OgnzUserTreeNode({
style={{ paddingInlineStart: indent + 24 }}
className={cn(
"group flex cursor-pointer items-center justify-between gap-2 rounded-sm p-2 hover:bg-accent",
isSelected && "bg-accent",
isSelected && "!border !border-primary !bg-primary-transparent",
)}
onClick={() => onSelect(toReviewerOption(user, node.ognzNm))}
onClick={() => onSelect(selectedUser)}
>
<span className="text-[1.4rem] text-label">{user.userNm}</span>
<Button
type="button"
size="sm"
onClick={(e) => {
e.stopPropagation();
onSelect(toReviewerOption(user, node.ognzNm));
}}
>
선택
</Button>
{showSelectButton ? (
<Button
type="button"
size="sm"
onClick={(e) => {
e.stopPropagation();
onSelect(selectedUser);
}}
>
선택
</Button>
) : null}
</div>
);
})}
@@ -104,6 +108,7 @@ function OgnzUserTreeNode({
depth={depth + 1}
expandedOgnzSnIds={expandedOgnzSnIds}
selectedUserId={selectedUserId}
showSelectButton={showSelectButton}
onToggle={onToggle}
onSelect={onSelect}
/>
@@ -3,7 +3,7 @@
import type { OgnzUserTreeResponseDto } from "@/entities/user-organization";
import { Skeleton } from "@common/components";
import type { ReviewerOption } from "../model/reviewer-option";
import type { SelectedOgnzUser } from "../model/selected-ognz-user";
import { OgnzUserTreeNode } from "./ognz-user-tree-node";
@@ -11,18 +11,22 @@ interface OgnzUserTreeProps {
nodes: OgnzUserTreeResponseDto[];
isPending?: boolean;
isError?: boolean;
isSearchActive?: boolean;
expandedOgnzSnIds: Set<number>;
selectedUserId?: string;
showSelectButton?: boolean;
onToggle: (ognzSn: number, open: boolean) => void;
onSelect: (reviewer: ReviewerOption) => void;
onSelect: (user: SelectedOgnzUser) => void;
}
function OgnzUserTree({
nodes,
isPending,
isError,
isSearchActive = false,
expandedOgnzSnIds,
selectedUserId,
showSelectButton = false,
onToggle,
onSelect,
}: OgnzUserTreeProps) {
@@ -47,7 +51,7 @@ function OgnzUserTree({
if (nodes.length === 0) {
return (
<p className="py-8 text-center text-lg text-muted-foreground">
등록된 조직/사용자가 없습니다.
{isSearchActive ? "조회된 인원이 없습니다." : "등록된 조직/사용자가 없습니다."}
</p>
);
}
@@ -60,6 +64,7 @@ function OgnzUserTree({
node={node}
expandedOgnzSnIds={expandedOgnzSnIds}
selectedUserId={selectedUserId}
showSelectButton={showSelectButton}
onToggle={onToggle}
onSelect={onSelect}
/>