행정지원 검토의견 수정
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user