[as] feat: 설치인가 보완요청 및 완료 기능 구현

This commit is contained in:
2026-07-16 15:17:43 +09:00
parent 70faadb27e
commit f584e21480
14 changed files with 683 additions and 32 deletions
@@ -65,6 +65,14 @@ async function techReview(id: string, payload: InstlSncnApplySaveRequestDto): Pr
await client.post(`${BASE}/${id}/tech-review`, payload);
}
async function submitSupplement(
id: string,
payload: InstlSncnApplySaveRequestDto,
): Promise<string> {
const response = await client.post<string>(`${BASE}/${id}/submitsupplement`, payload);
return response.data;
}
async function submitReqSupplement(
id: string,
payload: InstlSncnApplySaveRequestDto,
@@ -88,6 +96,7 @@ export const instlSncnService = {
saveApply,
update,
techReview,
submitSupplement,
submitReqSupplement,
remove,
changePrgrsSttsComCd,
@@ -99,6 +99,19 @@ export function useInstlSncnTechReview() {
});
}
export function useInstlSncnSubmitSupplement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: InstlSncnApplySaveRequestDto }) =>
instlSncnService.submitSupplement(id, payload),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) });
},
});
}
export function useInstlSncnSubmitReqSupplement() {
const queryClient = useQueryClient();
+2
View File
@@ -6,6 +6,7 @@ export {
useInstlSncnSidoCombo,
useInstlSncnSigunguCombo,
useInstlSncnSubmitReqSupplement,
useInstlSncnSubmitSupplement,
useInstlSncnTechReview,
} from "./api/queries";
export {
@@ -29,6 +30,7 @@ export {
type InstlSncnMngSearchParams,
type InstlSncnRvwOpnnResponseDto,
type InstlSncnRvwOpnnSaveRequestDto,
type InstlSncnSplmntDmndResponseDto,
type InstlSncnSplmntDmndSaveDto,
type ReviewStepCode,
} from "./model/instl-sncn-interface";
@@ -15,6 +15,7 @@ import type {
PbadmsMngDetResponseBase,
RvwOpnnResponseExtension,
RvwOpnnSaveBase,
SplmntDmndResponseExtension,
SplmntDmndSaveBase,
} from "@/shared/model";
@@ -185,6 +186,12 @@ export interface InstlSncnRvwOpnnSaveRequestDto extends RvwOpnnSaveBase {
/** 설치인가 보완요구 저장 DTO(배열 원소) */
export type InstlSncnSplmntDmndSaveDto = SplmntDmndSaveBase;
/** 설치인가 보완요구 응답 DTO */
export type InstlSncnSplmntDmndResponseDto = InstlSncnSplmntDmndSaveDto &
SplmntDmndResponseExtension & {
instlSncnDocNo?: string;
};
/** 설치인가 신청 - 통합 저장 요청 DTO */
export interface InstlSncnApplySaveRequestDto extends PbadmsApplySaveHeader {
/** 설치인가관리 정보 */
@@ -225,7 +232,7 @@ export type InstlSncnRvwOpnnResponseDto = InstlSncnRvwOpnnSaveRequestDto &
export interface InstlSncnMngDetResponse extends PbadmsMngDetResponseBase<
InstlSncnMngResponseDto,
InstlSncnRvwOpnnResponseDto,
InstlSncnSplmntDmndSaveDto
InstlSncnSplmntDmndResponseDto
> {
detDto: InstlSncnMngResponseDto;
bizSclList: BizSclTreeDto[];
@@ -242,6 +249,7 @@ export interface InstlSncnMngService {
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise<string>;
update: (id: string, payload: InstlSncnMngUpdateRequestDto) => Promise<void>;
techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<void>;
submitSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<string>;
submitReqSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<string>;
remove: (id: string) => Promise<void>;
changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => Promise<void>;
@@ -24,7 +24,7 @@ function mapEsntlAtchFileList(esntlFileList: InstlSncnMngDetResponse["esntlFileL
}
export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnRegistFormData {
const { detDto, bizSclList, bizCstList, cklstResultList, esntlFileList } = data;
const { detDto, bizSclList, bizCstList, cklstResultList, esntlFileList, splmntDmndList } = data;
return {
instlSncnMng: {
@@ -85,6 +85,23 @@ export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnReg
picRvwCn: item.picRvwCn,
})) ?? [],
esntlAtchFileList: mapEsntlAtchFileList(esntlFileList),
splmntDmndList:
splmntDmndList?.map((item) => ({
splmntDmndSn: item.splmntDmndSn,
splmntArtclComCd: item.splmntArtclComCd,
splmntArtclCdNm: item.splmntArtclCdNm,
splmntDmndCn: item.splmntDmndCn,
splmntDmndDt: item.splmntDmndDt,
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId ?? "",
splmntDmndAtchFileNm: item.splmntDmndAtchFileInfo?.orgnlFileNm ?? "",
splmntCn: item.splmntCn ?? "",
splmntPicNm: item.splmntPicNm ?? "",
splmntCmptnDt: item.splmntCmptnDt ?? "",
splmntAtchFileGroupId: item.splmntAtchFileGroupId ?? "",
splmntAtchFileNm: item.splmntAtchFileInfo?.orgnlFileNm ?? "",
splmntAtchFileAtflId: item.splmntAtchFileInfo?.atflId ?? "",
cmptnYn: item.cmptnYn,
})) ?? [],
privacyConsent: "Y",
};
}
@@ -116,6 +133,7 @@ export function getCreateDefaultValues(
bizCstList: [],
cklstResultList: [],
esntlAtchFileList: [],
splmntDmndList: [],
privacyConsent: "N",
};
}
@@ -75,6 +75,24 @@ export const instlSncnRegistSchema = z.object({
atchFileGroupId: z.string(),
}),
),
splmntDmndList: z.array(
z.object({
splmntDmndSn: z.number().optional(),
splmntArtclComCd: z.string().optional(),
splmntArtclCdNm: z.string().nullish(),
splmntDmndCn: z.string().optional(),
splmntDmndDt: z.string().optional(),
splmntDmndAtchFileGroupId: z.string().optional(),
splmntDmndAtchFileNm: z.string().optional(),
splmntCn: z.string().optional(),
splmntPicNm: z.string().optional(),
splmntCmptnDt: z.string().optional(),
splmntAtchFileGroupId: z.string().optional(),
splmntAtchFileNm: z.string().optional(),
splmntAtchFileAtflId: z.string().optional(),
cmptnYn: z.string().optional(),
}),
),
privacyConsent: z.enum(["Y", "N"], {
required_error: "개인정보 수집/이용 동의는 필수입니다",
}),
@@ -115,7 +115,7 @@ export function buildSupplementReqPayload(
};
}
function toSplmntDmndSaveItem(item: InstlSncnSplmntDmndSaveDto): InstlSncnSplmntDmndSaveDto {
export function toSplmntDmndSaveItem(item: InstlSncnSplmntDmndSaveDto): InstlSncnSplmntDmndSaveDto {
return {
splmntDmndSn: item.splmntDmndSn,
splmntArtclComCd: item.splmntArtclComCd,
@@ -41,8 +41,10 @@ import {
buildSavePayload,
buildSupplementReqPayload,
buildTechReviewReplyPayload,
toSplmntDmndSaveItem,
} from "../../lib/save-payload-utils";
import { type ChecklistFormState } from "../../model/view-types";
import type { SupplementDemandSectionHandle } from "../sections/supplement-demand-section";
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
interface FloatingActionButtonsProps {
@@ -53,6 +55,7 @@ interface FloatingActionButtonsProps {
dtlInstCd: string | undefined;
checklistFormState: ChecklistFormState;
techReviewOpinionRef?: React.RefObject<TechReviewOpinionSectionHandle | null>;
supplementDemandRef?: React.RefObject<SupplementDemandSectionHandle | null>;
opnrNm?: string;
}
@@ -66,6 +69,7 @@ function FloatingActionButtons({
dtlInstCd,
checklistFormState,
techReviewOpinionRef,
supplementDemandRef,
opnrNm,
}: FloatingActionButtonsProps) {
const router = useRouter();
@@ -383,15 +387,20 @@ function FloatingActionButtons({
return;
}
const payload = buildTechReviewReplyPayload(
data,
checklistFormState,
"KECO",
"TE",
"SV",
getReviewOpinionFormData(),
opnrNm,
);
const payload = {
...buildTechReviewReplyPayload(
data,
checklistFormState,
"KECO",
"TE",
"SV",
getReviewOpinionFormData(),
opnrNm,
),
splmntDmndList:
supplementDemandRef?.current?.getSplmntDmndList() ??
(data.splmntDmndList ?? []).map(toSplmntDmndSaveItem),
};
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn,
@@ -566,9 +575,17 @@ async function handleDel(context: ActionHandlerContext): Promise<void> {
}
}
async function handleSplmntCmptn(context: ActionHandlerContext): Promise<void> {
// TODO: 보완완료 API 호출
context.onSuccess?.();
async function handleSplmntCmptn(
context: ActionHandlerContext<InstlSncnApplySaveRequestDto>,
): Promise<void> {
try {
const docNo = context.data.detDto?.instlSncnDocNo;
if (!docNo) throw new Error("문서번호가 없습니다.");
await instlSncnService.submitSupplement(docNo, context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleChangePrgrsSttsComCd(
@@ -10,6 +10,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import {
type InstlSncnApplySaveRequestDto,
instlSncnKeys,
type InstlSncnMngDetResponse,
instlSncnService,
@@ -22,6 +23,7 @@ import {
ContactInfoFormSection,
isFormChecklistComplete,
PrivacyConsentSection,
SplmntDmndFormSection,
} from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
@@ -59,13 +61,13 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
: getCreateDefaultValues(userInfo, taskSeComCd),
});
async function submitForm(
function buildFormPayload(
data: InstlSncnRegistFormData,
prgrsSttsComCd: "SV" | "ST",
): Promise<string | undefined> {
prgrsSttsComCd: "SV" | "ST" | "TE",
): InstlSncnApplySaveRequestDto {
const { taskSeComCd: formTaskSeComCd, ...instlSncnMngRest } = data.instlSncnMng;
const payload = {
return {
instlSncnMng: {
...instlSncnMngRest,
instlSncnDocNo: isEditMode ? initialData?.detDto.instlSncnDocNo : undefined,
@@ -79,9 +81,50 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
selected: item.selected,
})),
esntlAtchFileList: data.esntlAtchFileList,
splmntDmndList: data.splmntDmndList.map(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ splmntAtchFileNm, splmntAtchFileAtflId, splmntDmndAtchFileNm, ...item }) => item,
),
};
}
return instlSncnService.saveApply(payload);
async function submitForm(
data: InstlSncnRegistFormData,
prgrsSttsComCd: "SV" | "ST",
): Promise<string | undefined> {
return instlSncnService.saveApply(buildFormPayload(data, prgrsSttsComCd));
}
async function submitSupplementForm(data: InstlSncnRegistFormData): Promise<string | undefined> {
const docNo = initialData?.detDto.instlSncnDocNo;
if (!docNo) return undefined;
return instlSncnService.submitSupplement(docNo, buildFormPayload(data, "TE"));
}
async function onSplmntCmptn(data: InstlSncnRegistFormData) {
if (isSubmitting) return;
const docNo = initialData?.detDto.instlSncnDocNo;
if (!docNo) {
alert("문서번호가 없습니다.");
return;
}
try {
setIsSubmitting(true);
const result = await submitSupplementForm(data);
if (!result) return;
alert("보완완료 처리되었습니다.");
router.push(`/instl-sncn/${docNo}`);
queryClient.invalidateQueries({ queryKey: instlSncnKeys.list() });
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(docNo) });
} catch (error) {
console.error("보완완료 실패:", error);
alert("보완완료 처리 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
}
async function onSave(data: InstlSncnRegistFormData) {
@@ -132,10 +175,6 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
}
}
function handleApplyClick() {
form.handleSubmit(onApply)();
}
async function handleDelClick() {
if (isSubmitting) return;
@@ -176,6 +215,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
/>
<SplmntDmndFormSection readOnly={!isSplmntPrgrsStts} />
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
@@ -197,7 +237,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
<Button
color="green"
type="button"
onClick={handleApplyClick}
onClick={form.handleSubmit(onApply)}
disabled={isSubmitting}
>
신청완료
@@ -214,7 +254,11 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
)}
</>
) : (
<Button type="submit" disabled={isSubmitting}>
<Button
type="button"
onClick={form.handleSubmit(onSplmntCmptn)}
disabled={isSubmitting}
>
보완완료
</Button>
)}
@@ -18,6 +18,10 @@ import { BusinessCostSection } from "./sections/business-cost-section";
import { BusinessScaleSection } from "./sections/business-scale-section";
import { ChecklistSection } from "./sections/checklist-section";
import { ContactInfoSection } from "./sections/contact-info-section";
import {
SupplementDemandSection,
type SupplementDemandSectionHandle,
} from "./sections/supplement-demand-section";
import {
TechReviewOpinionSection,
type TechReviewOpinionSectionHandle,
@@ -30,6 +34,7 @@ interface InstlViewPageProps {
function InstlViewPage({ id }: InstlViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const supplementDemandRef = useRef<SupplementDemandSectionHandle>(null);
const { data, isLoading, isError } = useInstlSncnDetail(id);
const { formState: checklistFormState, setFormState: setChecklistFormState } =
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
@@ -65,6 +70,7 @@ function InstlViewPage({ id }: InstlViewPageProps) {
esntlFileList,
etcFileList,
rvwList = [],
splmntDmndList = [],
} = data;
const editableReviewStep = getEditableReviewStep(
@@ -94,6 +100,11 @@ function InstlViewPage({ id }: InstlViewPageProps) {
rvwList={rvwList}
editableReviewStep={editableReviewStep}
/>
<SupplementDemandSection
ref={supplementDemandRef}
splmntDmndList={splmntDmndList}
canEditCmptnYn={userInfo?.dtlInstCd === "ROLE_USER01"}
/>
<FloatingActionButtons
data={data}
docSeComCd="INSTL"
@@ -102,6 +113,7 @@ function InstlViewPage({ id }: InstlViewPageProps) {
dtlInstCd={userInfo?.dtlInstCd}
checklistFormState={checklistFormState}
techReviewOpinionRef={techReviewOpinionRef}
supplementDemandRef={supplementDemandRef}
opnrNm={userInfo?.userName}
/>
</div>
@@ -0,0 +1,181 @@
"use client";
import { forwardRef, useImperativeHandle, useState } from "react";
import { Paperclip } from "lucide-react";
import { dayjs } from "@/shared/lib";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
import type {
InstlSncnSplmntDmndResponseDto,
InstlSncnSplmntDmndSaveDto,
} from "@/entities/instl-sncn";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Badge,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { toSplmntDmndSaveItem } from "../../lib/save-payload-utils";
interface SupplementDemandSectionProps {
splmntDmndList: InstlSncnSplmntDmndResponseDto[];
canEditCmptnYn: boolean;
}
export interface SupplementDemandSectionHandle {
getSplmntDmndList: () => InstlSncnSplmntDmndSaveDto[];
}
const SupplementDemandSection = forwardRef<
SupplementDemandSectionHandle,
SupplementDemandSectionProps
>(function SupplementDemandSection({ splmntDmndList, canEditCmptnYn }, ref) {
const [cmptnYnBySn, setCmptnYnBySn] = useState<Record<number, string>>({});
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
useImperativeHandle(
ref,
() => ({
getSplmntDmndList: () =>
splmntDmndList.map((item) => {
const saveItem = toSplmntDmndSaveItem(item);
const sn = item.splmntDmndSn;
if (sn == null || cmptnYnBySn[sn] == null) return saveItem;
return { ...saveItem, cmptnYn: cmptnYnBySn[sn] };
}),
}),
[splmntDmndList, cmptnYnBySn],
);
function getCmptnYn(item: InstlSncnSplmntDmndResponseDto) {
const sn = item.splmntDmndSn;
if (sn != null && cmptnYnBySn[sn] != null) return cmptnYnBySn[sn];
return item.cmptnYn ?? "N";
}
return (
<SectionCollapsible title="보완요청 및 완료 현황">
<div className="overflow-x-auto">
<Table variant="secondary">
<colgroup>
<col width="14%" />
<col width="8%" />
<col width="30%" />
<col width="8%" />
<col width="30%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>보완항목</TableHead>
<TableHead>보완요청일시</TableHead>
<TableHead>보완요청내용</TableHead>
<TableHead>보완완료일시</TableHead>
<TableHead>보완완료내용</TableHead>
<TableHead>완료여부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{splmntDmndList.map((item) => {
const sn = item.splmntDmndSn;
const cmptnYn = getCmptnYn(item);
const dmndAtchFileNm = item.splmntDmndAtchFileInfo?.orgnlFileNm;
const cmptnAtchFileNm = item.splmntAtchFileInfo?.orgnlFileNm;
return (
<TableRow key={sn ?? `${item.splmntArtclComCd}-${item.splmntDmndDt}`}>
<TableCell align="center" className="align-top">
{item.splmntArtclCdNm || "-"}
</TableCell>
<TableCell align="center" className="whitespace-nowrap align-top">
{item.splmntDmndDt ? dayjs(item.splmntDmndDt).format("YYYY-MM-DD HH:mm") : "-"}
</TableCell>
<TableCell className="align-top">
<div className="space-y-2">
<div className="whitespace-pre-wrap">{item.splmntDmndCn || "-"}</div>
{item.splmntDmndAtchFileGroupId ? (
<a
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
onClick={() => downloadAttachment(item.splmntDmndAtchFileGroupId!)}
>
<Paperclip className="my-1 size-5 shrink-0" />
{dmndAtchFileNm || "다운로드"}
</a>
) : null}
</div>
</TableCell>
<TableCell align="center" className="whitespace-nowrap align-top">
{item.splmntCmptnDt
? dayjs(item.splmntCmptnDt).format("YYYY-MM-DD HH:mm")
: "-"}
</TableCell>
<TableCell className="align-top">
<div className="space-y-2">
<div className="whitespace-pre-wrap">{item.splmntCn || "-"}</div>
{item.splmntAtchFileGroupId ? (
<a
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
onClick={() => downloadAttachment(item.splmntAtchFileGroupId!)}
>
<Paperclip className="my-1 size-5 shrink-0" />
{cmptnAtchFileNm || "다운로드"}
</a>
) : null}
</div>
</TableCell>
<TableCell align="center" className="align-top">
{canEditCmptnYn && sn != null ? (
<FieldSet>
<RadioGroup
value={cmptnYn}
onValueChange={(value) =>
setCmptnYnBySn((prev) => ({ ...prev, [sn]: value }))
}
className="flex justify-center gap-4"
>
<FieldLabel
htmlFor={`splmnt-cmptn-y-${sn}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`splmnt-cmptn-y-${sn}`} />
<span className="font-normal">완료</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`splmnt-cmptn-n-${sn}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`splmnt-cmptn-n-${sn}`} />
<span className="font-normal">미완료</span>
</Field>
</FieldLabel>
</RadioGroup>
</FieldSet>
) : (
<Badge size="lg" color={cmptnYn === "Y" ? "green" : "gray"}>
{cmptnYn === "Y" ? "완료" : "미완료"}
</Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</SectionCollapsible>
);
});
export { SupplementDemandSection };
@@ -167,8 +167,8 @@ const TechReviewOpinionSection = forwardRef<
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="p-2">
<h4 className="mb-4 text-xl font-semibold">검토의견 작성</h4>
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table variant="secondary">
<TableHeader>
<TableRow>
@@ -267,14 +267,18 @@ const TechReviewOpinionSection = forwardRef<
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
{renderOpinionForm("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} 검토의견`}>
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{renderOpinionForm("KECO")}
<div className="space-y-6">
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
) : null}
</>
+1
View File
@@ -35,4 +35,5 @@ export {
type SplmntDmndDialogConfirmItem,
type SplmntDmndDialogConfirmPayload,
} from "./ui/splmnt-dmnd-dialog";
export { type SplmntDmndFormItem, SplmntDmndFormSection } from "./ui/splmnt-dmnd-form-section";
export { TaskSePickerDialog } from "./ui/task-se-picker-dialog";
@@ -0,0 +1,324 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { Paperclip } from "lucide-react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach";
import {
Badge,
Button,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import { SectionCollapsible } from "./section-collapsible";
const SPLMNT_CMPTN_FILE_KEY_PREFIX = "splmnt-cmptn::";
interface UploadedSplmntFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
interface SplmntDmndFormItem {
splmntDmndSn?: number;
splmntArtclComCd?: string;
splmntArtclCdNm?: string | null;
splmntDmndCn?: string;
splmntDmndDt?: string;
splmntDmndAtchFileGroupId?: string;
splmntDmndAtchFileNm?: string;
splmntCn?: string;
splmntPicNm?: string;
splmntCmptnDt?: string;
splmntAtchFileGroupId?: string;
splmntAtchFileNm?: string;
splmntAtchFileAtflId?: string;
cmptnYn?: string;
}
interface SplmntDmndFormData {
splmntDmndList: SplmntDmndFormItem[];
}
interface SplmntDmndFormSectionProps {
readOnly?: boolean;
}
function createUploadKey(rowKey: string, file: File) {
return `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::${getDefaultFileKey(file)}`;
}
function getRowKeyFromFileKey(fileKey: string): string | null {
if (!fileKey.startsWith(SPLMNT_CMPTN_FILE_KEY_PREFIX)) return null;
const rest = fileKey.slice(SPLMNT_CMPTN_FILE_KEY_PREFIX.length);
const separatorIndex = rest.indexOf("::");
if (separatorIndex < 0) return null;
return rest.slice(0, separatorIndex);
}
function getRowKey(item: SplmntDmndFormItem, index: number) {
return item.splmntDmndSn != null ? String(item.splmntDmndSn) : `idx-${index}`;
}
function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps) {
const formId = useId();
const { control, setValue, getValues } = useFormContext<SplmntDmndFormData>();
const splmntDmndList = useWatch({ control, name: "splmntDmndList" }) ?? [];
const [uploadedFiles, setUploadedFiles] = useState<Record<string, UploadedSplmntFile | null>>({});
const initialFilesMappedRef = useRef(false);
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
const rowKey = getRowKeyFromFileKey(fileKey);
if (!rowKey) return;
setUploadedFiles((prev) => {
const current = prev[rowKey];
if (!current || current.fileKey !== fileKey) return prev;
return {
...prev,
[rowKey]: { ...current, atflCd, atflId },
};
});
const list = getValues("splmntDmndList") ?? [];
const index = list.findIndex((item, idx) => getRowKey(item, idx) === rowKey);
if (index < 0) return;
setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, atflCd, { shouldDirty: true });
setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, atflId, { shouldDirty: true });
},
onError: (_error, _file, fileKey) => {
const rowKey = getRowKeyFromFileKey(fileKey);
if (!rowKey) return;
setUploadedFiles((prev) => {
const current = prev[rowKey];
if (!current || current.fileKey !== fileKey) return prev;
return { ...prev, [rowKey]: null };
});
},
});
useEffect(() => {
if (initialFilesMappedRef.current) return;
if (splmntDmndList.length === 0) return;
const initial: Record<string, UploadedSplmntFile | null> = {};
for (let index = 0; index < splmntDmndList.length; index += 1) {
const item = splmntDmndList[index];
const rowKey = getRowKey(item, index);
if (item.splmntAtchFileGroupId && item.splmntAtchFileNm) {
initial[rowKey] = {
fileName: item.splmntAtchFileNm,
fileKey: `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::initial`,
atflCd: item.splmntAtchFileGroupId,
atflId: item.splmntAtchFileAtflId,
};
} else {
initial[rowKey] = null;
}
}
setUploadedFiles(initial);
initialFilesMappedRef.current = true;
}, [splmntDmndList]);
if (splmntDmndList.length === 0) return null;
async function handleFileUpload(index: number, files: FileList | null) {
if (!files || files.length === 0) return;
const item = splmntDmndList[index];
const rowKey = getRowKey(item, index);
const file = files[0];
const fileKey = createUploadKey(rowKey, file);
const existingAtflCd = item.splmntAtchFileGroupId;
setUploadedFiles((prev) => ({
...prev,
[rowKey]: { fileName: file.name, fileKey },
}));
setValue(`splmntDmndList.${index}.splmntAtchFileNm`, file.name, { shouldDirty: true });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove(index: number) {
const item = splmntDmndList[index];
const rowKey = getRowKey(item, index);
const uploaded = uploadedFiles[rowKey];
if (!uploaded) return;
const { fileKey, atflId } = uploaded;
uploads[fileKey]?.abort();
if (atflId) {
try {
await attachService.deleteAttachmentByAtflId(atflId);
} catch (error) {
console.error("보완완료 첨부파일 삭제 실패:", error);
}
}
setUploadedFiles((prev) => ({ ...prev, [rowKey]: null }));
setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, "", { shouldDirty: true });
setValue(`splmntDmndList.${index}.splmntAtchFileNm`, "", { shouldDirty: true });
setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, "", { shouldDirty: true });
}
return (
<SectionCollapsible title="보완요청">
<div className="overflow-x-auto">
<Table variant="secondary">
<colgroup>
<col width="12%" />
<col width="14%" />
<col width="22%" />
<col width="22%" />
<col width="18%" />
<col width="12%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>보완항목</TableHead>
<TableHead>보완요청일시</TableHead>
<TableHead>보완요청내용</TableHead>
<TableHead>보완완료내용</TableHead>
<TableHead>파일첨부</TableHead>
<TableHead>완료여부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{splmntDmndList.map((item, index) => {
const rowKey = getRowKey(item, index);
const uploaded = uploadedFiles[rowKey] ?? null;
const uploading = uploaded ? (isUploading[uploaded.fileKey] ?? false) : false;
const fileInputId = `${formId}-${rowKey}-file`;
const isRowReadOnly = readOnly || item.cmptnYn === "Y";
return (
<TableRow key={rowKey}>
<TableCell align="center" className="align-top">
{item.splmntArtclCdNm || "-"}
</TableCell>
<TableCell align="center" className="whitespace-nowrap align-top">
{item.splmntDmndDt ? dayjs(item.splmntDmndDt).format("YYYY-MM-DD HH:mm") : "-"}
</TableCell>
<TableCell className="align-top">
<div className="space-y-2">
<div className="whitespace-pre-wrap">{item.splmntDmndCn || "-"}</div>
{item.splmntDmndAtchFileGroupId ? (
<a
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
onClick={() => downloadAttachment(item.splmntDmndAtchFileGroupId!)}
>
<Paperclip className="my-1 size-5 shrink-0" />
{item.splmntDmndAtchFileNm || "다운로드"}
</a>
) : null}
</div>
</TableCell>
<TableCell className="align-top">
{isRowReadOnly ? (
<span className="whitespace-pre-wrap">{item.splmntCn || "-"}</span>
) : (
<Controller
control={control}
name={`splmntDmndList.${index}.splmntCn`}
render={({ field }) => (
<Textarea
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="보완완료 내용을 입력하세요."
className="min-h-[6rem] !text-xl"
/>
)}
/>
)}
</TableCell>
<TableCell className="align-top">
{uploaded ? (
<div className="flex items-stretch gap-2">
{uploaded.atflCd || item.splmntAtchFileGroupId ? (
<a
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
onClick={() =>
downloadAttachment((uploaded.atflCd || item.splmntAtchFileGroupId)!)
}
>
<Paperclip className="my-1 size-5 shrink-0" />
{uploaded.fileName}
</a>
) : (
<span className="text-xl">{uploaded.fileName}</span>
)}
{!isRowReadOnly ? (
<Button
type="button"
size="icon"
className="size-8 shrink-0 !bg-destructive"
disabled={uploading}
onClick={() => void handleFileRemove(index)}
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
) : null}
</div>
) : isRowReadOnly ? (
<span>-</span>
) : (
<Button type="button" size="sm" disabled={uploading} asChild>
<label htmlFor={fileInputId} className="cursor-pointer">
파일추가
<input
id={fileInputId}
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(index, event.target.files);
event.target.value = "";
}}
/>
</label>
</Button>
)}
</TableCell>
<TableCell align="center" className="align-top">
<Badge size="lg" color={item.cmptnYn === "Y" ? "green" : "gray"}>
{item.cmptnYn === "Y" ? "완료" : "미완료"}
</Badge>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</SectionCollapsible>
);
}
export { SplmntDmndFormSection };
export type { SplmntDmndFormItem };