Merge branch 'iipbs/as/dev'

This commit is contained in:
2026-09-21 17:50:59 +09:00
11 changed files with 362 additions and 107 deletions
@@ -0,0 +1,19 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
PwrkCnsltnDocEvlId,
PwrkCnsltnDocEvlService,
} from "../model/pwrk-cnsltn-doc-evl-interface";
import type { PwrkCnsltnDocEvlSaveRequestDto } from "../model/pwrk-cnsltn-doc-evl-payload-schema";
const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_DOC_EVL;
async function save(body: PwrkCnsltnDocEvlSaveRequestDto): Promise<PwrkCnsltnDocEvlId> {
const response = await client.post<PwrkCnsltnDocEvlId>(BASE, body);
return response.data;
}
export const pwrkCnsltnDocEvlService = {
save,
} satisfies PwrkCnsltnDocEvlService;
@@ -0,0 +1,11 @@
import { useMutation } from "@tanstack/react-query";
import type { PwrkCnsltnDocEvlSaveRequestDto } from "../model/pwrk-cnsltn-doc-evl-payload-schema";
import { pwrkCnsltnDocEvlService } from "./pwrk-cnsltn-doc-evl-service";
export function useSavePwrkCnsltnDocEvl() {
return useMutation({
mutationFn: (body: PwrkCnsltnDocEvlSaveRequestDto) => pwrkCnsltnDocEvlService.save(body),
});
}
@@ -0,0 +1,10 @@
export { pwrkCnsltnDocEvlService } from "./api/pwrk-cnsltn-doc-evl-service";
export { useSavePwrkCnsltnDocEvl } from "./api/queries";
export type {
PwrkCnsltnDocEvlId,
PwrkCnsltnDocEvlService,
} from "./model/pwrk-cnsltn-doc-evl-interface";
export {
type PwrkCnsltnDocEvlSaveRequestDto,
pwrkCnsltnDocEvlSaveRequestSchema,
} from "./model/pwrk-cnsltn-doc-evl-payload-schema";
@@ -0,0 +1,12 @@
import type { PwrkCnsltnDocEvlSaveRequestDto } from "./pwrk-cnsltn-doc-evl-payload-schema";
/** 백엔드 PwrkCnsltnDocEvlId (IdClass) */
export interface PwrkCnsltnDocEvlId {
pwrkCnsltnDocMng: string;
pwrkCnsltnDocEvlUuid: string;
}
/** Controller public 메서드명·시그니처와 동일 (save만) */
export interface PwrkCnsltnDocEvlService {
save(body: PwrkCnsltnDocEvlSaveRequestDto): Promise<PwrkCnsltnDocEvlId>;
}
@@ -0,0 +1,12 @@
import { z } from "zod";
/** 백엔드 PwrkCnsltnDocEvlSaveRequestDto */
export const pwrkCnsltnDocEvlSaveRequestSchema = z.object({
pwrkCnsltnDocNo: z.string().min(1),
pwrkCnsltnDocEvlUuid: z.string().uuid().optional(),
pwrkCnsltnDocEvlCn: z.string().max(2000).optional(),
pwrkCnsltnDocEvlScr: z.number().optional(),
pwrkCnsltnDocEvlDt: z.string().optional(),
});
export type PwrkCnsltnDocEvlSaveRequestDto = z.infer<typeof pwrkCnsltnDocEvlSaveRequestSchema>;
@@ -141,11 +141,9 @@ const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
ATRZ01: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr,
],
ATRZ02: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.costVrfc,
],
ATRZ02: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
@@ -235,6 +233,9 @@ function resolveRoleUser01AtrzButtonActions(
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileVrfc) {
return options?.canRetryFileVrfc === true;
}
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt) {
return options?.canRetryFileVrfc !== true;
}
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocCrt) {
return options?.canRetryDraftGen === true;
}
@@ -0,0 +1,32 @@
import { z } from "zod";
import { pwrkCnsltnDocEvlSaveRequestSchema } from "@/entities/pwrk-cnsltn-doc-evl";
export const PWRK_CNSLTN_DOC_EVL_SCR_OPTIONS = [
{ value: "5", label: "매우 만족" },
{ value: "4", label: "만족" },
{ value: "3", label: "보통" },
{ value: "2", label: "불만족" },
{ value: "1", label: "매우 불만족" },
] as const;
export const pwrkCnsltnDocEvlFormSchema = z.object({
pwrkCnsltnDocEvlScr: z.enum(["1", "2", "3", "4", "5"], {
required_error: "만족도를 선택해 주세요.",
invalid_type_error: "만족도를 선택해 주세요.",
}),
pwrkCnsltnDocEvlCn: z.string().max(2000).optional(),
});
export type PwrkCnsltnDocEvlFormValues = z.infer<typeof pwrkCnsltnDocEvlFormSchema>;
export function buildPwrkCnsltnDocEvlSavePayload(
pwrkCnsltnDocNo: string,
form: PwrkCnsltnDocEvlFormValues,
) {
return pwrkCnsltnDocEvlSaveRequestSchema.parse({
pwrkCnsltnDocNo,
pwrkCnsltnDocEvlScr: Number(form.pwrkCnsltnDocEvlScr),
pwrkCnsltnDocEvlCn: form.pwrkCnsltnDocEvlCn?.trim() || undefined,
});
}
@@ -709,6 +709,7 @@ function FloatingActionButtons({
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileVrfc) && (
<Button
color="gray"
onClick={handleFileVrfcClick}
disabled={isBusy}
title={fileVrfcLatest?.prcsErrCn ?? undefined}
@@ -716,6 +717,16 @@ function FloatingActionButtons({
파일 검증
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.costVrfc) && (
<Button
color="gray"
onClick={handleCostVrfcClick}
disabled={isBusy}
title={costVrfcLatest?.prcsErrCn ?? undefined}
>
금액 검증
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr) &&
!isRagIndexingInProgress(prgrsSttsComCd, data.ragIndexingInfo) && (
<Button
@@ -727,15 +738,6 @@ function FloatingActionButtons({
검토자 지정
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.costVrfc) && (
<Button
onClick={handleCostVrfcClick}
disabled={isBusy}
title={costVrfcLatest?.prcsErrCn ?? undefined}
>
금액 검증
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.actnCmptn) && (
<Button color="green" onClick={handleActnCmptnClick} disabled={isBusy}>
조치 완료
@@ -0,0 +1,136 @@
"use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAlertStore } from "@/shared/lib";
import { useSavePwrkCnsltnDocEvl } from "@/entities/pwrk-cnsltn-doc-evl";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from "@common/components";
import {
buildPwrkCnsltnDocEvlSavePayload,
PWRK_CNSLTN_DOC_EVL_SCR_OPTIONS,
pwrkCnsltnDocEvlFormSchema,
type PwrkCnsltnDocEvlFormValues,
} from "../../lib/pwrk-cnsltn-doc-evl-schema";
interface PwrkCnsltnDocEvlDialogProps {
open: boolean;
pwrkCnsltnDocNo: string;
onClose: () => void;
}
const DEFAULT_VALUES: Partial<PwrkCnsltnDocEvlFormValues> = {
pwrkCnsltnDocEvlScr: undefined,
pwrkCnsltnDocEvlCn: "",
};
function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDocEvlDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const { mutateAsync: saveEvl, isPending } = useSavePwrkCnsltnDocEvl();
const { handleSubmit, reset, setValue, watch, register } = useForm<PwrkCnsltnDocEvlFormValues>({
resolver: zodResolver(pwrkCnsltnDocEvlFormSchema),
defaultValues: DEFAULT_VALUES,
});
const pwrkCnsltnDocEvlScr = watch("pwrkCnsltnDocEvlScr");
useEffect(() => {
if (!open) return;
reset(DEFAULT_VALUES);
}, [open, reset]);
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) onClose();
}
async function onSubmit(values: PwrkCnsltnDocEvlFormValues) {
try {
const payload = buildPwrkCnsltnDocEvlSavePayload(pwrkCnsltnDocNo, values);
await saveEvl(payload);
alert("AI 초안 만족도 평가를 등록했습니다.");
onClose();
} catch (error) {
console.error("AI 초안 만족도 평가 등록 실패:", error);
alert("AI 초안 만족도 평가 등록에 실패했습니다.");
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[40rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>AI 초안 만족도 평가</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-2">
<Label>만족도</Label>
<Select
value={pwrkCnsltnDocEvlScr || undefined}
onValueChange={(value) =>
setValue(
"pwrkCnsltnDocEvlScr",
value as PwrkCnsltnDocEvlFormValues["pwrkCnsltnDocEvlScr"],
{
shouldValidate: true,
},
)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="만족도를 선택해 주세요" />
</SelectTrigger>
<SelectContent>
{PWRK_CNSLTN_DOC_EVL_SCR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="pwrkCnsltnDocEvlCn">의견</Label>
<Textarea
id="pwrkCnsltnDocEvlCn"
rows={4}
placeholder="의견을 입력해 주세요 (선택)"
{...register("pwrkCnsltnDocEvlCn")}
/>
</div>
<DialogFooter>
<Button type="button" color="white" onClick={onClose} disabled={isPending}>
닫기
</Button>
<Button type="submit" disabled={isPending}>
제출
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export { PwrkCnsltnDocEvlDialog };
export type { PwrkCnsltnDocEvlDialogProps };
@@ -33,6 +33,7 @@ import {
Skeleton,
} from "@common/components";
import { PwrkCnsltnDocEvlDialog } from "./pwrk-cnsltn-doc-evl-dialog";
import { PwrkCnsltnDocPjtcoAjmtTable } from "./pwrk-cnsltn-doc-pjtco-ajmt-table";
const HangulGiangi = dynamic(
@@ -45,6 +46,8 @@ const HangulGiangi = dynamic(
const PWRK_ATCH_FILE_KEY_PREFIX = "pwrk-cnsltn-doc::";
const AI_DRAFT_EVL_MAX_VER_SN = 2;
const APPROVER_ATRZ_SE_COM_CDS = new Set(["ATRZ03", "ATRZ99"]);
const VIEW_ONLY_PRGRS_STTS_COM_CDS = new Set(["TP", "RA", "AV", "EV", "RR", "EP", "TA"]);
@@ -82,6 +85,7 @@ function PwrkCnsltnDocMngDialog({
const [atchFileGroupId, setAtchFileGroupId] = useState("");
const [uploadedFiles, setUploadedFiles] = useState<UploadedAtchFile[]>([]);
const [isPjtcoAjmtTableOpen, setIsPjtcoAjmtTableOpen] = useState(false);
const [isEvlDialogOpen, setIsEvlDialogOpen] = useState(false);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
const {
@@ -138,6 +142,7 @@ function PwrkCnsltnDocMngDialog({
if (!open) {
setAtchFileGroupId("");
setUploadedFiles([]);
setIsEvlDialogOpen(false);
return;
}
@@ -194,6 +199,10 @@ function PwrkCnsltnDocMngDialog({
}),
]);
alert(`실무협의서를 저장했습니다. (버전 ${savedDocument.verSn})`);
if (savedDocument.verSn <= AI_DRAFT_EVL_MAX_VER_SN) {
setIsEvlDialogOpen(true);
}
}
async function handleDownload() {
@@ -269,105 +278,115 @@ function PwrkCnsltnDocMngDialog({
const canSave = !viewOnly && (canSaveProp ?? canSaveByAtrz);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-h-[96vh] max-w-[80vw] overflow-y-auto"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>실무협의서 관리</DialogTitle>
</DialogHeader>
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-h-[96vh] max-w-[80vw] overflow-y-auto"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>실무협의서 관리</DialogTitle>
</DialogHeader>
{isLoading ? <Skeleton className="h-[70vh] min-h-[40rem] w-full" /> : null}
{isError ? (
<p className="py-16 text-center text-lg text-muted-foreground">
실무협의서 정보를 불러오지 못했습니다.
</p>
) : null}
{!isLoading && !isError && !completedDraft ? (
<p className="py-16 text-center text-lg text-muted-foreground">
생성 완료된 실무협의서 초안이 없습니다.
</p>
) : null}
{completedDraft && documentBlob ? (
<div className="flex min-h-[40rem] items-stretch gap-2">
<div className="min-w-0 flex-1">
<HangulGiangi
documentBlob={documentBlob}
documentKey={completedDraft.crtDmndUuid}
documentFileName={documentFileName}
canSave={canSave}
onSaveDocument={handleSaveDocument}
onDownload={handleDownload}
/>
{isLoading ? <Skeleton className="h-[70vh] min-h-[40rem] w-full" /> : null}
{isError ? (
<p className="py-16 text-center text-lg text-muted-foreground">
실무협의서 정보를 불러오지 못했습니다.
</p>
) : null}
{!isLoading && !isError && !completedDraft ? (
<p className="py-16 text-center text-lg text-muted-foreground">
생성 완료된 실무협의서 초안이 없습니다.
</p>
) : null}
{completedDraft && documentBlob ? (
<div className="flex min-h-[40rem] items-stretch gap-2">
<div className="min-w-0 flex-1">
<HangulGiangi
documentBlob={documentBlob}
documentKey={completedDraft.crtDmndUuid}
documentFileName={documentFileName}
canSave={canSave}
onSaveDocument={handleSaveDocument}
onDownload={handleDownload}
/>
</div>
{pjtcoAjmtPage?.content?.length ? (
isPjtcoAjmtTableOpen ? (
<section className="flex h-[calc(64vh+4rem)] w-0 flex-[0.4] flex-col overflow-hidden rounded-md border border-border">
<header className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<h3 className="text-xl font-semibold">금액검증결과</h3>
<button
type="button"
className="rounded-sm border border-border p-2 hover:opacity-90"
onClick={() => setIsPjtcoAjmtTableOpen(false)}
aria-label="접기"
>
<PanelRightClose className="size-6" />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<PwrkCnsltnDocPjtcoAjmtTable
rows={pjtcoAjmtPage?.content ?? []}
isLoading={isPjtcoAjmtLoading}
isError={isPjtcoAjmtError}
/>
</div>
</section>
) : (
<button
type="button"
className="flex items-center gap-1 self-start rounded-sm border border-border px-1 py-2 hover:opacity-90"
style={{ writingMode: "vertical-rl" }}
onClick={() => setIsPjtcoAjmtTableOpen(true)}
aria-label="펼치기"
>
<PanelRightOpen className="size-5" />
<span className="text-xl tracking-wide">금액검증결과</span>
</button>
)
) : null}
</div>
{pjtcoAjmtPage?.content?.length ? (
isPjtcoAjmtTableOpen ? (
<section className="flex h-[calc(64vh+4rem)] w-0 flex-[0.4] flex-col overflow-hidden rounded-md border border-border">
<header className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<h3 className="text-xl font-semibold">금액검증결과</h3>
<button
type="button"
className="rounded-sm border border-border p-2 hover:opacity-90"
onClick={() => setIsPjtcoAjmtTableOpen(false)}
aria-label="접기"
>
<PanelRightClose className="size-6" />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<PwrkCnsltnDocPjtcoAjmtTable
rows={pjtcoAjmtPage?.content ?? []}
isLoading={isPjtcoAjmtLoading}
isError={isPjtcoAjmtError}
/>
</div>
</section>
) : (
<button
type="button"
className="flex items-center gap-1 self-start rounded-sm border border-border px-1 py-2 hover:opacity-90"
style={{ writingMode: "vertical-rl" }}
onClick={() => setIsPjtcoAjmtTableOpen(true)}
aria-label="펼치기"
>
<PanelRightOpen className="size-5" />
<span className="text-xl tracking-wide">금액검증결과</span>
</button>
)
) : null}
</div>
) : null}
) : null}
{completedDraft ? (
<section className="space-y-2 rounded-md border border-border p-4">
<h3 className="text-xl font-semibold">첨부파일</h3>
{uploadedFiles.length === 0 ? (
<p className="text-lg text-muted-foreground">등록된 첨부파일이 없습니다.</p>
) : null}
<AttachmentFileField
files={uploadedFiles.map((item) => ({
fileKey: item.fileKey,
fileName: item.fileName,
onDownload: item.atflId ? () => downloadByAtflId(item.atflId!) : undefined,
}))}
inputId="pwrk-cnsltn-doc-atch-file-input"
isUploadingByKey={isUploading}
addDisabled={!canSave || isAnyAtchUploading}
showAddButton={canSave}
onAdd={(files) => void handleAtchFileUpload(files)}
onRemove={canSave ? (index) => void handleAtchFileRemove(index) : undefined}
/>
</section>
) : null}
{completedDraft ? (
<section className="space-y-2 rounded-md border border-border p-4">
<h3 className="text-xl font-semibold">첨부파일</h3>
{uploadedFiles.length === 0 ? (
<p className="text-lg text-muted-foreground">등록된 첨부파일이 없습니다.</p>
) : null}
<AttachmentFileField
files={uploadedFiles.map((item) => ({
fileKey: item.fileKey,
fileName: item.fileName,
onDownload: item.atflId ? () => downloadByAtflId(item.atflId!) : undefined,
}))}
inputId="pwrk-cnsltn-doc-atch-file-input"
isUploadingByKey={isUploading}
addDisabled={!canSave || isAnyAtchUploading}
showAddButton={canSave}
onAdd={(files) => void handleAtchFileUpload(files)}
onRemove={canSave ? (index) => void handleAtchFileRemove(index) : undefined}
/>
</section>
) : null}
<DialogFooter>
<Button type="button" color="white" onClick={onClose}>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DialogFooter>
<Button type="button" color="white" onClick={onClose}>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{completedDraft ? (
<PwrkCnsltnDocEvlDialog
open={isEvlDialogOpen}
pwrkCnsltnDocNo={completedDraft.pwrkCnsltnDocNo}
onClose={() => setIsEvlDialogOpen(false)}
/>
) : null}
</>
);
}
+1
View File
@@ -30,6 +30,7 @@ export const ENDPOINT = {
PWRK_CNSLTN_MNG: "/as-service/api/v1/pwrk-cnsltn-mng",
PWRK_CNSLTN_DOC_CRT_RSLT: "/as-service/api/v1/pwrk-cnsltn-doc-crt-rslt",
PWRK_CNSLTN_DOC_PJTCO_AJMT_MNG: "/as-service/api/v1/pwrk-cnsltn-doc-pjtco-ajmt-mng",
PWRK_CNSLTN_DOC_EVL: "/as-service/api/v1/pwrk-cnsltn-doc-evl",
},
USER_SERVICE: {
LOGIN: "/user-service/login",