Merge remote-tracking branch 'origin/iipbs/as/dev' into iipbs/as/dev

This commit is contained in:
2026-06-08 18:21:40 +09:00
11 changed files with 116 additions and 121 deletions
+2 -1
View File
@@ -12,10 +12,11 @@ import { rvwMnpwMngService } from "./rvw-mnpw-service";
const rvwMnpwMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW] as const;
export function useRvwMnpwMngPage(params: RvwMnpwMngSearchDto) {
export function useRvwMnpwMngPage(params: RvwMnpwMngSearchDto, options?: { enabled?: boolean }) {
return useQuery({
queryKey: [...rvwMnpwMngBaseKey, "findPage", params],
queryFn: () => rvwMnpwMngService.findPage(params),
enabled: options?.enabled,
});
}
@@ -7,6 +7,9 @@ export interface RvwMnpwMngResponseDto {
ognzSn: number;
userUuid: string;
userUuidNm?: string;
ognzNm?: string;
jbpsSeNm?: string;
jbgdSeNm?: string;
rgtr?: string;
regDt?: string;
}
@@ -4,6 +4,8 @@ export interface OgnzUserTreeUserDto {
userNm: string;
jbpsSeCd: string;
jbgdSeCd: string;
jbpsSeNm: string;
jbgdSeNm: string;
}
/** OgnzUserTreeResponseDto */
@@ -1,10 +1,7 @@
import type { ElaprAplnRow } from "../model/apln-row-types";
export const ATRZ_RECEIVER_CD = "ATRZ00";
export const ATRZ_REVIEWER_1_CD = "ATRZ01";
export const ATRZ_REVIEWER_2_CD = "ATRZ02";
export const ATRZ_APPROVER_CD = "ATRZ03";
export const ATRZ_FINAL_CD = "ATRZ99";
const ATRZ_RECEIVER_CD = "ATRZ01";
const ATRZ_FINAL_CD = "ATRZ99";
export function getVisibleRows(rows: ElaprAplnRow[]): ElaprAplnRow[] {
return rows.filter((row) => !row.isDeleted);
@@ -1,4 +1,4 @@
import type { SelectedOgnzUser } from "@/widgets/ognz-user-tree";
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
export interface ReviewerOption {
uuid: string;
@@ -6,10 +6,10 @@ export interface ReviewerOption {
deptNm: string;
}
export function fromSelectedOgnzUser(user: SelectedOgnzUser): ReviewerOption {
export function fromRvwMnpwMng(item: RvwMnpwMngResponseDto): ReviewerOption {
return {
uuid: user.userUuid,
name: user.userNm,
deptNm: user.ognzNm,
uuid: item.userUuid,
name: item.userUuidNm ?? "",
deptNm: item.ognzNm ?? "",
};
}
@@ -1,14 +1,6 @@
"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 { useRvwMnpwMngPage } from "@/entities/rvw-mnpw";
import {
Button,
Dialog,
@@ -16,113 +8,109 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { fromSelectedOgnzUser, type ReviewerOption } from "../model/reviewer-option";
import { fromRvwMnpwMng, type ReviewerOption } from "../model/reviewer-option";
const PAGE_SIZE = 500;
interface AprvrPickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
atrzSeComCd: string;
onSelect: (reviewer: ReviewerOption) => void;
}
function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogProps) {
const [keyword, setKeyword] = useState("");
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);
function AprvrPickerDialog({
open,
onOpenChange,
atrzSeComCd,
onSelect,
}: AprvrPickerDialogProps) {
const { data, isPending, isError } = useRvwMnpwMngPage(
{
atrzSeComCd,
page: 0,
size: PAGE_SIZE,
sort: "userUuidNm,asc",
},
{ enabled: open },
);
const { data, isPending, isError } = useOgnzUserTree({ enabled: open });
const items = data?.content ?? [];
const resetState = useCallback(() => {
setKeyword("");
setSelectedUserId(undefined);
setExpandedOgnzSnIds(new Set());
setIsSearchActive(false);
setDisplayTree(data ?? []);
}, [data]);
useEffect(() => {
if (!open) {
setKeyword("");
setSelectedUserId(undefined);
setExpandedOgnzSnIds(new Set());
setIsSearchActive(false);
return;
}
setDisplayTree(data ?? []);
}, [open, data]);
const handleToggle = (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();
e.stopPropagation();
const filtered = filterOgnzUserTree(data ?? [], keyword);
setDisplayTree(filtered);
setExpandedOgnzSnIds(collectExpandedOgnzSnIds(filtered));
setIsSearchActive(keyword.trim().length > 0);
};
const handleReset = () => {
resetState();
};
const handleSelect = (user: SelectedOgnzUser) => {
setSelectedUserId(user.userUuid);
onSelect(fromSelectedOgnzUser(user));
const handleSelect = (item: (typeof items)[number]) => {
onSelect(fromRvwMnpwMng(item));
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[30rem]" aria-describedby={undefined}>
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>검토인력 선택</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="card card-border !m-0 w-full">
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<Input
className="h-[2.5rem] !text-[1.3rem]"
placeholder="성명·부서 검색"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<Button type="submit" size="sm">
조회
</Button>
<Button type="button" size="sm" color="white" onClick={handleReset}>
초기화
</Button>
</form>
</div>
<div className="h-[36rem] overflow-auto">
<OgnzUserTree
nodes={displayTree}
isPending={isPending}
isError={isError}
isSearchActive={isSearchActive}
expandedOgnzSnIds={expandedOgnzSnIds}
selectedUserId={selectedUserId}
showSelectButton
onToggle={handleToggle}
onSelect={handleSelect}
/>
</div>
<div className="h-[36rem] overflow-auto">
{isPending ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem]" />
<Skeleton className="h-[16rem]" />
</div>
) : isError ? (
<p className="py-8 text-center text-lg text-muted-foreground">
검토인력 목록을 불러오지 못했습니다.
</p>
) : items.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">
배치된 검토인력이 없습니다.
</p>
) : (
<Table className="w-full">
<colgroup>
<col width="25%" />
<col width="25%" />
<col width="15%" />
<col width="15%" />
<col width="20%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>성명</TableHead>
<TableHead>부서</TableHead>
<TableHead>직위</TableHead>
<TableHead>직급</TableHead>
<TableHead>선택</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow
key={`${item.atrzSeComCd}-${item.ognzSn}-${item.userUuid}`}
>
<TableCell align="center">
{item.userUuidNm || item.userUuid}
</TableCell>
<TableCell align="center">{item.ognzNm || "-"}</TableCell>
<TableCell align="center">{item.jbpsSeNm || "-"}</TableCell>
<TableCell align="center">{item.jbgdSeNm || "-"}</TableCell>
<TableCell align="center">
<Button type="button" size="sm" onClick={() => handleSelect(item)}>
선택
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<DialogFooter>
@@ -75,6 +75,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
const isDisabled = !elaprTmpltId;
const serverRows = aplnQuery.data?.content ?? [];
const canSave = hasUnsavedChanges(rows, serverRows);
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
@@ -236,6 +237,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
<AprvrPickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
atrzSeComCd={pickingRow?.atrzSeComCd ?? ""}
onSelect={handleReviewerSelect}
/>
</section>
@@ -36,7 +36,7 @@ function SortableAplnRow({
onChange,
onPickAprvr,
}: SortableAplnRowProps) {
const { data: atrzSeCodes } = useCodeList("ATRZSECD");
const { data: atrzSeCodes } = useCodeList("ATRZSE");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: row.rowKey,
@@ -50,7 +50,7 @@ function SortableAplnRow({
};
const rowDisabled = isLocked && !isEditing;
const atrzSeLabel = row.atrzSeComNm ?? findCodeLabel(atrzSeCodes, row.atrzSeComCd);
const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
return (
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
@@ -72,7 +72,7 @@ function SortableAplnRow({
<TableCell align="center">
{isEditing ? (
<CodeSelect
upCd="ATRZSECD"
upCd="ATRZSE"
className="min-w-[120px]"
value={row.atrzSeComCd ?? ""}
onValueChange={(v) =>
@@ -4,11 +4,10 @@ export interface RvwMnpwBlockConfig {
atrzSeComCd: string;
}
/** 결재선 관리 validators.ts ATRZ 코드와 동일. ATRZSE / ATRZSECD 배포 환경 코드표 최종 확인 필요 */
/** 결재선 관리 validators.ts ATRZ 코드와 동일 */
export const RVW_MNPW_BLOCKS: RvwMnpwBlockConfig[] = [
{ id: "receiver", title: "접수", atrzSeComCd: "ATRZ00" },
// TBD: ATRZ02(검토2) 별도 블록 필요 여부 — 현재 ATRZ01만 검토 블록으로 사용
{ id: "reviewer", title: "검토", atrzSeComCd: "ATRZ01" },
{ id: "receiver", title: "접수", atrzSeComCd: "ATRZ01" },
{ id: "reviewer", title: "검토", atrzSeComCd: "ATRZ02" },
{ id: "approver", title: "승인", atrzSeComCd: "ATRZ03" },
{ id: "final", title: "최종승인", atrzSeComCd: "ATRZ99" },
];
@@ -61,7 +61,7 @@ function RvwMnpwBlock({ block, items, selectedPerson }: RvwMnpwBlockProps) {
<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>
<Badge color="gray">{items.length}</Badge>
</div>
<Button
type="button"
@@ -83,13 +83,15 @@ function RvwMnpwBlock({ block, items, selectedPerson }: RvwMnpwBlockProps) {
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">
<div className="flex min-w-0 flex-1 items-center gap-2">
<p className="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> */}
{item.ognzNm && item.jbpsSeNm && (
<span className="text-lg text-muted-foreground">
({item.ognzNm} &middot; {item.jbpsSeNm})
</span>
)}
</div>
<Button
type="button"
@@ -79,12 +79,13 @@ function OgnzUserTreeNode({
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",
"group flex cursor-pointer items-center 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>
<span className="text-xl text-muted-foreground">({user.jbpsSeNm})</span>
{showSelectButton ? (
<Button
type="button"