[as] feat: 행정지원 문서 파일 첨부 로직 수정

This commit is contained in:
2026-08-25 09:42:05 +09:00
parent 4bd5276fc5
commit a4c14433cd
26 changed files with 270 additions and 714 deletions
@@ -16,7 +16,14 @@ async function downloadAttachmentByAtflCd(atflCd: string): Promise<AxiosResponse
});
}
async function downloadAttachmentByAtflId(atflId: string): Promise<AxiosResponse> {
return await client.get<Blob>(`${ENDPOINT.ATTACH_SERVICE.DOWNLOAD_BY_ATFL_ID}/${atflId}`, {
responseType: "blob",
});
}
export const attachService = {
deleteAttachmentByAtflId,
downloadAttachmentByAtflCd,
downloadAttachmentByAtflId,
} satisfies AttachService;
+28 -13
View File
@@ -1,4 +1,5 @@
import { useMutation } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { ENDPOINT } from "@/shared/config";
@@ -8,6 +9,22 @@ import { attachService } from "./attach-service";
const attachBaseKey = [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS] as const;
const attachDownloadBaseKey = [ENDPOINT.ATTACH_SERVICE.DOWNLOAD] as const;
const attachDownloadByAtflIdKey = [ENDPOINT.ATTACH_SERVICE.DOWNLOAD_BY_ATFL_ID] as const;
function triggerBlobDownload(response: AxiosResponse) {
const filename = parseFilenameFromResponse(response);
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
}
export function useDeleteAttachment() {
return useMutation({
@@ -21,19 +38,17 @@ export function useDownloadAttachmentByAtflCd() {
mutationKey: [...attachDownloadBaseKey, "download"],
mutationFn: async (atflCd: string) => {
const response = await attachService.downloadAttachmentByAtflCd(atflCd);
const filename = parseFilenameFromResponse(response);
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
triggerBlobDownload(response);
},
});
}
export function useDownloadAttachmentByAtflId() {
return useMutation({
mutationKey: [...attachDownloadByAtflIdKey, "download"],
mutationFn: async (atflId: string) => {
const response = await attachService.downloadAttachmentByAtflId(atflId);
triggerBlobDownload(response);
},
});
}
+5 -1
View File
@@ -1,3 +1,7 @@
export { attachService } from "./api/attach-service";
export { useDeleteAttachment, useDownloadAttachmentByAtflCd } from "./api/queries";
export {
useDeleteAttachment,
useDownloadAttachmentByAtflCd,
useDownloadAttachmentByAtflId,
} from "./api/queries";
export type { Attach, AttachService } from "./model/attach-interface";
@@ -19,4 +19,5 @@ export interface Attach {
export interface AttachService {
deleteAttachmentByAtflId: (atflId: string) => Promise<void>;
downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>;
downloadAttachmentByAtflId: (atflId: string) => Promise<AxiosResponse>;
}
@@ -1,23 +1,10 @@
import type { IUserInfo } from "@/shared/store";
import type { DesignCnstnMngDetResponseDto } from "@/entities/design-cnstn";
import { toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt";
import type { DesignCnstnRegistFormData } from "./design-cnstn-regist-schema";
import { parseTaskSeComCdFromApi } from "./design-cnstn-regist-schema";
function mapEsntlAtchFileList(esntlFileList: DesignCnstnMngDetResponseDto["esntlFileList"]) {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList ?? []) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function mapDetailToFormData(data: DesignCnstnMngDetResponseDto): DesignCnstnRegistFormData {
const detDto = data.detDto;
if (!detDto) {
@@ -63,7 +50,7 @@ export function mapDetailToFormData(data: DesignCnstnMngDetResponseDto): DesignC
thtiDsgn: item.tstmDesignQty,
diff: item.tstmDesignQty - item.bgtRfltQty,
})),
esntlAtchFileList: mapEsntlAtchFileList(data.esntlFileList),
esntlAtchFileList: toEsntlAtchFileSaveList(data.esntlFileList),
privacyConsent: "Y",
};
}
@@ -3,9 +3,8 @@ import type {
BzScaleItem,
DesignCnstnApplySaveRequestDto,
DesignCnstnMngDetResponseDto,
EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
} from "@/entities/design-cnstn";
import { toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt";
import type { ReviewStepCode } from "../model/view-types";
@@ -22,22 +21,6 @@ export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] {
}));
}
export function toEsntlAtchFileSaveDto(
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
): EsntlAtchFileItem[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function buildSavePayload(
data: DesignCnstnMngDetResponseDto,
prgrsSttsComCd: string,
@@ -50,7 +33,7 @@ export function buildSavePayload(
return {
designCnstnMng: { ...detDto, prgrsSttsComCd },
bzScaleList: toBzScaleSaveDto(data.bizSclList ?? []),
esntlAtchFileList: toEsntlAtchFileSaveDto(data.esntlFileList ?? []),
esntlAtchFileList: toEsntlAtchFileSaveList(data.esntlFileList),
};
}
@@ -6,7 +6,7 @@ import { useUserStore } from "@/shared/lib";
import { useDesignCnstnMng } from "@/entities/design-cnstn";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { ProgressStatusSection } from "@/widgets/pbadms-sprt";
import { AttachmentsViewSection, ProgressStatusSection } from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
@@ -14,7 +14,6 @@ import { getEditableReviewStep } from "../lib/review-step-utils";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { FloatingActionButtons } from "./components/floating-action-buttons";
import { AttachmentsSection } from "./sections/attachments-section";
import { BasicInfoSection } from "./sections/basic-info-section";
import { BusinessScaleSection } from "./sections/business-scale-section";
import { ContactInfoSection } from "./sections/contact-info-section";
@@ -91,7 +90,7 @@ function DesignViewPage({ id }: DesignViewPageProps) {
<ContactInfoSection det={detDto} />
<ElaprDocPrgrsStatus elaprDocNo={detDto.designCnstnDocNo} />
<BusinessScaleSection bizSclList={bizSclList} />
<AttachmentsSection esntlFileList={esntlFileList} etcFileList={etcFileList} />
<AttachmentsViewSection esntlFileList={esntlFileList} etcFileList={etcFileList} />
<TechReviewOpinionSection
ref={techReviewOpinionRef}
rvwList={rvwList}
@@ -1,114 +0,0 @@
"use client";
import { formatFileSize } from "@/shared/lib/file-utils";
import { SectionCollapsible } from "@/shared/ui";
import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
import type {
AtflMngClientResponseDto,
EsntlAtchFileMngTreeResponseDto,
} from "@/entities/design-cnstn";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface AttachmentsSectionProps {
esntlFileList: EsntlAtchFileMngTreeResponseDto[];
etcFileList: AtflMngClientResponseDto[];
}
function AttachmentsSection({ esntlFileList, etcFileList }: AttachmentsSectionProps) {
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
return (
<>
{esntlFileList.length > 0 && (
<SectionCollapsible title="필수 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>필수 자료명</TableHead>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{esntlFileList.map((file) => (
<TableRow key={file.esntlAtchFileId}>
<TableCell className="pl-2 text-left">
{file.esntlAtchFileNm}
{file.esntlAtchYn === "Y" ? (
<span className="ml-1 text-red-500">*</span>
) : null}
</TableCell>
<TableCell className="text-left">
{file.atflCd ? (
<a
className="cursor-pointer text-blue-600 hover:underline"
onClick={() => downloadAttachment(file.atflCd ?? "")}
target="_blank"
rel="noreferrer"
>
{file.orgnlFileNm || "다운로드"}
</a>
) : (
<span className="text-gray-500">첨부없음</span>
)}
</TableCell>
<TableCell className="text-right">{formatFileSize(file.atflFlieSz)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionCollapsible>
)}
{etcFileList.length > 0 && (
<SectionCollapsible title="기타 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{etcFileList.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="py-12 text-center text-muted-foreground">
등록된 기타첨부파일이 없습니다.
</TableCell>
</TableRow>
) : (
etcFileList.map((file) => (
<TableRow key={file.atflId}>
<TableCell className="text-left">
{file.atflCd ? (
<a
className="cursor-pointer text-blue-600 hover:underline"
onClick={() => downloadAttachment(file.atflCd)}
target="_blank"
rel="noreferrer"
>
{file.orgnlFileNm || file.physFileNm || file.atflId}
</a>
) : (
<span>{file.orgnlFileNm || file.physFileNm || file.atflId}</span>
)}
</TableCell>
<TableCell className="text-right text-gray-500">
{formatFileSize(file.atflFlieSz)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</SectionCollapsible>
)}
</>
);
}
export { AttachmentsSection };
@@ -1,6 +1,6 @@
import type { IUserInfo } from "@/shared/store";
import type { FnrsCnsltnMngDetResponseDto } from "@/entities/fnrs-cnsltn";
import { mapFnrsSharDetToFormList } from "@/widgets/pbadms-sprt";
import { mapFnrsSharDetToFormList, toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt";
import type { FnrsCnsltnRegistFormData } from "./fnrs-cnsltn-regist-schema";
import { parseTaskSeComCdFromApi } from "./fnrs-cnsltn-regist-schema";
@@ -10,20 +10,6 @@ function mapSelectedValue(value?: string | null): "Y" | "N" | null {
return null;
}
function mapEsntlAtchFileList(esntlFileList: FnrsCnsltnMngDetResponseDto["esntlFileList"]) {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList ?? []) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function mapDetailToFormData(data: FnrsCnsltnMngDetResponseDto): FnrsCnsltnRegistFormData {
const detDto = data.detDto;
if (!detDto) {
@@ -94,7 +80,7 @@ export function mapDetailToFormData(data: FnrsCnsltnMngDetResponseDto): FnrsCnsl
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
picRvwCn: item.picRvwCn,
})) ?? [],
esntlAtchFileList: mapEsntlAtchFileList(esntlFileList),
esntlAtchFileList: toEsntlAtchFileSaveList(esntlFileList),
splmntDmndList:
splmntDmndList?.map((item) => ({
splmntDmndSn: item.splmntDmndSn,
@@ -4,8 +4,6 @@ import type {
BizSclTreeDto,
BzScaleItem,
CklstResultItem,
EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngResponseDto,
@@ -14,6 +12,7 @@ import type {
import {
mapFnrsSharDetToFormList,
type SplmntDmndDialogConfirmPayload,
toEsntlAtchFileSaveList,
toFnrsSharSaveList,
} from "@/widgets/pbadms-sprt";
@@ -44,22 +43,6 @@ export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): BizCstItem[] {
}));
}
export function toEsntlAtchFileSaveDto(
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
): EsntlAtchFileItem[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function buildSavePayload(
data: FnrsCnsltnMngDetResponseDto,
checklistFormState: ChecklistFormState,
@@ -81,7 +64,7 @@ export function buildSavePayload(
bzScaleList: toBzScaleSaveDto(bizSclList),
bizCstList: toBizCstSaveDto(bizCstList),
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
esntlAtchFileList: toEsntlAtchFileSaveList(esntlFileList),
fnrsSharList: toFnrsSharSaveList(mapFnrsSharDetToFormList(data.fnrsSharList ?? [])),
};
}
@@ -6,7 +6,7 @@ import { useUserStore } from "@/shared/lib";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useFnrsCnsltnMng } from "@/entities/fnrs-cnsltn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { ProgressStatusSection, SupplementDemandSection } from "@/widgets/pbadms-sprt";
import { AttachmentsViewSection, ProgressStatusSection, SupplementDemandSection } from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
@@ -15,7 +15,6 @@ import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-check
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { FloatingActionButtons } from "./components/floating-action-buttons";
import { AttachmentsSection } from "./sections/attachments-section";
import { BasicInfoSection } from "./sections/basic-info-section";
import { BusinessCostSection } from "./sections/business-cost-section";
import { BusinessScaleSection } from "./sections/business-scale-section";
@@ -110,7 +109,7 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
formState={checklistFormState}
setFormState={setChecklistFormState}
/>
<AttachmentsSection esntlFileList={esntlFileList} etcFileList={etcFileList} />
<AttachmentsViewSection esntlFileList={esntlFileList} etcFileList={etcFileList} />
<SupplementDemandSection splmntDmndList={splmntDmndList} />
<TechReviewOpinionSection
ref={techReviewOpinionRef}
@@ -1,124 +0,0 @@
"use client";
import { formatFileSize } from "@/shared/lib/file-utils";
import { SectionCollapsible } from "@/shared/ui";
import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
import type {
AtflMngClientResponseDto,
EsntlAtchFileMngTreeResponseDto,
} from "@/entities/fnrs-cnsltn";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface AttachmentsSectionProps {
esntlFileList: EsntlAtchFileMngTreeResponseDto[];
etcFileList: AtflMngClientResponseDto[];
}
function AttachmentsSection({ esntlFileList, etcFileList }: AttachmentsSectionProps) {
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
return (
<>
{esntlFileList.length > 0 && (
<SectionCollapsible title="필수 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>필수 자료명</TableHead>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{esntlFileList.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
조회된 필수 첨부파일 항목이 없습니다.
</TableCell>
</TableRow>
) : (
esntlFileList.map((file) => (
<TableRow key={file.esntlAtchFileId}>
<TableCell className="pl-2 text-left">
{file.esntlAtchFileNm}
{file.esntlAtchYn === "Y" ? (
<span className="ml-1 text-red-500">*</span>
) : null}
</TableCell>
<TableCell className="text-left">
{file.atflCd ? (
<a
className="cursor-pointer text-blue-600 hover:underline"
onClick={() => downloadAttachment(file.atflCd ?? "")}
target="_blank"
rel="noreferrer"
>
{file.orgnlFileNm || "다운로드"}
</a>
) : (
<span className="text-gray-500">첨부없음</span>
)}
</TableCell>
<TableCell className="text-right">
{formatFileSize(file.atflFlieSz)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</SectionCollapsible>
)}
{etcFileList.length > 0 && (
<SectionCollapsible title="기타 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{etcFileList.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="py-12 text-center text-muted-foreground">
등록된 기타첨부파일이 없습니다.
</TableCell>
</TableRow>
) : (
etcFileList.map((file) => (
<TableRow key={file.atflId}>
<TableCell className="text-left">
{file.atflCd ? (
<a
className="cursor-pointer text-blue-600 hover:underline"
onClick={() => downloadAttachment(file.atflCd)}
target="_blank"
rel="noreferrer"
>
{file.orgnlFileNm || file.physFileNm || file.atflId}
</a>
) : (
<span>{file.orgnlFileNm || file.physFileNm || file.atflId}</span>
)}
</TableCell>
<TableCell className="text-right text-gray-500">
{formatFileSize(file.atflFlieSz)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</SectionCollapsible>
)}
</>
);
}
export { AttachmentsSection };
@@ -1,5 +1,6 @@
import type { IUserInfo } from "@/shared/store/user-store";
import type { InstlSncnMngDetResponse } from "@/entities/instl-sncn";
import { toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt";
import type { InstlSncnRegistFormData } from "./instl-sncn-regist-schema";
import { parseTaskSeComCdFromApi } from "./instl-sncn-regist-schema";
@@ -9,20 +10,6 @@ function mapSelectedValue(value?: string | null): "Y" | "N" | null {
return null;
}
function mapEsntlAtchFileList(esntlFileList: InstlSncnMngDetResponse["esntlFileList"]) {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnRegistFormData {
const { detDto, bizSclList, bizCstList, cklstResultList, esntlFileList, splmntDmndList } = data;
@@ -84,7 +71,7 @@ export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnReg
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
picRvwCn: item.picRvwCn,
})) ?? [],
esntlAtchFileList: mapEsntlAtchFileList(esntlFileList),
esntlAtchFileList: toEsntlAtchFileSaveList(esntlFileList),
splmntDmndList:
splmntDmndList?.map((item) => ({
splmntDmndSn: item.splmntDmndSn,
@@ -1,16 +1,14 @@
import type {
BizCstTreeDto,
BizSclTreeDto,
EsntlAtchFileMngTreeResponseDto,
InstlSncnApplySaveRequestDto,
InstlSncnBizCstSaveRequestDto,
InstlSncnBzScaleSaveDto,
InstlSncnEsntlAtchFileSaveDto,
InstlSncnMngDetResponse,
InstlSncnSplmntDmndSaveDto,
ReviewStepCode,
} from "@/entities/instl-sncn";
import type { SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
import { type SplmntDmndDialogConfirmPayload, toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt";
import type { ChecklistFormState } from "../model/view-types";
@@ -40,22 +38,6 @@ export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSav
}));
}
export function toEsntlAtchFileSaveDto(
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
): InstlSncnEsntlAtchFileSaveDto[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function buildSavePayload(
data: InstlSncnMngDetResponse,
checklistFormState: ChecklistFormState,
@@ -69,7 +51,7 @@ export function buildSavePayload(
bzScaleList: toBzScaleSaveDto(bizSclList),
bizCstList: toBizCstSaveDto(bizCstList),
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
esntlAtchFileList: toEsntlAtchFileSaveList(esntlFileList),
};
}
@@ -6,7 +6,7 @@ import { useUserStore } from "@/shared/lib";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useInstlSncnDetail } from "@/entities/instl-sncn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { ProgressStatusSection, SupplementDemandSection } from "@/widgets/pbadms-sprt";
import { AttachmentsViewSection, ProgressStatusSection, SupplementDemandSection } from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
@@ -15,7 +15,6 @@ import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-check
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { FloatingActionButtons } from "./components/floating-action-buttons";
import { AttachmentsSection } from "./sections/attachments-section";
import { BasicInfoSection } from "./sections/basic-info-section";
import { BusinessCostSection } from "./sections/business-cost-section";
import { BusinessScaleSection } from "./sections/business-scale-section";
@@ -107,7 +106,10 @@ function InstlViewPage({ id }: InstlViewPageProps) {
formState={checklistFormState}
setFormState={setChecklistFormState}
/>
<AttachmentsSection esntlFileList={esntlFileList} etcFileList={etcFileList} />
<AttachmentsViewSection
esntlFileList={esntlFileList ?? []}
etcFileList={etcFileList ?? []}
/>
<TechReviewOpinionSection
ref={techReviewOpinionRef}
rvwList={rvwList}
@@ -1,124 +0,0 @@
"use client";
import { formatFileSize } from "@/shared/lib/file-utils";
import { SectionCollapsible } from "@/shared/ui";
import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
import type {
AtflMngClientResponseDto,
EsntlAtchFileMngTreeResponseDto,
} from "@/entities/instl-sncn";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface AttachmentsSectionProps {
esntlFileList: EsntlAtchFileMngTreeResponseDto[];
etcFileList: AtflMngClientResponseDto[];
}
function AttachmentsSection({ esntlFileList, etcFileList }: AttachmentsSectionProps) {
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
return (
<>
{esntlFileList.length > 0 && (
<SectionCollapsible title="필수 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>필수 자료명</TableHead>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{esntlFileList.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
조회된 필수 첨부파일 항목이 없습니다.
</TableCell>
</TableRow>
) : (
esntlFileList.map((file) => (
<TableRow key={file.esntlAtchFileId}>
<TableCell className="pl-2 text-left">
{file.esntlAtchFileNm}
{file.esntlAtchYn === "Y" ? (
<span className="ml-1 text-red-500">*</span>
) : null}
</TableCell>
<TableCell className="text-left">
{file.atflCd ? (
<a
className="cursor-pointer text-blue-600 hover:underline"
onClick={() => downloadAttachment(file.atflCd ?? "")}
target="_blank"
rel="noreferrer"
>
{file.orgnlFileNm || "다운로드"}
</a>
) : (
<span className="text-gray-500">첨부없음</span>
)}
</TableCell>
<TableCell className="text-right">
{formatFileSize(file.atflFlieSz)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</SectionCollapsible>
)}
{etcFileList.length > 0 && (
<SectionCollapsible title="기타 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{etcFileList.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="py-12 text-center text-muted-foreground">
등록된 기타첨부파일이 없습니다.
</TableCell>
</TableRow>
) : (
etcFileList.map((file) => (
<TableRow key={file.atflId}>
<TableCell className="text-left">
{file.atflCd ? (
<a
className="cursor-pointer text-blue-600 hover:underline"
onClick={() => downloadAttachment(file.atflCd)}
target="_blank"
rel="noreferrer"
>
{file.orgnlFileNm || file.physFileNm || file.atflId}
</a>
) : (
<span>{file.orgnlFileNm || file.physFileNm || file.atflId}</span>
)}
</TableCell>
<TableCell className="text-right text-gray-500">
{formatFileSize(file.atflFlieSz)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</SectionCollapsible>
)}
</>
);
}
export { AttachmentsSection };
@@ -4,6 +4,7 @@ import type {
SewerBscPlanDetResponseDto,
} from "@/entities/sewer-bsc-plan";
import { sewerBscPlanApplySaveSchema } from "@/entities/sewer-bsc-plan";
import { toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt";
import type { BizCstMatrixItem } from "./biz-cst-matrix-utils";
import { recalcFnrsCstSumAmt } from "./fnrs-cst-form-utils";
@@ -26,20 +27,6 @@ function mapSelectedValue(value?: string | null): "Y" | "N" | null {
return null;
}
function mapEsntlAtchFileList(esntlFileList: SewerBscPlanDetResponseDto["esntlFileList"]) {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList ?? []) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
function createEmptyTmplntDtlList(swtrmtFcltCd: string) {
return TMPLNT_DTL_STEPS.map((planStpNo) => ({
planStpNo,
@@ -218,7 +205,7 @@ export function mapDetailToFormData(data: SewerBscPlanDetResponseDto): SewerBscP
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
picRvwCn: item.picRvwCn,
})) ?? [],
esntlAtchFileList: mapEsntlAtchFileList(data.esntlFileList),
esntlAtchFileList: toEsntlAtchFileSaveList(data.esntlFileList),
splmntDmndList:
data.splmntDmndList?.map((item) => ({
splmntDmndSn: item.splmntDmndSn,
@@ -1,11 +1,9 @@
import type {
EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
SewerBscPlanApplySaveRequestDto,
SewerBscPlanDetResponseDto,
SewerBscPlanSplmntDmndItem,
} from "@/entities/sewer-bsc-plan";
import type { SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
import { type SplmntDmndDialogConfirmPayload, toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt";
import type { ChecklistFormState, ReviewStepCode } from "../model/view-types";
@@ -13,22 +11,6 @@ import { toCklstSaveDto } from "./checklist-utils";
import { recalcFnrsCstSumAmt } from "./fnrs-cst-form-utils";
import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils";
export function toEsntlAtchFileSaveDto(
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
): EsntlAtchFileItem[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function buildSavePayload(
data: SewerBscPlanDetResponseDto,
checklistFormState: ChecklistFormState,
@@ -116,7 +98,7 @@ export function buildSavePayload(
})),
})),
cklstResultList: toCklstSaveDto(cklstResultRows, checklistFormState, reviewStep),
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
esntlAtchFileList: toEsntlAtchFileSaveList(esntlFileList),
};
}
@@ -1,127 +0,0 @@
"use client";
import { Paperclip } from "lucide-react";
import { formatFileSize } from "@/shared/lib/file-utils";
import { SectionCollapsible } from "@/shared/ui";
import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
import type {
AtflMngClientResponseDto,
EsntlAtchFileMngTreeResponseDto,
} from "@/entities/sewer-bsc-plan";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface AttachmentsSectionProps {
esntlFileList: EsntlAtchFileMngTreeResponseDto[];
etcFileList: AtflMngClientResponseDto[];
}
function AttachmentsSection({ esntlFileList, etcFileList }: AttachmentsSectionProps) {
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
return (
<>
{esntlFileList.length > 0 && (
<SectionCollapsible title="필수 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>필수 자료명</TableHead>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{esntlFileList.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
조회된 필수 첨부파일 항목이 없습니다.
</TableCell>
</TableRow>
) : (
esntlFileList.map((file) => (
<TableRow key={file.esntlAtchFileId}>
<TableCell className="pl-2 text-left">
{file.esntlAtchFileNm}
{file.esntlAtchYn === "Y" ? (
<span className="ml-1 text-red-500">*</span>
) : null}
</TableCell>
<TableCell className="text-left">
{file.atflCd ? (
<a
className="inline-flex cursor-pointer items-center gap-1 text-blue-600 hover:underline"
onClick={() => downloadAttachment(file.atflCd ?? "")}
target="_blank"
rel="noreferrer"
>
<Paperclip className="size-5 shrink-0" />
{file.orgnlFileNm || "다운로드"}
</a>
) : (
<span className="text-gray-500">첨부없음</span>
)}
</TableCell>
<TableCell className="text-right">
{formatFileSize(file.atflFlieSz)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</SectionCollapsible>
)}
{etcFileList.length > 0 && (
<SectionCollapsible title="기타 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{etcFileList.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="py-12 text-center text-muted-foreground">
등록된 기타첨부파일이 없습니다.
</TableCell>
</TableRow>
) : (
etcFileList.map((file) => (
<TableRow key={file.atflId}>
<TableCell className="text-left">
{file.atflCd ? (
<a
className="cursor-pointer text-blue-600 hover:underline"
onClick={() => downloadAttachment(file.atflCd)}
target="_blank"
rel="noreferrer"
>
{file.orgnlFileNm || file.physFileNm || file.atflId}
</a>
) : (
<span>{file.orgnlFileNm || file.physFileNm || file.atflId}</span>
)}
</TableCell>
<TableCell className="text-right text-gray-500">
{formatFileSize(file.atflFlieSz)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</SectionCollapsible>
)}
</>
);
}
export { AttachmentsSection };
@@ -6,7 +6,7 @@ import { useUserStore } from "@/shared/lib";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { useSewerBscPlanDetails } from "@/entities/sewer-bsc-plan";
import { ProgressStatusSection, SupplementDemandSection } from "@/widgets/pbadms-sprt";
import { AttachmentsViewSection, ProgressStatusSection, SupplementDemandSection } from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
@@ -15,7 +15,6 @@ import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-check
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { FloatingActionButtons } from "./components/floating-action-buttons";
import { AttachmentsSection } from "./sections/attachments-section";
import { BasicInfoSection } from "./sections/basic-info-section";
import { BizCstMatrixSection } from "./sections/biz-cst-matrix-section";
import { ChecklistSection } from "./sections/checklist-section";
@@ -116,7 +115,7 @@ function SewerBscPlanViewPage({ id, propChgCycl }: SewerBscPlanViewPageProps) {
formState={checklistFormState}
setFormState={setChecklistFormState}
/>
<AttachmentsSection esntlFileList={esntlFileList} etcFileList={etcFileList} />
<AttachmentsViewSection esntlFileList={esntlFileList} etcFileList={etcFileList} />
<SupplementDemandSection splmntDmndList={splmntDmndList} />
<TechReviewOpinionSection
ref={techReviewOpinionRef}
+1
View File
@@ -3,6 +3,7 @@ export const ENDPOINT = {
TUS_UPLOAD: "/attach-service/api/v1/tus/upload",
ATTACHMENTS: "/attach-service/api/v1/atfl/attachments",
DOWNLOAD: "/attach-service/api/v1/atflcd/download",
DOWNLOAD_BY_ATFL_ID: "/attach-service/api/v1/download",
},
AS_SERVICE: {
ACNTG_SE: "/as-service/api/v1/acntg-se",
+2 -16
View File
@@ -108,22 +108,8 @@ export interface EsntlAtchFileMngTreeResponseDto {
esntlAtchYn: string;
/** 필수첨부파일비고 */
esntlAtchFileRmrk: string | null;
/** 첨부파일 코드 */
atflCd?: string;
/** 첨부파일 일련번호 */
atflSn?: number;
/** 첨부파일 ID */
atflId?: string;
/** 원본 파일명 */
orgnlFileNm?: string;
/** 첨부파일 크기 */
atflFlieSz?: number;
/** 첨부파일 타입 */
atflType?: string;
/** 다운로드 횟수 */
dwnldNocs?: number;
/** 등록일시 */
regDt?: string;
/** 첨부파일 목록 (슬롯당 atchFileGroupId 1:N) */
atchFileList?: AtflMngClientResponseDto[];
}
/** 첨부파일 응답 DTO (attach-service client) */
+3
View File
@@ -2,8 +2,10 @@ export {
atchFileGroupIdPath,
createEtcUploadKey,
createUploadKey,
getEsntlAtchFileGroupId,
isEtcFileKey,
isFormEsntlAtchComplete,
toEsntlAtchFileSaveList,
} from "./lib/attachments-form-utils";
export {
buildAcntgCostItems,
@@ -30,6 +32,7 @@ export {
} from "./lib/fnrs-shar-form-utils";
export { calcDiffPercent, fmtNumber, indentPx } from "./lib/format-utils";
export { AttachmentsFormSection } from "./ui/attachments-form-section";
export { AttachmentsViewSection } from "./ui/attachments-view-section";
export { BusinessCostFormSection } from "./ui/business-cost-form-section";
export { BusinessScaleFormSection } from "./ui/business-scale-form-section";
export { ChecklistFormSection } from "./ui/checklist-form-section";
@@ -10,7 +10,7 @@ export type FieldPrefix =
export const ETC_FILE_KEY_PREFIX = "etc::";
interface EsntlAtchFileListItem {
export interface EsntlAtchFileListItem {
esntlAtchFileId: string;
atchFileGroupId: string;
}
@@ -42,6 +42,27 @@ export function toFileAccept(rmrk: string | null | undefined): string {
.join(",");
}
export function getEsntlAtchFileGroupId(item: EsntlAtchFileMngTreeResponseDto): string {
return item.atchFileList?.[0]?.atflCd ?? "";
}
export function toEsntlAtchFileSaveList(
esntlFileList?: EsntlAtchFileMngTreeResponseDto[],
): EsntlAtchFileListItem[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList ?? []) {
const atchFileGroupId = getEsntlAtchFileGroupId(item);
if (!item.esntlAtchFileId || !atchFileGroupId) continue;
esntlMap.set(item.esntlAtchFileId, atchFileGroupId);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function isFormEsntlAtchComplete(
essentialFiles: EsntlAtchFileMngTreeResponseDto[],
esntlAtchFileList: EsntlAtchFileListItem[],
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useQueryClient } from "@tanstack/react-query";
import { Paperclip, X } from "lucide-react";
import { ENDPOINT } from "@/shared/config";
import { useTus } from "@/shared/lib";
@@ -30,7 +31,7 @@ import {
type FieldPrefix,
isEtcFileKey,
toFileAccept,
} from "../lib/attachments-form-utils";
} from "../lib/attachments-form-utils";
interface AttachmentsFormData {
esntlAtchFileList: Array<{
@@ -103,22 +104,22 @@ function AttachmentsFormSection({
if (initialEsntlFiles?.length) {
const nextUploadedFiles: Record<string, UploadedFileItem[]> = {};
for (const file of initialEsntlFiles) {
if (!file.esntlAtchFileId || !file.atflCd || !file.atflId) continue;
for (const slot of initialEsntlFiles) {
if (!slot.esntlAtchFileId) continue;
const fileKey = `server::${file.esntlAtchFileId}::${file.atflId}`;
const fileName = file.orgnlFileNm ?? "첨부파일";
const files = (slot.atchFileList ?? []).filter((file) => file.atflId && file.atflCd);
if (files.length === 0) continue;
nextUploadedFiles[file.esntlAtchFileId] = [
...(nextUploadedFiles[file.esntlAtchFileId] ?? []),
{
fileName,
fileKey,
atflId: file.atflId,
atflCd: file.atflCd,
},
];
atflCdBySlotRef.current[file.esntlAtchFileId] = file.atflCd;
const [firstFile] = files;
if (!firstFile) continue;
nextUploadedFiles[slot.esntlAtchFileId] = files.map((file) => ({
fileName: file.orgnlFileNm ?? "첨부파일",
fileKey: `server::${slot.esntlAtchFileId}::${file.atflId}`,
atflId: file.atflId,
atflCd: file.atflCd,
}));
atflCdBySlotRef.current[slot.esntlAtchFileId] = firstFile.atflCd;
}
setUploadedFiles(nextUploadedFiles);
@@ -372,7 +373,7 @@ function AttachmentsFormSection({
render={() => (
<div className="px-2">
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
<Table variant="secondary">
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="40%" />
<col width="15%" />
@@ -406,36 +407,43 @@ function AttachmentsFormSection({
</TableHead>
<TableCell align="center">{file.esntlAtchFileRmrk}</TableCell>
<TableCell className="space-y-2">
{uploadedFiles[file.esntlAtchFileId]?.length > 0 ? (
<div className="space-y-1">
{uploadedFiles[file.esntlAtchFileId]?.length > 0 && (
<div className="flex flex-wrap gap-2">
{uploadedFiles[file.esntlAtchFileId].map((item, idx) => {
const uploading = isUploading[item.fileKey] ?? false;
return (
<div key={item.fileKey} className="flex items-center gap-2">
<div
key={item.fileKey}
className="inline-flex items-center justify-between gap-2 rounded-sm border bg-accent p-2"
>
<Paperclip className="size-5 shrink-0" />
<TextShimmer className="text-xl" active={uploading}>
{item.file?.name ?? item.fileName}
</TextShimmer>
{!uploading && (
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void removeEssentialFile(file.esntlAtchFileId, idx);
}}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
)}
<button
type="button"
onClick={(e) => {
e.preventDefault();
void removeEssentialFile(file.esntlAtchFileId, idx);
}}
disabled={uploading}
>
<X
className="size-5 shrink-0 text-destructive"
aria-label="삭제"
/>
</button>
</div>
);
})}
</div>
) : (
)}
<div>
<label>
<input
type="file"
multiple
className="hidden"
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
onChange={(e) => {
@@ -447,10 +455,10 @@ function AttachmentsFormSection({
}}
/>
<Button size="sm" variant="outline" asChild>
<span>파일선택</span>
<span>파일추가</span>
</Button>
</label>
)}
</div>
</TableCell>
</TableRow>
))
@@ -475,35 +483,35 @@ function AttachmentsFormSection({
}}
/>
<Button size="sm" variant="outline" asChild>
<span>파일 추가</span>
<span>파일추가</span>
</Button>
</label>
</div>
<div className="rounded border p-4">
{etcFiles.length > 0 ? (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{etcFiles.map((item, idx) => {
const uploading = isUploading[item.fileKey] ?? false;
return (
<div
key={item.fileKey}
className="flex items-center justify-between rounded-sm border p-2"
className="inline-flex items-center justify-between gap-2 rounded-sm border bg-accent p-2"
>
<TextShimmer className="px-2 text-xl" active={uploading}>
<Paperclip className="size-5 shrink-0" />
<TextShimmer className="text-xl" active={uploading}>
{item.file?.name ?? item.fileName}
</TextShimmer>
<Button
size="sm"
color="gray"
<button
type="button"
onClick={(e) => {
e.preventDefault();
void removeEtcFile(idx);
}}
disabled={uploading}
>
삭제
</Button>
<X className="size-5 shrink-0 text-destructive" aria-label="삭제" />
</button>
</div>
);
})}
@@ -0,0 +1,123 @@
"use client";
import { Paperclip } from "lucide-react";
import { formatFileSize } from "@/shared/lib/file-utils";
import type { AtflMngClientResponseDto, EsntlAtchFileMngTreeResponseDto } from "@/shared/model";
import { SectionCollapsible } from "@/shared/ui";
import { useDownloadAttachmentByAtflCd, useDownloadAttachmentByAtflId } from "@/entities/attach";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface AttachmentsViewSectionProps {
esntlFileList: EsntlAtchFileMngTreeResponseDto[];
etcFileList: AtflMngClientResponseDto[];
}
function AttachmentsViewSection({ esntlFileList, etcFileList }: AttachmentsViewSectionProps) {
const { mutate: downloadByAtflCd } = useDownloadAttachmentByAtflCd();
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
return (
<>
{esntlFileList.length > 0 && (
<SectionCollapsible title="필수 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>필수 자료명</TableHead>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{esntlFileList.map((slot) => {
const files = slot.atchFileList ?? [];
return (
<TableRow key={slot.esntlAtchFileId}>
<TableCell className="pl-2 text-left">
{slot.esntlAtchFileNm}
{slot.esntlAtchYn === "Y" ? (
<span className="ml-1 text-red-500">*</span>
) : null}
</TableCell>
<TableCell className="text-left">
{files.length === 0 ? (
<span className="text-gray-500">첨부없음</span>
) : (
<div className="space-y-2">
{files.map((file) => (
<a
key={file.atflId}
className="flex cursor-pointer items-center gap-1 text-blue-600 hover:underline"
onClick={() => downloadByAtflId(file.atflId)}
>
<Paperclip className="size-5 shrink-0 text-blue-600" />
{file.orgnlFileNm || "다운로드"}
</a>
))}
</div>
)}
</TableCell>
<TableCell className="text-right">
{files.length === 0 ? (
"-"
) : (
<div className="space-y-1">
{files.map((file) => (
<div key={file.atflId}>{formatFileSize(file.atflFlieSz)}</div>
))}
</div>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</SectionCollapsible>
)}
{etcFileList.length > 0 && (
<SectionCollapsible title="기타 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{etcFileList.map((file) => (
<TableRow key={file.atflId}>
<TableCell className="text-left">
{file.atflCd ? (
<a
className="flex cursor-pointer items-center gap-1 text-blue-600 hover:underline"
onClick={() => downloadByAtflCd(file.atflCd)}
>
<Paperclip className="size-5 shrink-0 text-blue-600" />
{file.orgnlFileNm || file.physFileNm || file.atflId}
</a>
) : (
<span>{file.orgnlFileNm || file.physFileNm || file.atflId}</span>
)}
</TableCell>
<TableCell className="text-right text-gray-500">
{formatFileSize(file.atflFlieSz)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionCollapsible>
)}
</>
);
}
export { AttachmentsViewSection };