행정지원 검토의견 수정

This commit is contained in:
2026-10-20 10:04:42 +09:00
parent 3a5b48b608
commit 8a98cd7d19
15 changed files with 2012 additions and 1526 deletions
@@ -1,338 +1,336 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { AttachmentDownloadLink, attachService } from "@/entities/attach";
import type { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: DesignCnstnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: DesignCnstnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteOpinion, editableReviewStep, formData, rvwList],
);
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion);
if (!hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep || !canWriteOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary">
<TableHeader>
<TableRow>
<TableHead scope="row" className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
);
});
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
return (
<div className="overflow-x-auto">
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary">
<colgroup>
<col width="10%" />
<col width="55%" />
<col width="5%" />
<col width="20%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={`${item.designCnstnDocNo}_${item.rvwOpnnSn}`}>
<TableCell align="center">{item.opnrNm}</TableCell>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/>
) : (
"-"
)}
</TableCell>
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService } from "@/entities/attach";
import {
designCnstnKeys,
type DesignCnstnRvwOpnnResponseDto,
designCnstnRvwOpnnService,
} from "@/entities/design-cnstn";
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: DesignCnstnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: DesignCnstnRvwOpnnResponseDto[];
isReviewTurn: boolean;
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteOpinion, editableReviewStep, formData, rvwList],
);
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion);
if (!hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function isReviewTurn(reviewStep: ReviewStepCode) {
return editableReviewStep === reviewStep && canWriteOpinion;
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (!isReviewTurn(reviewStep)) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
variant="secondary"
>
<TableHeader>
<TableRow>
<TableHead scope="row" className="w-[12rem]">
의견내용
</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
) : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
);
});
function getRvwOpnnIds(item: DesignCnstnRvwOpnnResponseDto) {
if (!item.designCnstnDocNo || item.rvwOpnnSn == null) {
throw new Error("검토의견 식별값이 없습니다.");
}
return [item.designCnstnDocNo, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
return (
<RvwOpnnListTable
items={items}
getRowKey={(item) => `${item.designCnstnDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
isReviewTurn={isReviewTurn}
onUpdate={async (item, body) => {
await designCnstnRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
}}
onDelete={async (item) => {
await designCnstnRvwOpnnService.delete(...getRvwOpnnIds(item));
await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
}}
/>
);
}
export { TechReviewOpinionSection };
@@ -1,352 +1,347 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import {
AttachmentDownloadLink,
attachService,
useDownloadAttachmentByAtflId,
} from "@/entities/attach";
import type { FnrsCnsltnRvwOpnnResponseDto } from "@/entities/fnrs-cnsltn";
import {
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: FnrsCnsltnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return null;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
<AttachmentFileField
files={
uploadedFile
? [
{
fileKey: uploadedFile.fileKey,
fileName: uploadedFile.fileName,
onDownload: uploadedFile.atflId
? () => downloadByAtflId(uploadedFile.atflId!)
: undefined,
},
]
: []
}
inputId={`rvw-opnn-${reviewStep}-file-input`}
isUploadingByKey={isUploading}
multiple={false}
showAddButton={!uploadedFile}
addDisabled={uploading}
onAdd={(fileList) => void handleFileUpload(fileList)}
onRemove={() => void handleFileRemove()}
/>
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{/*
재원협의에서 환경공단 검토의견은 자동 생성
{renderOpinionForm("KECO")}
*/}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
return (
<div className="overflow-x-auto">
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary">
<colgroup>
<col width="10%" />
<col width="55%" />
<col width="5%" />
<col width="20%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={`${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}>
<TableCell align="center">{item.opnrNm}</TableCell>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/>
) : (
"-"
)}
</TableCell>
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService, useDownloadAttachmentByAtflId } from "@/entities/attach";
import {
fnrsCnsltnKeys,
type FnrsCnsltnRvwOpnnResponseDto,
fnrsCnsltnRvwOpnnService,
} from "@/entities/fnrs-cnsltn";
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
import {
RadioGroup,
RadioGroupItem,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: FnrsCnsltnRvwOpnnResponseDto[];
isReviewTurn: boolean;
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function isReviewTurn(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return false;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
return true;
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (!isReviewTurn(reviewStep)) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
variant="secondary"
>
<TableHeader>
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
<AttachmentFileField
files={
uploadedFile
? [
{
fileKey: uploadedFile.fileKey,
fileName: uploadedFile.fileName,
onDownload: uploadedFile.atflId
? () => downloadByAtflId(uploadedFile.atflId!)
: undefined,
},
]
: []
}
inputId={`rvw-opnn-${reviewStep}-file-input`}
isUploadingByKey={isUploading}
multiple={false}
showAddButton={!uploadedFile}
addDisabled={uploading}
onAdd={(fileList) => void handleFileUpload(fileList)}
onRemove={() => void handleFileRemove()}
/>
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? (
<TechReviewOpinionTable items={envRvwList} isReviewTurn={isReviewTurn("ENV")} />
) : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
) : null}
{/*
재원협의에서 환경공단 검토의견은 자동 생성
{renderOpinionForm("KECO")}
*/}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function getRvwOpnnIds(item: FnrsCnsltnRvwOpnnResponseDto) {
if (!item.fnrsCnsltnDocNo || item.rvwOpnnSn == null) {
throw new Error("검토의견 식별값이 없습니다.");
}
return [item.fnrsCnsltnDocNo, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
return (
<RvwOpnnListTable
items={items}
getRowKey={(item) => `${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
isReviewTurn={isReviewTurn}
onUpdate={async (item, body) => {
await fnrsCnsltnRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
}}
onDelete={async (item) => {
await fnrsCnsltnRvwOpnnService.delete(...getRvwOpnnIds(item));
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
}}
/>
);
}
export { TechReviewOpinionSection };
@@ -1,350 +1,352 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { AttachmentDownloadLink, attachService } from "@/entities/attach";
import type { InstlSncnRvwOpnnResponseDto, ReviewStepCode } from "@/entities/instl-sncn";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: InstlSncnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionFormData | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: InstlSncnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
...formData,
opnrSeComCd: editableReviewStep,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return null;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary">
<TableHeader>
<TableRow>
<TableHead scope="row" className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
return (
<div className="overflow-x-auto">
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary">
<colgroup>
<col width="10%" />
<col width="55%" />
<col width="5%" />
<col width="20%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={`${item.instlSncnDocNo}_${item.rvwOpnnSn}`}>
<TableCell align="center">{item.opnrNm}</TableCell>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/>
) : (
"-"
)}
</TableCell>
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService } from "@/entities/attach";
import {
instlSncnKeys,
type InstlSncnRvwOpnnResponseDto,
instlSncnRvwOpnnService,
type ReviewStepCode,
} from "@/entities/instl-sncn";
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: InstlSncnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionFormData | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: InstlSncnRvwOpnnResponseDto[];
isReviewTurn: boolean;
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
...formData,
opnrSeComCd: editableReviewStep,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function isReviewTurn(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return false;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
return true;
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (!isReviewTurn(reviewStep)) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
variant="secondary"
>
<TableHeader>
<TableRow>
<TableHead scope="row" className="w-[12rem]">
의견내용
</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? (
<TechReviewOpinionTable items={envRvwList} isReviewTurn={isReviewTurn("ENV")} />
) : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
) : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function getRvwOpnnIds(item: InstlSncnRvwOpnnResponseDto) {
if (!item.instlSncnDocNo || item.rvwOpnnSn == null) {
throw new Error("검토의견 식별값이 없습니다.");
}
return [item.instlSncnDocNo, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
return (
<RvwOpnnListTable
items={items}
getRowKey={(item) => `${item.instlSncnDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
isReviewTurn={isReviewTurn}
onUpdate={async (item, body) => {
await instlSncnRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
}}
onDelete={async (item) => {
await instlSncnRvwOpnnService.delete(...getRvwOpnnIds(item));
await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
}}
/>
);
}
export { TechReviewOpinionSection };
@@ -1,357 +1,356 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { AttachmentDownloadLink, attachService } from "@/entities/attach";
import type { SewerBscPlanRvwOpnnResponseDto } from "@/entities/sewer-bsc-plan";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: SewerBscPlanRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: SewerBscPlanRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return null;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
return (
<div className="overflow-x-auto">
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary">
<colgroup>
<col width="10%" />
<col width="55%" />
<col width="5%" />
<col width="20%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={`${item.sewerBscPlanDocNo}_${item.rvwOpnnSn}`}>
<TableCell align="center">{item.opnrNm}</TableCell>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/>
) : (
"-"
)}
</TableCell>
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService } from "@/entities/attach";
import {
sewerBscPlanKeys,
type SewerBscPlanRvwOpnnResponseDto,
sewerBscPlanRvwOpnnService,
} from "@/entities/sewer-bsc-plan";
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: SewerBscPlanRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: SewerBscPlanRvwOpnnResponseDto[];
isReviewTurn: boolean;
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function isReviewTurn(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return false;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
return true;
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (!isReviewTurn(reviewStep)) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
variant="secondary"
>
<TableHeader>
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? (
<TechReviewOpinionTable items={envRvwList} isReviewTurn={isReviewTurn("ENV")} />
) : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
) : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function getRvwOpnnIds(item: SewerBscPlanRvwOpnnResponseDto) {
if (!item.sewerBscPlanDocNo || item.chgCycl == null || item.rvwOpnnSn == null) {
throw new Error("검토의견 식별값이 없습니다.");
}
return [item.sewerBscPlanDocNo, item.chgCycl, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
return (
<RvwOpnnListTable
items={items}
getRowKey={(item) => `${item.sewerBscPlanDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
isReviewTurn={isReviewTurn}
onUpdate={async (item, body) => {
await sewerBscPlanRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
}}
onDelete={async (item) => {
await sewerBscPlanRvwOpnnService.delete(...getRvwOpnnIds(item));
await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
}}
/>
);
}
export { TechReviewOpinionSection };