335 lines
10 KiB
TypeScript
335 lines
10 KiB
TypeScript
"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[];
|
|
}
|
|
|
|
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} />
|
|
) : 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 }: TechReviewOpinionTableProps) {
|
|
const queryClient = useQueryClient();
|
|
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
|
|
|
|
return (
|
|
<RvwOpnnListTable
|
|
items={items}
|
|
getRowKey={(item) => `${item.designCnstnDocNo}_${item.rvwOpnnSn}`}
|
|
currentUserUuid={currentUserUuid}
|
|
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 };
|