Merge branch 'iipbs/as/dev'

This commit is contained in:
2026-08-05 17:20:23 +09:00
66 changed files with 3806 additions and 670 deletions
@@ -1,17 +1,13 @@
import Link from "next/link"; import { SewerBscPlanViewPage } from "@/pages/sewer-bsc-plan";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: Promise<{ chgCycl?: string }>;
} }
export default async function Page({ params }: PageProps) { export default async function Page({ params, searchParams }: PageProps) {
const { id } = await params; const { id } = await params;
return ( const { chgCycl } = await searchParams;
<div className="mt-6 px-4 text-[1.4rem]"> const propChgCycl = chgCycl ? Number(chgCycl) : undefined;
하수도기본계획 상세({id})는 후속 구현 예정입니다.{" "} return <SewerBscPlanViewPage id={id} propChgCycl={propChgCycl} />;
<Link className="underline" href={`/sewer-bsc-plan/${id}/edit`}>
수정 화면으로 이동
</Link>
</div>
);
} }
@@ -51,7 +51,6 @@ export interface ElaprPrgrsSttsMyDocSearchParams extends ElaprPrgrsSttsSearchDto
export interface ElaprProcessRequestDto { export interface ElaprProcessRequestDto {
atrzPrgrsSttsComCd: string; atrzPrgrsSttsComCd: string;
rjctRsn?: string; rjctRsn?: string;
finalApprovalYn?: boolean;
} }
/** 백엔드 ElaprApproverChangeRequestDto */ /** 백엔드 ElaprApproverChangeRequestDto */
@@ -5,6 +5,8 @@ import { ENDPOINT } from "@/shared/config";
import type { import type {
SewerBscPlanApplySaveRequestDto, SewerBscPlanApplySaveRequestDto,
SewerBscPlanSaveRequestDto, SewerBscPlanSaveRequestDto,
} from "../model/sewer-bsc-plan-apply-payload-schema";
import type {
SewerBscPlanSearchRequestDto, SewerBscPlanSearchRequestDto,
SewerBscPlanUpdateRequestDto, SewerBscPlanUpdateRequestDto,
} from "../model/sewer-bsc-plan-interface"; } from "../model/sewer-bsc-plan-interface";
@@ -4,10 +4,12 @@ import type { PageResponse } from "@/shared/model";
import type { import type {
SewerBscPlanApplySaveRequestDto, SewerBscPlanApplySaveRequestDto,
SewerBscPlanSaveRequestDto,
} from "../model/sewer-bsc-plan-apply-payload-schema";
import type {
SewerBscPlanApplySaveResponseDto, SewerBscPlanApplySaveResponseDto,
SewerBscPlanDetResponseDto, SewerBscPlanDetResponseDto,
SewerBscPlanResponseDto, SewerBscPlanResponseDto,
SewerBscPlanSaveRequestDto,
SewerBscPlanSearchRequestDto, SewerBscPlanSearchRequestDto,
SewerBscPlanService, SewerBscPlanService,
SewerBscPlanUpdateRequestDto, SewerBscPlanUpdateRequestDto,
@@ -75,8 +77,11 @@ async function submitReqSupplement(
id: string, id: string,
chgCycl: number, chgCycl: number,
body: SewerBscPlanApplySaveRequestDto, body: SewerBscPlanApplySaveRequestDto,
): Promise<string> { ): Promise<SewerBscPlanApplySaveResponseDto> {
const response = await client.post<string>(`${BASE}/${id}/${chgCycl}/reqsupplement`, body); const response = await client.post<SewerBscPlanApplySaveResponseDto>(
`${BASE}/${id}/${chgCycl}/reqsupplement`,
body,
);
return response.data; return response.data;
} }
+30 -13
View File
@@ -13,35 +13,52 @@ export {
} from "./api/queries"; } from "./api/queries";
export { sewerBscPlanService } from "./api/sewer-bsc-plan-service"; export { sewerBscPlanService } from "./api/sewer-bsc-plan-service";
export type { export type {
AtflMngClientResponseDto,
CklstResultItem, CklstResultItem,
CklstRvwRsltTreeDto,
EsntlAtchFileItem, EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
SewerBscPlanApplySaveRequestDto, SewerBscPlanApplySaveRequestDto,
SewerBscPlanApplySaveResponseDto,
SewerBscPlanBizCstItem, SewerBscPlanBizCstItem,
SewerBscPlanDtlSaveRequestDto,
SewerBscPlanFnrsCstItem,
SewerBscPlanOtlnItem,
SewerBscPlanRvwOpnnItem,
SewerBscPlanSaveRequestDto,
SewerBscPlanSplmntDmndItem,
SewerBscPlanTmplntDtlItem,
SewerBscPlanTmplntItem,
SewerBscPlanTmplntTmprStrgItem,
} from "./model/sewer-bsc-plan-apply-payload-schema";
export {
sewerBscPlanApplySaveSchema,
sewerBscPlanBizCstItemSchema,
sewerBscPlanCklstResultItemSchema,
sewerBscPlanDtlSaveSchema,
sewerBscPlanEsntlAtchFileItemSchema,
sewerBscPlanFnrsCstItemSchema,
sewerBscPlanOtlnItemSchema,
sewerBscPlanRvwOpnnItemSchema,
sewerBscPlanSaveSchema,
sewerBscPlanSplmntDmndItemSchema,
sewerBscPlanTmplntDtlItemSchema,
sewerBscPlanTmplntItemSchema,
sewerBscPlanTmplntTmprStrgItemSchema,
} from "./model/sewer-bsc-plan-apply-payload-schema";
export type {
AtflMngClientResponseDto,
CklstRvwRsltTreeDto,
EsntlAtchFileMngTreeResponseDto,
SewerBscPlanApplySaveResponseDto,
SewerBscPlanBizCstResponseDto, SewerBscPlanBizCstResponseDto,
SewerBscPlanDetResponseDto, SewerBscPlanDetResponseDto,
SewerBscPlanDtlResponseDto, SewerBscPlanDtlResponseDto,
SewerBscPlanDtlSaveRequestDto,
SewerBscPlanFnrsCstItem,
SewerBscPlanFnrsCstResponseDto, SewerBscPlanFnrsCstResponseDto,
SewerBscPlanOtlnItem,
SewerBscPlanOtlnResponseDto, SewerBscPlanOtlnResponseDto,
SewerBscPlanResponseDto, SewerBscPlanResponseDto,
SewerBscPlanRvwOpnnItem,
SewerBscPlanRvwOpnnResponseDto, SewerBscPlanRvwOpnnResponseDto,
SewerBscPlanSaveRequestDto,
SewerBscPlanSearchRequestDto, SewerBscPlanSearchRequestDto,
SewerBscPlanService, SewerBscPlanService,
SewerBscPlanSplmntDmndItem,
SewerBscPlanSplmntDmndResponseDto, SewerBscPlanSplmntDmndResponseDto,
SewerBscPlanTmplntDtlItem,
SewerBscPlanTmplntDtlResponseDto, SewerBscPlanTmplntDtlResponseDto,
SewerBscPlanTmplntItem,
SewerBscPlanTmplntResponseDto, SewerBscPlanTmplntResponseDto,
SewerBscPlanTmplntTmprStrgItem,
SewerBscPlanTmplntTmprStrgResponseDto, SewerBscPlanTmplntTmprStrgResponseDto,
SewerBscPlanUpdateRequestDto, SewerBscPlanUpdateRequestDto,
} from "./model/sewer-bsc-plan-interface"; } from "./model/sewer-bsc-plan-interface";
@@ -0,0 +1,173 @@
import { z } from "zod";
/** 백엔드 SewerBscPlanSaveRequestDto */
export const sewerBscPlanSaveSchema = z.object({
sewerBscPlanDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
sewerBscPlanSeComCd: z.string().optional(),
sewerBscPlanNm: z.string().optional(),
taskSeComCd: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
});
/** 백엔드 SewerBscPlanDtlSaveRequestDto */
export const sewerBscPlanDtlSaveSchema = z.object({
sewerBscPlanDocNo: z.string().optional(),
chgCycl: z.number().optional(),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
dsgnrNm: z.string().optional(),
dsgnrTelno: z.string().optional(),
dsgnrOgdpNm: z.string().optional(),
dsgnrEmlAddr: z.string().optional(),
planYr: z.string().optional(),
crtrYr: z.string().optional(),
bizOtlnCn: z.string().optional(),
frlvlGoalYr: z.string().optional(),
sclvlGoalYr: z.string().optional(),
thlvlGoalYr: z.string().optional(),
folvlGoalYr: z.string().optional(),
atchFileGroupId: z.string().optional(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanOtlnItem */
export const sewerBscPlanOtlnItemSchema = z.object({
planStpNo: z.number().optional(),
sewerPlanPpltnCnt: z.number().optional(),
sewerPrcsPpltnCnt: z.number().optional(),
sewerPsplyRt: z.number().optional(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.CklstResultItem */
export const sewerBscPlanCklstResultItemSchema = z.object({
cklstRvwSn: z.number().optional(),
cklstArtclSn: z.number().optional(),
cklstSn: z.number().optional(),
rvwStpComCd: z.string(),
selected: z.string().optional(),
picRvwCn: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanFnrsCstItem */
export const sewerBscPlanFnrsCstItemSchema = z.object({
planStpNo: z.number().optional(),
gvexpAmt: z.number().optional(),
lcexpAmt: z.number().optional(),
orgprBdchgAmt: z.number().optional(),
etcAmt: z.number().optional(),
sumAmt: z.number().optional(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanSplmntDmndItem */
export const sewerBscPlanSplmntDmndItemSchema = z.object({
splmntDmndSn: z.number().nullish(),
splmntArtclComCd: z.string().optional(),
splmntDmndCn: z.string().optional(),
splmntDmndDt: z.string().optional(),
splmntDmndAtchFileGroupId: z.string().optional(),
splmntCn: z.string().optional(),
splmntPicNm: z.string().optional(),
splmntCmptnDt: z.string().optional(),
splmntAtchFileGroupId: z.string().optional(),
cmptnYn: z.string().optional(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanRvwOpnnItem */
export const sewerBscPlanRvwOpnnItemSchema = z.object({
rvwOpnnSn: z.number().optional(),
opnrSeComCd: z.string().optional(),
opnrNm: z.string().optional(),
rvwRsltCn: z.string().optional(),
rvwRsltAnmlyYn: z.string().optional(),
atchFileGroupId: z.string().optional(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.EsntlAtchFileItem */
export const sewerBscPlanEsntlAtchFileItemSchema = z.object({
esntlAtchFileId: z.string().optional(),
atchFileGroupId: z.string().optional(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanBizCstItem */
export const sewerBscPlanBizCstItemSchema = z.object({
planStpNo: z.number().optional(),
acntgSeCd: z.string().optional(),
cstrnAmt: z.number().optional(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntDtlItem */
export const sewerBscPlanTmplntDtlItemSchema = z.object({
planStpNo: z.number().optional(),
swtrmtFcltCd: z.string().optional(),
prcsZoneArea: z.number().optional(),
swtrmtPpltnCnt: z.number().optional(),
planSwqty: z.number().optional(),
fcltCpct: z.number().optional(),
sprsysDrtwPplLen: z.number().optional(),
sprsysExlnPplLen: z.number().optional(),
cbsysPplLen: z.number().optional(),
icptgPplLen: z.number().optional(),
drgCnt: z.number().optional(),
drtwRlyPumpCnt: z.number().optional(),
drtwPumpCnt: z.number().optional(),
exlnPumpCnt: z.number().optional(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntItem */
export const sewerBscPlanTmplntItemSchema = z.object({
swtrmtFcltCd: z.string().optional(),
atchFileGroupId: z.string().optional(),
sewerBscPlanTmplntDtlList: z.array(sewerBscPlanTmplntDtlItemSchema).optional(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntTmprStrgItem */
export const sewerBscPlanTmplntTmprStrgItemSchema = z.object({
dtStrgDt: z.string().optional(),
swtrmtFcltCd: z.string().optional(),
prcsZoneArea: z.number().optional(),
swtrmtPpltnCnt: z.number().optional(),
planSwqty: z.number().optional(),
fcltCpct: z.number().optional(),
sprsysDrtwPplLen: z.number().optional(),
sprsysExlnPplLen: z.number().optional(),
cbsysPplLen: z.number().optional(),
icptgPplLen: z.number().optional(),
drgCnt: z.number().optional(),
drtwRlyPumpCnt: z.number().optional(),
drtwPumpCnt: z.number().optional(),
exlnPumpCnt: z.number().optional(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto */
export const sewerBscPlanApplySaveSchema = z.object({
techReViewSaveCd: z.string().optional(),
rvwStpCd: z.string().optional(),
sewerBscPlan: sewerBscPlanSaveSchema,
sewerBscPlanDtl: sewerBscPlanDtlSaveSchema,
sewerBscPlanOtlnList: z.array(sewerBscPlanOtlnItemSchema).optional(),
cklstResultList: z.array(sewerBscPlanCklstResultItemSchema).optional(),
fnrsCstList: z.array(sewerBscPlanFnrsCstItemSchema).optional(),
splmntDmndList: z.array(sewerBscPlanSplmntDmndItemSchema).optional(),
rvwOpnnList: z.array(sewerBscPlanRvwOpnnItemSchema).optional(),
esntlAtchFileList: z.array(sewerBscPlanEsntlAtchFileItemSchema).optional(),
bizCstList: z.array(sewerBscPlanBizCstItemSchema).optional(),
sewerBscPlanTmplntList: z.array(sewerBscPlanTmplntItemSchema).optional(),
tmplntTmprStrgList: z.array(sewerBscPlanTmplntTmprStrgItemSchema).optional(),
});
export type SewerBscPlanSaveRequestDto = z.infer<typeof sewerBscPlanSaveSchema>;
export type SewerBscPlanDtlSaveRequestDto = z.infer<typeof sewerBscPlanDtlSaveSchema>;
export type SewerBscPlanOtlnItem = z.infer<typeof sewerBscPlanOtlnItemSchema>;
export type CklstResultItem = z.infer<typeof sewerBscPlanCklstResultItemSchema>;
export type SewerBscPlanFnrsCstItem = z.infer<typeof sewerBscPlanFnrsCstItemSchema>;
export type SewerBscPlanSplmntDmndItem = z.infer<typeof sewerBscPlanSplmntDmndItemSchema>;
export type SewerBscPlanRvwOpnnItem = z.infer<typeof sewerBscPlanRvwOpnnItemSchema>;
export type EsntlAtchFileItem = z.infer<typeof sewerBscPlanEsntlAtchFileItemSchema>;
export type SewerBscPlanBizCstItem = z.infer<typeof sewerBscPlanBizCstItemSchema>;
export type SewerBscPlanTmplntDtlItem = z.infer<typeof sewerBscPlanTmplntDtlItemSchema>;
export type SewerBscPlanTmplntItem = z.infer<typeof sewerBscPlanTmplntItemSchema>;
export type SewerBscPlanTmplntTmprStrgItem = z.infer<typeof sewerBscPlanTmplntTmprStrgItemSchema>;
export type SewerBscPlanApplySaveRequestDto = z.infer<typeof sewerBscPlanApplySaveSchema>;
@@ -1,35 +1,26 @@
import type { import type {
AtflMngClientResponseDto, AtflMngClientResponseDto,
CklstResultSaveBase,
CklstRvwRsltTreeDto, CklstRvwRsltTreeDto,
EsntlAtchFileMngTreeResponseDto, EsntlAtchFileMngTreeResponseDto,
EsntlAtchFileSaveBase,
PageableParams, PageableParams,
PageResponse, PageResponse,
PbadmsApplySaveHeader,
RvwOpnnResponseExtension, RvwOpnnResponseExtension,
RvwOpnnSaveBase, RvwOpnnSaveBase,
SplmntDmndResponseExtension, SplmntDmndResponseExtension,
SplmntDmndSaveBase, SplmntDmndSaveBase,
} from "@/shared/model"; } from "@/shared/model";
import type {
SewerBscPlanApplySaveRequestDto,
SewerBscPlanSaveRequestDto,
} from "./sewer-bsc-plan-apply-payload-schema";
export type { export type {
AtflMngClientResponseDto, AtflMngClientResponseDto,
CklstRvwRsltTreeDto, CklstRvwRsltTreeDto,
EsntlAtchFileMngTreeResponseDto, EsntlAtchFileMngTreeResponseDto,
} from "@/shared/model"; } from "@/shared/model";
/** 백엔드 SewerBscPlanSaveRequestDto */
export interface SewerBscPlanSaveRequestDto {
sewerBscPlanDocNo?: string;
admdstCd?: string;
envoSeComCd?: string;
sewerBscPlanSeComCd?: string;
sewerBscPlanNm?: string;
taskSeComCd?: string;
prgrsSttsComCd?: string;
}
/** 백엔드 SewerBscPlanUpdateRequestDto */ /** 백엔드 SewerBscPlanUpdateRequestDto */
export interface SewerBscPlanUpdateRequestDto { export interface SewerBscPlanUpdateRequestDto {
admdstCd?: string; admdstCd?: string;
@@ -72,28 +63,6 @@ export interface SewerBscPlanResponseDto {
mdfcnDt?: string; mdfcnDt?: string;
} }
/** 백엔드 SewerBscPlanDtlSaveRequestDto */
export interface SewerBscPlanDtlSaveRequestDto {
sewerBscPlanDocNo?: string;
chgCycl?: number;
aplcntNm?: string;
aplcntMblTelno?: string;
aplcntOgdpNm?: string;
aplcntEmlAddr?: string;
dsgnrNm?: string;
dsgnrTelno?: string;
dsgnrOgdpNm?: string;
dsgnrEmlAddr?: string;
planYr?: string;
crtrYr?: string;
bizOtlnCn?: string;
frlvlGoalYr?: string;
sclvlGoalYr?: string;
thlvlGoalYr?: string;
folvlGoalYr?: string;
atchFileGroupId?: string;
}
/** 백엔드 SewerBscPlanDtlResponseDto */ /** 백엔드 SewerBscPlanDtlResponseDto */
export interface SewerBscPlanDtlResponseDto { export interface SewerBscPlanDtlResponseDto {
sewerBscPlanDocNo?: string; sewerBscPlanDocNo?: string;
@@ -210,103 +179,6 @@ export interface SewerBscPlanTmplntTmprStrgResponseDto {
exlnPumpCnt?: number; exlnPumpCnt?: number;
} }
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanOtlnItem */
export interface SewerBscPlanOtlnItem {
planStpNo?: number;
sewerPlanPpltnCnt?: number;
sewerPrcsPpltnCnt?: number;
sewerPsplyRt?: number;
}
/** 백엔드 SewerBscPlanApplySaveRequestDto.CklstResultItem */
export interface CklstResultItem extends CklstResultSaveBase {
selected?: string;
}
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanFnrsCstItem */
export interface SewerBscPlanFnrsCstItem {
planStpNo?: number;
gvexpAmt?: number;
lcexpAmt?: number;
orgprBdchgAmt?: number;
etcAmt?: number;
sumAmt?: number;
}
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanSplmntDmndItem */
export type SewerBscPlanSplmntDmndItem = SplmntDmndSaveBase;
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanRvwOpnnItem */
export type SewerBscPlanRvwOpnnItem = RvwOpnnSaveBase;
/** 백엔드 SewerBscPlanApplySaveRequestDto.EsntlAtchFileItem */
export type EsntlAtchFileItem = EsntlAtchFileSaveBase;
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanBizCstItem */
export interface SewerBscPlanBizCstItem {
planStpNo?: number;
acntgSeCd?: string;
cstrnAmt?: number;
}
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntDtlItem */
export interface SewerBscPlanTmplntDtlItem {
planStpNo?: number;
swtrmtFcltCd?: string;
prcsZoneArea?: number;
swtrmtPpltnCnt?: number;
planSwqty?: number;
fcltCpct?: number;
sprsysDrtwPplLen?: number;
sprsysExlnPplLen?: number;
cbsysPplLen?: number;
icptgPplLen?: number;
drgCnt?: number;
drtwRlyPumpCnt?: number;
drtwPumpCnt?: number;
exlnPumpCnt?: number;
}
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntItem */
export interface SewerBscPlanTmplntItem {
swtrmtFcltCd?: string;
atchFileGroupId?: string;
sewerBscPlanTmplntDtlList?: SewerBscPlanTmplntDtlItem[];
}
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntTmprStrgItem */
export interface SewerBscPlanTmplntTmprStrgItem {
dtStrgDt?: string;
swtrmtFcltCd?: string;
prcsZoneArea?: number;
swtrmtPpltnCnt?: number;
planSwqty?: number;
fcltCpct?: number;
sprsysDrtwPplLen?: number;
sprsysExlnPplLen?: number;
cbsysPplLen?: number;
icptgPplLen?: number;
drgCnt?: number;
drtwRlyPumpCnt?: number;
drtwPumpCnt?: number;
exlnPumpCnt?: number;
}
/** 백엔드 SewerBscPlanApplySaveRequestDto */
export interface SewerBscPlanApplySaveRequestDto extends PbadmsApplySaveHeader {
sewerBscPlan: SewerBscPlanSaveRequestDto;
sewerBscPlanDtl: SewerBscPlanDtlSaveRequestDto;
sewerBscPlanOtlnList?: SewerBscPlanOtlnItem[];
cklstResultList?: CklstResultItem[];
fnrsCstList?: SewerBscPlanFnrsCstItem[];
splmntDmndList?: SewerBscPlanSplmntDmndItem[];
rvwOpnnList?: SewerBscPlanRvwOpnnItem[];
esntlAtchFileList?: EsntlAtchFileItem[];
bizCstList?: SewerBscPlanBizCstItem[];
sewerBscPlanTmplntList?: SewerBscPlanTmplntItem[];
tmplntTmprStrgList?: SewerBscPlanTmplntTmprStrgItem[];
}
/** 백엔드 SewerBscPlanApplySaveResponseDto */ /** 백엔드 SewerBscPlanApplySaveResponseDto */
export interface SewerBscPlanApplySaveResponseDto { export interface SewerBscPlanApplySaveResponseDto {
sewerBscPlanDocNo: string; sewerBscPlanDocNo: string;
@@ -346,6 +218,6 @@ export interface SewerBscPlanService {
id: string, id: string,
chgCycl: number, chgCycl: number,
body: SewerBscPlanApplySaveRequestDto, body: SewerBscPlanApplySaveRequestDto,
): Promise<string>; ): Promise<SewerBscPlanApplySaveResponseDto>;
techReview(id: string, body: SewerBscPlanApplySaveRequestDto): Promise<void>; techReview(id: string, body: SewerBscPlanApplySaveRequestDto): Promise<void>;
} }
@@ -15,10 +15,12 @@ import {
type DesignCnstnMngDetResponseDto, type DesignCnstnMngDetResponseDto,
designCnstnMngService, designCnstnMngService,
} from "@/entities/design-cnstn"; } from "@/entities/design-cnstn";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import { import {
AttachmentsFormSection, AttachmentsFormSection,
BusinessScaleFormSection, BusinessScaleFormSection,
ContactInfoFormSection, ContactInfoFormSection,
isFormEsntlAtchComplete,
PrivacyConsentSection, PrivacyConsentSection,
} from "@/widgets/pbadms-sprt"; } from "@/widgets/pbadms-sprt";
import { Button } from "@common/components"; import { Button } from "@common/components";
@@ -53,6 +55,8 @@ function DesignFormPage({ id, mode, initialData, createDefaults }: DesignFormPag
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const isEditMode = mode === "edit"; const isEditMode = mode === "edit";
const { data: essentialFiles = [] } = useEsntlAtchFileMngTree({ docSeComCd: "CNFM" });
const form = useForm<DesignCnstnRegistFormData>({ const form = useForm<DesignCnstnRegistFormData>({
resolver: zodResolver(designCnstnRegistSchema), resolver: zodResolver(designCnstnRegistSchema),
mode: "onChange", mode: "onChange",
@@ -113,6 +117,11 @@ function DesignFormPage({ id, mode, initialData, createDefaults }: DesignFormPag
async function onApply(data: DesignCnstnRegistFormData) { async function onApply(data: DesignCnstnRegistFormData) {
if (isSubmitting) return; if (isSubmitting) return;
if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) {
alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다.");
return;
}
try { try {
setIsSubmitting(true); setIsSubmitting(true);
const designCnstnDocNo = await submitForm(data, "CT"); const designCnstnDocNo = await submitForm(data, "CT");
@@ -62,49 +62,51 @@ function AttachmentsSection({ esntlFileList, etcFileList }: AttachmentsSectionPr
</SectionCollapsible> </SectionCollapsible>
)} )}
<SectionCollapsible title="기타 첨부파일"> {etcFileList.length > 0 && (
<div className="overflow-x-auto"> <SectionCollapsible title="기타 첨부파일">
<Table variant="secondary"> <div className="overflow-x-auto">
<TableHeader> <Table variant="secondary">
<TableRow> <TableHeader>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{etcFileList.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={2} className="py-12 text-center text-muted-foreground"> <TableHead>파일명</TableHead>
등록된 기타첨부파일이 없습니다. <TableHead className="w-[8rem] text-right">크기</TableHead>
</TableCell>
</TableRow> </TableRow>
) : ( </TableHeader>
etcFileList.map((file) => ( <TableBody>
<TableRow key={file.atflId}> {etcFileList.length === 0 ? (
<TableCell className="text-left"> <TableRow>
{file.atflCd ? ( <TableCell colSpan={2} className="py-12 text-center text-muted-foreground">
<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> </TableCell>
</TableRow> </TableRow>
)) ) : (
)} etcFileList.map((file) => (
</TableBody> <TableRow key={file.atflId}>
</Table> <TableCell className="text-left">
</div> {file.atflCd ? (
</SectionCollapsible> <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>
)}
</> </>
); );
} }
@@ -2,8 +2,11 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { Paperclip } from "lucide-react";
import { PROXY_HOST } from "@/shared/config"; import { PROXY_HOST } from "@/shared/config";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService } from "@/entities/attach"; import { attachService } from "@/entities/attach";
import type { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn"; import type { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn";
@@ -71,12 +74,10 @@ function hasFormContent(formData: ReviewOpinionFormData) {
const TechReviewOpinionSection = forwardRef< const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle, TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps TechReviewOpinionSectionProps
>(function TechReviewOpinionSection({ >(function TechReviewOpinionSection(
rvwList, { rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
editableReviewStep, ref,
atrzSeComCdList = [], ) {
isAtrzLoading = false,
}, ref) {
const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02"); const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({ const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA, ...EMPTY_FORM_DATA,
@@ -240,7 +241,9 @@ const TechReviewOpinionSection = forwardRef<
<TableCell> <TableCell>
{uploadedFile ? ( {uploadedFile ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xl">{uploadedFile.fileName}</span> <TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button <Button
size="icon" size="icon"
onClick={(e) => { onClick={(e) => {
@@ -317,11 +320,12 @@ function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
<TableCell align="center"> <TableCell align="center">
{item.atchFileInfo?.atflCd ? ( {item.atchFileInfo?.atflCd ? (
<a <a
className="text-blue-600 hover:underline" className="inline-flex cursor-pointer items-start gap-1 text-left text-blue-600 hover:underline"
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${item.atchFileInfo.atflCd}`} href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${item.atchFileInfo.atflCd}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
<Paperclip className="my-1 size-5 shrink-0" />
{item.atchFileInfo.orgnlFileNm || "다운로드"} {item.atchFileInfo.orgnlFileNm || "다운로드"}
</a> </a>
) : ( ) : (
@@ -85,7 +85,7 @@ export const fnrsCnsltnRegistSchema = z.object({
), ),
splmntDmndList: z.array( splmntDmndList: z.array(
z.object({ z.object({
splmntDmndSn: z.number().optional(), splmntDmndSn: z.number().nullish(),
splmntArtclComCd: z.string().optional(), splmntArtclComCd: z.string().optional(),
splmntArtclCdNm: z.string().nullish(), splmntArtclCdNm: z.string().nullish(),
splmntDmndCn: z.string().optional(), splmntDmndCn: z.string().optional(),
@@ -46,12 +46,7 @@ function ChecklistTaskPanel({
.forEach((row) => { .forEach((row) => {
nextResults[buildChecklistRowKey(row, selectedReviewStep)] = value; nextResults[buildChecklistRowKey(row, selectedReviewStep)] = value;
}); });
return patchChecklistFormState( return patchChecklistFormState(prev, { results: nextResults }, allRows, selectedReviewStep);
prev,
{ results: nextResults },
allRows,
selectedReviewStep,
);
}); });
} }
@@ -77,6 +72,7 @@ function ChecklistTaskPanel({
<Button <Button
type="button" type="button"
size="sm" size="sm"
color="white"
disabled={!isStepEditable} disabled={!isStepEditable}
onClick={() => handleSelectAll("Y")} onClick={() => handleSelectAll("Y")}
> >
@@ -87,9 +83,9 @@ function ChecklistTaskPanel({
<Button <Button
type="button" type="button"
size="sm" size="sm"
color="gray"
disabled={!isStepEditable} disabled={!isStepEditable}
onClick={() => handleSelectAll("N")} onClick={() => handleSelectAll("N")}
className="!bg-destructive"
> >
전체 아니오 전체 아니오
</Button> </Button>
@@ -498,6 +498,7 @@ function FloatingActionButtons({
/> />
<SplmntDmndDialog <SplmntDmndDialog
splmntArtclUpCd="SPLMNTARTCL"
open={activeDialog === "dmndSplmnt"} open={activeDialog === "dmndSplmnt"}
onOpenChange={() => setActiveDialog(null)} onOpenChange={() => setActiveDialog(null)}
isSubmitting={isProcessing} isSubmitting={isProcessing}
@@ -10,6 +10,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib"; import { useAlertStore, useUserStore } from "@/shared/lib";
import { useAcntgSeMngTree } from "@/entities/acntg"; import { useAcntgSeMngTree } from "@/entities/acntg";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import { import {
type FnrsCnsltnApplySaveRequestDto, type FnrsCnsltnApplySaveRequestDto,
fnrsCnsltnMngBaseKey, fnrsCnsltnMngBaseKey,
@@ -27,6 +28,7 @@ import {
ContactInfoFormSection, ContactInfoFormSection,
FnrsSharFormSection, FnrsSharFormSection,
isFormChecklistComplete, isFormChecklistComplete,
isFormEsntlAtchComplete,
PrivacyConsentSection, PrivacyConsentSection,
recalculateFnrsSharTotals, recalculateFnrsSharTotals,
SplmntDmndFormSection, SplmntDmndFormSection,
@@ -67,6 +69,7 @@ function FnrsFormPage({ id, mode, initialData, createDefaults }: FnrsFormPagePro
const { data: masterSharTree = [] } = useFnrsSharSeMngTree(); const { data: masterSharTree = [] } = useFnrsSharSeMngTree();
const { data: acntgTree = [] } = useAcntgSeMngTree(); const { data: acntgTree = [] } = useAcntgSeMngTree();
const { data: essentialFiles = [] } = useEsntlAtchFileMngTree({ docSeComCd: "FNRS" });
const sharTree = const sharTree =
isEditMode && initialData?.fnrsSharList?.length ? initialData.fnrsSharList : masterSharTree; isEditMode && initialData?.fnrsSharList?.length ? initialData.fnrsSharList : masterSharTree;
const sharItems = useMemo(() => buildFnrsSharItems(sharTree), [sharTree]); const sharItems = useMemo(() => buildFnrsSharItems(sharTree), [sharTree]);
@@ -210,6 +213,11 @@ function FnrsFormPage({ id, mode, initialData, createDefaults }: FnrsFormPagePro
return; return;
} }
if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) {
alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다.");
return;
}
try { try {
setIsSubmitting(true); setIsSubmitting(true);
const fnrsCnsltnDocNo = await submitForm(data, "ST"); const fnrsCnsltnDocNo = await submitForm(data, "ST");
@@ -19,98 +19,104 @@ function AttachmentsSection({ esntlFileList, etcFileList }: AttachmentsSectionPr
return ( return (
<> <>
<SectionCollapsible title="필수 첨부파일"> {esntlFileList.length > 0 && (
<div className="overflow-x-auto"> <SectionCollapsible title="필수 첨부파일">
<Table variant="secondary"> <div className="overflow-x-auto">
<TableHeader> <Table variant="secondary">
<TableRow> <TableHeader>
<TableHead>필수 자료명</TableHead>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{esntlFileList.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground"> <TableHead>필수 자료명</TableHead>
조회된 필수 첨부파일 항목이 없습니다. <TableHead>파일명</TableHead>
</TableCell> <TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow> </TableRow>
) : ( </TableHeader>
esntlFileList.map((file) => ( <TableBody>
<TableRow key={file.esntlAtchFileId}> {esntlFileList.length === 0 ? (
<TableCell className="pl-2 text-left"> <TableRow>
{file.esntlAtchFileNm} <TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
{file.esntlAtchYn === "Y" ? ( 조회된 필수 첨부파일 항목이 없습니다.
<span className="ml-1 text-red-500">*</span>
) : null}
</TableCell> </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> </TableRow>
)) ) : (
)} esntlFileList.map((file) => (
</TableBody> <TableRow key={file.esntlAtchFileId}>
</Table> <TableCell className="pl-2 text-left">
</div> {file.esntlAtchFileNm}
</SectionCollapsible> {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>
)}
<SectionCollapsible title="기타 첨부파일"> {etcFileList.length > 0 && (
<div className="overflow-x-auto"> <SectionCollapsible title="기타 첨부파일">
<Table variant="secondary"> <div className="overflow-x-auto">
<TableHeader> <Table variant="secondary">
<TableRow> <TableHeader>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{etcFileList.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={2} className="py-12 text-center text-muted-foreground"> <TableHead>파일명</TableHead>
등록된 기타첨부파일이 없습니다. <TableHead className="w-[8rem] text-right">크기</TableHead>
</TableCell>
</TableRow> </TableRow>
) : ( </TableHeader>
etcFileList.map((file) => ( <TableBody>
<TableRow key={file.atflId}> {etcFileList.length === 0 ? (
<TableCell className="text-left"> <TableRow>
{file.atflCd ? ( <TableCell colSpan={2} className="py-12 text-center text-muted-foreground">
<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> </TableCell>
</TableRow> </TableRow>
)) ) : (
)} etcFileList.map((file) => (
</TableBody> <TableRow key={file.atflId}>
</Table> <TableCell className="text-left">
</div> {file.atflCd ? (
</SectionCollapsible> <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>
)}
</> </>
); );
} }
@@ -71,6 +71,10 @@ function ChecklistSection({
const { total, completed, percent } = progress; const { total, completed, percent } = progress;
if (cklstResultList.length === 0) {
return null;
}
return ( return (
<SectionCollapsible title="체크리스트"> <SectionCollapsible title="체크리스트">
<div className="flex flex-col gap-4 p-2"> <div className="flex flex-col gap-4 p-2">
@@ -2,8 +2,11 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { Paperclip } from "lucide-react";
import { PROXY_HOST } from "@/shared/config"; import { PROXY_HOST } from "@/shared/config";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService } from "@/entities/attach"; import { attachService } from "@/entities/attach";
import type { FnrsCnsltnRvwOpnnResponseDto } from "@/entities/fnrs-cnsltn"; import type { FnrsCnsltnRvwOpnnResponseDto } from "@/entities/fnrs-cnsltn";
@@ -71,12 +74,10 @@ function hasFormContent(formData: ReviewOpinionFormData) {
const TechReviewOpinionSection = forwardRef< const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle, TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps TechReviewOpinionSectionProps
>(function TechReviewOpinionSection({ >(function TechReviewOpinionSection(
rvwList, { rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
editableReviewStep, ref,
atrzSeComCdList = [], ) {
isAtrzLoading = false,
}, ref) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02"); const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({ const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA, ...EMPTY_FORM_DATA,
@@ -247,7 +248,9 @@ const TechReviewOpinionSection = forwardRef<
<TableCell> <TableCell>
{uploadedFile ? ( {uploadedFile ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xl">{uploadedFile.fileName}</span> <TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button <Button
size="icon" size="icon"
onClick={(e) => { onClick={(e) => {
@@ -336,11 +339,12 @@ function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
<TableCell align="center"> <TableCell align="center">
{item.atchFileInfo?.atflCd ? ( {item.atchFileInfo?.atflCd ? (
<a <a
className="text-blue-600 hover:underline" className="inline-flex cursor-pointer items-start gap-1 text-left text-blue-600 hover:underline"
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${item.atchFileInfo.atflCd}`} href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${item.atchFileInfo.atflCd}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
<Paperclip className="my-1 size-5 shrink-0" />
{item.atchFileInfo.orgnlFileNm || "다운로드"} {item.atchFileInfo.orgnlFileNm || "다운로드"}
</a> </a>
) : ( ) : (
@@ -77,7 +77,7 @@ export const instlSncnRegistSchema = z.object({
), ),
splmntDmndList: z.array( splmntDmndList: z.array(
z.object({ z.object({
splmntDmndSn: z.number().optional(), splmntDmndSn: z.number().nullish(),
splmntArtclComCd: z.string().optional(), splmntArtclComCd: z.string().optional(),
splmntArtclCdNm: z.string().nullish(), splmntArtclCdNm: z.string().nullish(),
splmntDmndCn: z.string().optional(), splmntDmndCn: z.string().optional(),
@@ -45,12 +45,7 @@ function ChecklistTaskPanel({
.forEach((row) => { .forEach((row) => {
nextResults[buildChecklistRowKey(row, selectedReviewStep)] = value; nextResults[buildChecklistRowKey(row, selectedReviewStep)] = value;
}); });
return patchChecklistFormState( return patchChecklistFormState(prev, { results: nextResults }, allRows, selectedReviewStep);
prev,
{ results: nextResults },
allRows,
selectedReviewStep,
);
}); });
} }
@@ -76,6 +71,7 @@ function ChecklistTaskPanel({
<Button <Button
type="button" type="button"
size="sm" size="sm"
color="white"
disabled={!isStepEditable} disabled={!isStepEditable}
onClick={() => handleSelectAll("Y")} onClick={() => handleSelectAll("Y")}
> >
@@ -86,9 +82,9 @@ function ChecklistTaskPanel({
<Button <Button
type="button" type="button"
size="sm" size="sm"
color="gray"
disabled={!isStepEditable} disabled={!isStepEditable}
onClick={() => handleSelectAll("N")} onClick={() => handleSelectAll("N")}
className="!bg-destructive"
> >
전체 아니오 전체 아니오
</Button> </Button>
@@ -511,6 +511,7 @@ function FloatingActionButtons({
/> />
<SplmntDmndDialog <SplmntDmndDialog
splmntArtclUpCd="SPLMNTARTCL"
open={activeDialog === "dmndSplmnt"} open={activeDialog === "dmndSplmnt"}
onOpenChange={() => setActiveDialog(null)} onOpenChange={() => setActiveDialog(null)}
isSubmitting={isProcessing} isSubmitting={isProcessing}
@@ -9,6 +9,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib"; import { useAlertStore, useUserStore } from "@/shared/lib";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import { import {
type InstlSncnApplySaveRequestDto, type InstlSncnApplySaveRequestDto,
instlSncnKeys, instlSncnKeys,
@@ -22,6 +23,7 @@ import {
ChecklistFormSection, ChecklistFormSection,
ContactInfoFormSection, ContactInfoFormSection,
isFormChecklistComplete, isFormChecklistComplete,
isFormEsntlAtchComplete,
PrivacyConsentSection, PrivacyConsentSection,
SplmntDmndFormSection, SplmntDmndFormSection,
} from "@/widgets/pbadms-sprt"; } from "@/widgets/pbadms-sprt";
@@ -57,6 +59,8 @@ function InstlFormPage({ id, mode, initialData, createDefaults }: InstlFormPageP
const isEditMode = mode === "edit"; const isEditMode = mode === "edit";
const isSplmntPrgrsStts = initialData?.detDto.prgrsSttsComCd === "SL"; const isSplmntPrgrsStts = initialData?.detDto.prgrsSttsComCd === "SL";
const { data: essentialFiles = [] } = useEsntlAtchFileMngTree({ docSeComCd: "INSTL" });
const form = useForm<InstlSncnRegistFormData>({ const form = useForm<InstlSncnRegistFormData>({
resolver: zodResolver(instlSncnRegistSchema), resolver: zodResolver(instlSncnRegistSchema),
mode: "onChange", mode: "onChange",
@@ -172,6 +176,11 @@ function InstlFormPage({ id, mode, initialData, createDefaults }: InstlFormPageP
return; return;
} }
if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) {
alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다.");
return;
}
try { try {
setIsSubmitting(true); setIsSubmitting(true);
const instlSncnDocNo = await submitForm(data, "ST"); const instlSncnDocNo = await submitForm(data, "ST");
@@ -19,98 +19,104 @@ function AttachmentsSection({ esntlFileList, etcFileList }: AttachmentsSectionPr
return ( return (
<> <>
<SectionCollapsible title="필수 첨부파일"> {esntlFileList.length > 0 && (
<div className="overflow-x-auto"> <SectionCollapsible title="필수 첨부파일">
<Table variant="secondary"> <div className="overflow-x-auto">
<TableHeader> <Table variant="secondary">
<TableRow> <TableHeader>
<TableHead>필수 자료명</TableHead>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{esntlFileList.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground"> <TableHead>필수 자료명</TableHead>
조회된 필수 첨부파일 항목이 없습니다. <TableHead>파일명</TableHead>
</TableCell> <TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow> </TableRow>
) : ( </TableHeader>
esntlFileList.map((file) => ( <TableBody>
<TableRow key={file.esntlAtchFileId}> {esntlFileList.length === 0 ? (
<TableCell className="pl-2 text-left"> <TableRow>
{file.esntlAtchFileNm} <TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
{file.esntlAtchYn === "Y" ? ( 조회된 필수 첨부파일 항목이 없습니다.
<span className="ml-1 text-red-500">*</span>
) : null}
</TableCell> </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> </TableRow>
)) ) : (
)} esntlFileList.map((file) => (
</TableBody> <TableRow key={file.esntlAtchFileId}>
</Table> <TableCell className="pl-2 text-left">
</div> {file.esntlAtchFileNm}
</SectionCollapsible> {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>
)}
<SectionCollapsible title="기타 첨부파일"> {etcFileList.length > 0 && (
<div className="overflow-x-auto"> <SectionCollapsible title="기타 첨부파일">
<Table variant="secondary"> <div className="overflow-x-auto">
<TableHeader> <Table variant="secondary">
<TableRow> <TableHeader>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{etcFileList.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={2} className="py-12 text-center text-muted-foreground"> <TableHead>파일명</TableHead>
등록된 기타첨부파일이 없습니다. <TableHead className="w-[8rem] text-right">크기</TableHead>
</TableCell>
</TableRow> </TableRow>
) : ( </TableHeader>
etcFileList.map((file) => ( <TableBody>
<TableRow key={file.atflId}> {etcFileList.length === 0 ? (
<TableCell className="text-left"> <TableRow>
{file.atflCd ? ( <TableCell colSpan={2} className="py-12 text-center text-muted-foreground">
<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> </TableCell>
</TableRow> </TableRow>
)) ) : (
)} etcFileList.map((file) => (
</TableBody> <TableRow key={file.atflId}>
</Table> <TableCell className="text-left">
</div> {file.atflCd ? (
</SectionCollapsible> <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>
)}
</> </>
); );
} }
@@ -70,6 +70,10 @@ function ChecklistSection({
const { total, completed, percent } = progress; const { total, completed, percent } = progress;
if (cklstResultList.length === 0) {
return null;
}
return ( return (
<SectionCollapsible title="체크리스트"> <SectionCollapsible title="체크리스트">
<div className="flex flex-col gap-4 p-2"> <div className="flex flex-col gap-4 p-2">
@@ -2,8 +2,11 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { Paperclip } from "lucide-react";
import { PROXY_HOST } from "@/shared/config"; import { PROXY_HOST } from "@/shared/config";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService } from "@/entities/attach"; import { attachService } from "@/entities/attach";
import type { InstlSncnRvwOpnnResponseDto, ReviewStepCode } from "@/entities/instl-sncn"; import type { InstlSncnRvwOpnnResponseDto, ReviewStepCode } from "@/entities/instl-sncn";
@@ -67,12 +70,10 @@ function hasFormContent(formData: ReviewOpinionFormData) {
const TechReviewOpinionSection = forwardRef< const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle, TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps TechReviewOpinionSectionProps
>(function TechReviewOpinionSection({ >(function TechReviewOpinionSection(
rvwList, { rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
editableReviewStep, ref,
atrzSeComCdList = [], ) {
isAtrzLoading = false,
}, ref) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02"); const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({ const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA, ...EMPTY_FORM_DATA,
@@ -240,7 +241,9 @@ const TechReviewOpinionSection = forwardRef<
<TableCell> <TableCell>
{uploadedFile ? ( {uploadedFile ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xl">{uploadedFile.fileName}</span> <TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button <Button
size="icon" size="icon"
onClick={(e) => { onClick={(e) => {
@@ -329,11 +332,12 @@ function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
<TableCell align="center"> <TableCell align="center">
{item.atchFileInfo?.atflCd ? ( {item.atchFileInfo?.atflCd ? (
<a <a
className="text-blue-600 hover:underline" className="inline-flex cursor-pointer items-start gap-1 text-left text-blue-600 hover:underline"
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${item.atchFileInfo.atflCd}`} href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${item.atchFileInfo.atflCd}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
<Paperclip className="my-1 size-5 shrink-0" />
{item.atchFileInfo.orgnlFileNm || "다운로드"} {item.atchFileInfo.orgnlFileNm || "다운로드"}
</a> </a>
) : ( ) : (
@@ -2,3 +2,4 @@ export { SewerBscPlanEditPage } from "./ui/sewer-bsc-plan-edit-page";
export { SewerBscPlanFormPage } from "./ui/sewer-bsc-plan-form-page"; export { SewerBscPlanFormPage } from "./ui/sewer-bsc-plan-form-page";
export { SewerBscPlanListPage } from "./ui/sewer-bsc-plan-list-page"; export { SewerBscPlanListPage } from "./ui/sewer-bsc-plan-list-page";
export { SewerBscPlanRegistPage } from "./ui/sewer-bsc-plan-regist-page"; export { SewerBscPlanRegistPage } from "./ui/sewer-bsc-plan-regist-page";
export { SewerBscPlanViewPage } from "./ui/sewer-bsc-plan-view-page";
@@ -0,0 +1,129 @@
export const SEWER_BSC_PLAN_VIEW_BUTTON_ACTION = {
strg: "strg",
aply: "aply",
del: "del",
rvwRply: "rvwRply",
rqstTechRvw: "rqstTechRvw",
dsgntRvwr: "dsgntRvwr",
rvwStrg: "rvwStrg",
rvwCmptn: "rvwCmptn",
dmndSplmnt: "dmndSplmnt",
atrzAprv: "atrzAprv",
atrzRply: "atrzRply",
} as const;
export type SewerBscPlanViewButtonAction =
(typeof SEWER_BSC_PLAN_VIEW_BUTTON_ACTION)[keyof typeof SEWER_BSC_PLAN_VIEW_BUTTON_ACTION];
const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, SewerBscPlanViewButtonAction[]>> = {
ROLE_USER05: {
"": [
SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg,
SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.aply,
SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.del,
],
SV: [
SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg,
SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.aply,
SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.del,
],
},
ROLE_USER03: {
LE: [SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwRply, SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rqstTechRvw],
TP: [SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwRply],
},
};
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
string,
Record<string, SewerBscPlanViewButtonAction[]>
> = {
ER: {
ATRZ01: [SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dsgntRvwr],
},
TE: {
ATRZ02: [
SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwStrg,
SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwCmptn,
SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dmndSplmnt,
],
ATRZ03: [SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.atrzAprv],
ATRZ99: [SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.atrzRply],
},
};
function resolveButtonConfigs(
actions: SewerBscPlanViewButtonAction[],
): SewerBscPlanViewButtonAction[] {
return actions.map((action) => SEWER_BSC_PLAN_VIEW_BUTTON_ACTION[action]);
}
interface GetButtonConfigsOptions {
hasPendingApprovalRequest?: boolean;
}
function resolveRoleUser01AtrzButtonActions(
prgrsSttsComCd: string,
atrzSeComCdList: string[],
options?: GetButtonConfigsOptions,
): SewerBscPlanViewButtonAction[] {
if (prgrsSttsComCd === "TE" && options?.hasPendingApprovalRequest === false) {
return [];
}
const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd];
if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
const seen = new Set<SewerBscPlanViewButtonAction>();
const actions: SewerBscPlanViewButtonAction[] = [];
for (const atrzSeComCd of atrzSeComCdList) {
const mappedActions = atrzStatusMap[atrzSeComCd];
if (!mappedActions) continue;
for (const action of mappedActions) {
if (seen.has(action)) continue;
seen.add(action);
actions.push(action);
}
}
return actions;
}
export function needsRoleUser01AtrzResolve(
dtlInstCd: string | undefined,
prgrsSttsComCd: string,
): boolean {
return (
dtlInstCd === "ROLE_USER01" &&
ROLE_USER01_ATRZ_STATUS_CODES.includes(
prgrsSttsComCd as (typeof ROLE_USER01_ATRZ_STATUS_CODES)[number],
)
);
}
export function getButtonConfigs(
dtlInstCd: string | undefined,
prgrsSttsComCd: string,
atrzSeComCdList?: string[],
options?: GetButtonConfigsOptions,
): SewerBscPlanViewButtonAction[] {
if (!dtlInstCd) return [];
if (needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd)) {
return resolveButtonConfigs(
resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? [], options),
);
}
const statusMap = ROLE_STATUS_BUTTON_ACTIONS[dtlInstCd];
if (!statusMap) return [];
const actions = statusMap[prgrsSttsComCd];
if (!actions) return [];
return resolveButtonConfigs(actions);
}
@@ -0,0 +1,178 @@
import type { CklstResultItem, CklstRvwRsltTreeDto } from "@/entities/sewer-bsc-plan";
import {
type ChecklistFormState,
type ChecklistProgress,
type ChecklistRowKey,
type ChecklistStepMap,
REVIEW_STEP_CODES,
type ReviewStepCode,
} from "../model/view-types";
export function buildChecklistRowKey(
row: CklstRvwRsltTreeDto,
reviewStep?: ReviewStepCode,
): ChecklistRowKey {
const step = reviewStep ?? (row.rvwStpComCd as ReviewStepCode) ?? "LOCAL";
return `${row.taskSeComCd}_${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${step}`;
}
export function groupByReviewStep(rows: CklstRvwRsltTreeDto[]): ChecklistStepMap {
const base: ChecklistStepMap = { LOCAL: [], ENV: [], KECO: [] };
rows.forEach((row) => {
const step = (row.rvwStpComCd ?? "LOCAL") as ReviewStepCode;
if (REVIEW_STEP_CODES.includes(step)) base[step].push(row);
});
return base;
}
export function extractTaskCodes(rows: CklstRvwRsltTreeDto[]): string[] {
return Array.from(new Set(rows.map((row) => row.taskSeComCd).filter(Boolean))) as string[];
}
export function filterChecklistRows(
rows: CklstRvwRsltTreeDto[],
taskCode: string | null,
): CklstRvwRsltTreeDto[] {
return rows.filter((row) => (taskCode ? row.taskSeComCd === taskCode : true)).slice();
}
export function getChecklistDisplayRows(
rows: CklstRvwRsltTreeDto[],
taskCode?: string | null,
): CklstRvwRsltTreeDto[] {
const filtered = filterChecklistRows(rows, taskCode ?? null);
const seen = new Set<string>();
const deduped: CklstRvwRsltTreeDto[] = [];
for (const row of filtered) {
const dedupeKey = `${row.taskSeComCd}_${row.cklstRvwSn}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
deduped.push(row);
}
return deduped;
}
export function computeChecklistProgress(
rows: CklstRvwRsltTreeDto[],
results: ChecklistFormState["results"],
): ChecklistProgress {
const activeRows = rows.filter((row) => row.bchckUseYn === "Y");
const total = activeRows.length;
const completed = activeRows.reduce((acc, row) => {
const key = buildChecklistRowKey(row);
const val = results[key] ?? row.rvwRsltAnmlyYn ?? "";
return acc + (val ? 1 : 0);
}, 0);
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
return { total, completed, percent };
}
export function filterChecklistRowsByReviewStep(
rows: CklstRvwRsltTreeDto[],
reviewStep: ReviewStepCode,
): CklstRvwRsltTreeDto[] {
return rows.filter((row) => row.rvwStpComCd === reviewStep);
}
export function computeReviewStepChecklistProgress(
rows: CklstRvwRsltTreeDto[],
results: ChecklistFormState["results"],
reviewStep: ReviewStepCode,
): ChecklistProgress {
const activeRows = getChecklistDisplayRows(rows).filter((row) => row.bchckUseYn === "Y");
const total = activeRows.length;
const completed = activeRows.reduce((acc, row) => {
const key = buildChecklistRowKey(row, reviewStep);
const val = results[key];
return acc + (val === "Y" || val === "N" ? 1 : 0);
}, 0);
const percent = total > 0 ? Math.round((completed / total) * 100) : 100;
return { total, completed, percent };
}
export function buildChecklistFormState(rows: CklstRvwRsltTreeDto[]): ChecklistFormState {
const results: ChecklistFormState["results"] = {};
const comments: ChecklistFormState["comments"] = {};
rows.forEach((row) => {
const key = buildChecklistRowKey(row);
if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") {
results[key] = row.rvwRsltAnmlyYn;
}
if (row.picRvwCn) comments[key] = row.picRvwCn;
});
return {
results,
comments,
progress: computeChecklistProgress(rows, results),
};
}
export function mergeChecklistFormStateFromServer(
prev: ChecklistFormState,
allRows: CklstRvwRsltTreeDto[],
): ChecklistFormState {
const results = { ...prev.results };
const comments = { ...prev.comments };
for (const row of allRows) {
const step = row.rvwStpComCd as ReviewStepCode | undefined;
if (!step || !REVIEW_STEP_CODES.includes(step)) continue;
const key = buildChecklistRowKey(row);
if ((row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") && results[key] === undefined) {
results[key] = row.rvwRsltAnmlyYn;
}
if (row.picRvwCn && !comments[key]) {
comments[key] = row.picRvwCn;
}
}
return {
results,
comments,
progress: computeChecklistProgress(getChecklistDisplayRows(allRows), results),
};
}
export function patchChecklistFormState(
prev: ChecklistFormState,
patch: Partial<Pick<ChecklistFormState, "results" | "comments">>,
allRows: CklstRvwRsltTreeDto[],
reviewStep?: ReviewStepCode,
): ChecklistFormState {
const results = patch.results ?? prev.results;
const comments = patch.comments ?? prev.comments;
const progress = reviewStep
? computeReviewStepChecklistProgress(allRows, results, reviewStep)
: computeChecklistProgress(getChecklistDisplayRows(allRows), results);
return {
results,
comments,
progress,
};
}
export function toCklstSaveDto(
rows: CklstRvwRsltTreeDto[],
formState: ChecklistFormState,
reviewStep: ReviewStepCode,
): CklstResultItem[] {
return getChecklistDisplayRows(rows)
.filter((row) => row.bchckUseYn === "Y")
.map((row) => {
const key = buildChecklistRowKey(row, reviewStep);
const selected = formState.results[key] ?? null;
return {
cklstSn: row.cklstSn,
cklstArtclSn: row.cklstArtclSn,
cklstRvwSn: row.cklstRvwSn,
rvwStpComCd: reviewStep,
selected: selected ?? undefined,
picRvwCn: formState.comments[key] ?? null,
};
});
}
@@ -3,6 +3,7 @@ import type {
SewerBscPlanApplySaveRequestDto, SewerBscPlanApplySaveRequestDto,
SewerBscPlanDetResponseDto, SewerBscPlanDetResponseDto,
} from "@/entities/sewer-bsc-plan"; } from "@/entities/sewer-bsc-plan";
import { sewerBscPlanApplySaveSchema } from "@/entities/sewer-bsc-plan";
import type { BizCstMatrixItem } from "./biz-cst-matrix-utils"; import type { BizCstMatrixItem } from "./biz-cst-matrix-utils";
import { recalcFnrsCstSumAmt } from "./fnrs-cst-form-utils"; import { recalcFnrsCstSumAmt } from "./fnrs-cst-form-utils";
@@ -244,43 +245,33 @@ export function buildApplyPayload(
prgrsSttsComCd: "SV" | "LE" | "TE", prgrsSttsComCd: "SV" | "LE" | "TE",
options?: { isEditMode?: boolean; docNo?: string }, options?: { isEditMode?: boolean; docNo?: string },
): SewerBscPlanApplySaveRequestDto { ): SewerBscPlanApplySaveRequestDto {
const { taskSeComCd, ...sewerBscPlanRest } = data.sewerBscPlan; const docNo = options?.isEditMode ? options.docNo : undefined;
return { return sewerBscPlanApplySaveSchema.parse({
sewerBscPlan: { sewerBscPlan: {
...sewerBscPlanRest, ...data.sewerBscPlan,
taskSeComCd: taskSeComCd ?? "", sewerBscPlanDocNo: docNo,
sewerBscPlanDocNo: options?.isEditMode ? options.docNo : undefined, taskSeComCd: data.sewerBscPlan.taskSeComCd ?? "",
prgrsSttsComCd, prgrsSttsComCd,
}, },
sewerBscPlanDtl: { sewerBscPlanDtl: {
...data.sewerBscPlanDtl, ...data.sewerBscPlanDtl,
sewerBscPlanDocNo: options?.isEditMode ? options.docNo : undefined, sewerBscPlanDocNo: docNo,
}, },
sewerBscPlanOtlnList: data.sewerBscPlanOtlnList, sewerBscPlanOtlnList: data.sewerBscPlanOtlnList,
bizCstList: data.bizCstList, bizCstList: data.bizCstList,
fnrsCstList: data.fnrsCstList.map((item) => recalcFnrsCstSumAmt(item)), fnrsCstList: data.fnrsCstList.map((item) => recalcFnrsCstSumAmt(item)),
sewerBscPlanTmplntList: data.sewerBscPlanTmplntList.map( sewerBscPlanTmplntList: data.sewerBscPlanTmplntList,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ atchFileNm, swtrmtFcltNm, ...item }) => ({
swtrmtFcltCd: item.swtrmtFcltCd,
atchFileGroupId: item.atchFileGroupId,
sewerBscPlanTmplntDtlList: item.sewerBscPlanTmplntDtlList,
}),
),
cklstResultList: data.cklstResultList.map((item) => ({ cklstResultList: data.cklstResultList.map((item) => ({
...item, ...item,
selected: item.selected ?? undefined, selected: item.selected ?? undefined,
})), })),
esntlAtchFileList: data.esntlAtchFileList, esntlAtchFileList: data.esntlAtchFileList,
splmntDmndList: data.splmntDmndList.map( splmntDmndList: data.splmntDmndList.map((item) => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars ...item,
({ splmntAtchFileNm, splmntAtchFileAtflId, splmntDmndAtchFileNm, ...item }) => ({ cmptnYn: prgrsSttsComCd === "TE" ? "Y" : item.cmptnYn,
...item, })),
...(prgrsSttsComCd === "TE" ? { cmptnYn: "Y" } : null), });
}),
),
};
} }
export { createEmptyTmplntDtlList }; export { createEmptyTmplntDtlList };
@@ -0,0 +1,176 @@
import type {
EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
SewerBscPlanApplySaveRequestDto,
SewerBscPlanDetResponseDto,
SewerBscPlanSplmntDmndItem,
} from "@/entities/sewer-bsc-plan";
import type { SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
import type { ChecklistFormState, ReviewStepCode } from "../model/view-types";
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,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
): SewerBscPlanApplySaveRequestDto {
const detDto = data.detDto;
const dtl = data.activeDtlDto;
if (!detDto) {
throw new Error("상세 정보(detDto)가 없습니다.");
}
const cklstResultRows = data.cklstResultRows ?? [];
const esntlFileList = data.esntlFileList ?? [];
return {
sewerBscPlan: {
sewerBscPlanDocNo: detDto.sewerBscPlanDocNo,
admdstCd: detDto.admdstCd,
envoSeComCd: detDto.envoSeComCd,
sewerBscPlanSeComCd: detDto.sewerBscPlanSeComCd,
sewerBscPlanNm: detDto.sewerBscPlanNm,
taskSeComCd: detDto.taskSeComCd,
prgrsSttsComCd,
},
sewerBscPlanDtl: {
sewerBscPlanDocNo: detDto.sewerBscPlanDocNo,
chgCycl: dtl?.chgCycl ?? detDto.chgCycl,
aplcntNm: dtl?.aplcntNm,
aplcntMblTelno: dtl?.aplcntMblTelno,
aplcntOgdpNm: dtl?.aplcntOgdpNm,
aplcntEmlAddr: dtl?.aplcntEmlAddr,
dsgnrNm: dtl?.dsgnrNm,
dsgnrTelno: dtl?.dsgnrTelno,
dsgnrOgdpNm: dtl?.dsgnrOgdpNm,
dsgnrEmlAddr: dtl?.dsgnrEmlAddr,
planYr: dtl?.planYr,
crtrYr: dtl?.crtrYr,
bizOtlnCn: dtl?.bizOtlnCn,
frlvlGoalYr: dtl?.frlvlGoalYr,
sclvlGoalYr: dtl?.sclvlGoalYr,
thlvlGoalYr: dtl?.thlvlGoalYr,
folvlGoalYr: dtl?.folvlGoalYr,
atchFileGroupId: dtl?.atchFileGroupId,
},
sewerBscPlanOtlnList: (data.otlnList ?? []).map((item) => ({
planStpNo: item.planStpNo,
sewerPlanPpltnCnt: item.sewerPlanPpltnCnt,
sewerPrcsPpltnCnt: item.sewerPrcsPpltnCnt,
sewerPsplyRt: item.sewerPsplyRt,
})),
bizCstList: (data.bizCstList ?? []).map((item) => ({
planStpNo: item.planStpNo,
acntgSeCd: item.acntgSeCd,
cstrnAmt: item.cstrnAmt,
})),
fnrsCstList: (data.fnrsCstList ?? []).map((item) =>
recalcFnrsCstSumAmt({
planStpNo: item.planStpNo ?? 1,
gvexpAmt: item.gvexpAmt ?? 0,
lcexpAmt: item.lcexpAmt ?? 0,
orgprBdchgAmt: item.orgprBdchgAmt ?? 0,
etcAmt: item.etcAmt ?? 0,
sumAmt: item.sumAmt ?? 0,
}),
),
sewerBscPlanTmplntList: (data.tmplntList ?? []).map((item) => ({
swtrmtFcltCd: item.swtrmtFcltCd,
atchFileGroupId: item.atchFileGroupId,
sewerBscPlanTmplntDtlList: (item.sewerBscPlanTmplntDtlList ?? []).map((dtlItem) => ({
planStpNo: dtlItem.planStpNo,
swtrmtFcltCd: dtlItem.swtrmtFcltCd ?? item.swtrmtFcltCd,
prcsZoneArea: dtlItem.prcsZoneArea,
swtrmtPpltnCnt: dtlItem.swtrmtPpltnCnt,
planSwqty: dtlItem.planSwqty,
fcltCpct: dtlItem.fcltCpct,
sprsysDrtwPplLen: dtlItem.sprsysDrtwPplLen,
sprsysExlnPplLen: dtlItem.sprsysExlnPplLen,
cbsysPplLen: dtlItem.cbsysPplLen,
icptgPplLen: dtlItem.icptgPplLen,
drgCnt: dtlItem.drgCnt,
drtwRlyPumpCnt: dtlItem.drtwRlyPumpCnt,
drtwPumpCnt: dtlItem.drtwPumpCnt,
exlnPumpCnt: dtlItem.exlnPumpCnt,
})),
})),
cklstResultList: toCklstSaveDto(cklstResultRows, checklistFormState, reviewStep),
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
};
}
export function buildTechReviewReplyPayload(
data: SewerBscPlanDetResponseDto,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
techReViewSaveCd: string,
reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
opnrNm?: string,
): SewerBscPlanApplySaveRequestDto {
return {
...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
techReViewSaveCd,
rvwStpCd: reviewStep,
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
};
}
export function buildSupplementReqPayload(
data: SewerBscPlanDetResponseDto,
payload: SplmntDmndDialogConfirmPayload,
checklistFormState: ChecklistFormState,
): SewerBscPlanApplySaveRequestDto {
const existingItems: SewerBscPlanSplmntDmndItem[] = (data.splmntDmndList ?? []).map((item) =>
toSplmntDmndSaveItem(item),
);
const newItems: SewerBscPlanSplmntDmndItem[] = payload.items.map((item) => ({
splmntDmndSn: null,
splmntArtclComCd: item.splmntArtclComCd,
splmntDmndCn: item.splmntDmndCn,
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
cmptnYn: "N",
}));
return {
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
splmntDmndList: [...existingItems, ...newItems],
};
}
export function toSplmntDmndSaveItem(item: SewerBscPlanSplmntDmndItem): SewerBscPlanSplmntDmndItem {
return {
splmntDmndSn: item.splmntDmndSn,
splmntArtclComCd: item.splmntArtclComCd,
splmntDmndCn: item.splmntDmndCn,
splmntDmndDt: item.splmntDmndDt,
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
splmntCn: item.splmntCn,
splmntPicNm: item.splmntPicNm,
splmntCmptnDt: item.splmntCmptnDt,
splmntAtchFileGroupId: item.splmntAtchFileGroupId,
cmptnYn: item.cmptnYn,
};
}
@@ -0,0 +1,87 @@
import type {
SewerBscPlanDetResponseDto,
SewerBscPlanRvwOpnnItem,
SewerBscPlanRvwOpnnResponseDto,
} from "@/entities/sewer-bsc-plan";
export interface ReviewOpinionFormData {
rvwRsltCn: string;
rvwRsltAnmlyYn: "Y" | "N" | "";
atchFileGroupId: string;
opnrSeComCd: string;
rvwOpnnSn?: number;
}
export interface ReviewOpinionGetFormDataResult {
formData: ReviewOpinionFormData;
rvwList: SewerBscPlanRvwOpnnResponseDto[];
}
function hasReviewOpinionContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
function mapServerItemToSaveDto(item: SewerBscPlanRvwOpnnResponseDto): SewerBscPlanRvwOpnnItem | null {
if (item.rvwOpnnSn == null) return null;
return {
rvwOpnnSn: item.rvwOpnnSn,
opnrSeComCd: item.opnrSeComCd,
opnrNm: item.opnrNm,
rvwRsltCn: item.rvwRsltCn,
rvwRsltAnmlyYn: item.rvwRsltAnmlyYn || undefined,
atchFileGroupId: item.atchFileGroupId || undefined,
};
}
function mapFormDataToSaveDto(
formData: ReviewOpinionFormData,
opnrNm?: string,
): SewerBscPlanRvwOpnnItem {
const base: SewerBscPlanRvwOpnnItem = {
opnrSeComCd: formData.opnrSeComCd,
opnrNm: opnrNm ?? "",
rvwRsltCn: formData.rvwRsltCn,
rvwRsltAnmlyYn: formData.rvwRsltAnmlyYn || undefined,
atchFileGroupId: formData.atchFileGroupId || undefined,
};
if (formData.rvwOpnnSn != null) {
return { ...base, rvwOpnnSn: formData.rvwOpnnSn };
}
return base;
}
export function toRvwOpnnSaveDto(
data: SewerBscPlanDetResponseDto,
result: ReviewOpinionGetFormDataResult | null | undefined,
opnrNm?: string,
): SewerBscPlanRvwOpnnItem[] | undefined {
if (!result || !data.detDto) return undefined;
const { formData, rvwList } = result;
const list: SewerBscPlanRvwOpnnItem[] = rvwList
.map((item) => mapServerItemToSaveDto(item))
.filter((item): item is SewerBscPlanRvwOpnnItem => item != null);
if (!hasReviewOpinionContent(formData)) {
return list.length > 0 ? list : undefined;
}
const formDto = mapFormDataToSaveDto(formData, opnrNm);
if (formData.rvwOpnnSn != null) {
const existingIndex = list.findIndex((item) => item.rvwOpnnSn === formData.rvwOpnnSn);
if (existingIndex >= 0) {
list[existingIndex] = formDto;
} else {
list.push(formDto);
}
} else {
list.push(formDto);
}
return list.length > 0 ? list : undefined;
}
@@ -0,0 +1,44 @@
import {
PBADMS_PROGRESS_ORDER,
REVIEW_STEP_CODES,
REVIEW_STEP_ROLE_RANK,
type ReviewStepCode,
ROLE_TO_REVIEW_STEP,
} from "../model/view-types";
export function getProgressStepCount(prgrsSttsComCd?: string): number {
const idx = PBADMS_PROGRESS_ORDER.indexOf(
prgrsSttsComCd as (typeof PBADMS_PROGRESS_ORDER)[number],
);
if (idx <= PBADMS_PROGRESS_ORDER.indexOf("SV")) return 1;
if (idx <= PBADMS_PROGRESS_ORDER.indexOf("LE")) return 2;
return 3;
}
export function getRoleStepCount(dtlInstCd?: string): number {
const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined;
return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0;
}
export function getVisibleReviewSteps(
prgrsSttsComCd: string,
userRoleCode?: string,
): ReviewStepCode[] {
const count = Math.min(getProgressStepCount(prgrsSttsComCd), getRoleStepCount(userRoleCode));
return REVIEW_STEP_CODES.slice(0, count);
}
export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
): ReviewStepCode | undefined {
if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) {
return "ENV";
}
if (dtlInstCd === "ROLE_USER01" && prgrsSttsComCd === "TE") {
return "KECO";
}
return undefined;
}
@@ -1,38 +1,27 @@
import { z } from "zod"; import { z } from "zod";
const tmplntDtlSchema = z.object({ import {
planStpNo: z.number(), sewerBscPlanBizCstItemSchema,
swtrmtFcltCd: z.string(), sewerBscPlanCklstResultItemSchema,
prcsZoneArea: z.number().optional(), sewerBscPlanDtlSaveSchema,
swtrmtPpltnCnt: z.number().optional(), sewerBscPlanEsntlAtchFileItemSchema,
planSwqty: z.number().optional(), sewerBscPlanFnrsCstItemSchema,
fcltCpct: z.number().optional(), sewerBscPlanOtlnItemSchema,
sprsysDrtwPplLen: z.number().optional(), sewerBscPlanSaveSchema,
sprsysExlnPplLen: z.number().optional(), sewerBscPlanSplmntDmndItemSchema,
cbsysPplLen: z.number().optional(), sewerBscPlanTmplntDtlItemSchema,
icptgPplLen: z.number().optional(), sewerBscPlanTmplntItemSchema,
drgCnt: z.number().optional(), } from "@/entities/sewer-bsc-plan";
drtwRlyPumpCnt: z.number().optional(),
drtwPumpCnt: z.number().optional(),
exlnPumpCnt: z.number().optional(),
});
export const sewerBscPlanRegistSchema = z.object({ export const sewerBscPlanRegistSchema = z.object({
sewerBscPlan: z.object({ sewerBscPlan: sewerBscPlanSaveSchema.extend({
sewerBscPlanDocNo: z.string().optional(),
sewerBscPlanNm: z.string().min(1, "기본계획명은 필수입니다"), sewerBscPlanNm: z.string().min(1, "기본계획명은 필수입니다"),
sewerBscPlanSeComCd: z.string().optional(),
sewerBscPlanSeCdNm: z.string().optional(), sewerBscPlanSeCdNm: z.string().optional(),
taskSeComCd: z.string().optional(),
taskSeCdNm: z.string().optional(), taskSeCdNm: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
sido: z.string().optional(), sido: z.string().optional(),
gugun: z.string().optional(), gugun: z.string().nullish(),
}), }),
sewerBscPlanDtl: z.object({ sewerBscPlanDtl: sewerBscPlanDtlSaveSchema.extend({
chgCycl: z.number().optional(),
planYr: z.string().min(1, "계획년도는 필수입니다"), planYr: z.string().min(1, "계획년도는 필수입니다"),
crtrYr: z.string().min(1, "기준년도는 필수입니다"), crtrYr: z.string().min(1, "기준년도는 필수입니다"),
frlvlGoalYr: z.string().min(1, "1단계 목표연도는 필수입니다"), frlvlGoalYr: z.string().min(1, "1단계 목표연도는 필수입니다"),
@@ -40,10 +29,6 @@ export const sewerBscPlanRegistSchema = z.object({
thlvlGoalYr: z.string().min(1, "3단계 목표연도는 필수입니다"), thlvlGoalYr: z.string().min(1, "3단계 목표연도는 필수입니다"),
folvlGoalYr: z.string().min(1, "4단계 목표연도는 필수입니다"), folvlGoalYr: z.string().min(1, "4단계 목표연도는 필수입니다"),
bizOtlnCn: z.string().min(1, "계획개요는 필수입니다"), bizOtlnCn: z.string().min(1, "계획개요는 필수입니다"),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"), dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
dsgnrTelno: z dsgnrTelno: z
.string() .string()
@@ -54,74 +39,60 @@ export const sewerBscPlanRegistSchema = z.object({
.string() .string()
.min(1, "설계자 이메일은 필수입니다") .min(1, "설계자 이메일은 필수입니다")
.email("올바른 이메일 형식이 아닙니다"), .email("올바른 이메일 형식이 아닙니다"),
atchFileGroupId: z.string().optional(),
}), }),
sewerBscPlanOtlnList: z.array( sewerBscPlanOtlnList: z.array(
z.object({ sewerBscPlanOtlnItemSchema.extend({
planStpNo: z.number(), planStpNo: z.number(),
sewerPlanPpltnCnt: z.number().optional(),
sewerPrcsPpltnCnt: z.number().optional(),
sewerPsplyRt: z.number().optional(),
}), }),
), ),
bizCstList: z.array( bizCstList: z.array(
z.object({ sewerBscPlanBizCstItemSchema.extend({
planStpNo: z.number(), planStpNo: z.number(),
acntgSeCd: z.string(), acntgSeCd: z.string(),
cstrnAmt: z.number(), cstrnAmt: z.number(),
}), }),
), ),
fnrsCstList: z.array( fnrsCstList: z.array(
z.object({ sewerBscPlanFnrsCstItemSchema.extend({
planStpNo: z.number(), planStpNo: z.number(),
gvexpAmt: z.number().optional(),
lcexpAmt: z.number().optional(),
orgprBdchgAmt: z.number().optional(),
etcAmt: z.number().optional(),
sumAmt: z.number().optional(),
}), }),
), ),
sewerBscPlanTmplntList: z.array( sewerBscPlanTmplntList: z.array(
z.object({ sewerBscPlanTmplntItemSchema.extend({
swtrmtFcltCd: z.string(), swtrmtFcltCd: z.string(),
swtrmtFcltNm: z.string().nullish(), swtrmtFcltNm: z.string().nullish(),
atchFileGroupId: z.string().optional(),
atchFileNm: z.string().optional(), atchFileNm: z.string().optional(),
sewerBscPlanTmplntDtlList: z.array(tmplntDtlSchema).optional(), sewerBscPlanTmplntDtlList: z
.array(
sewerBscPlanTmplntDtlItemSchema.extend({
planStpNo: z.number(),
swtrmtFcltCd: z.string(),
}),
)
.optional(),
}), }),
), ),
cklstResultList: z.array( cklstResultList: z.array(
z.object({ sewerBscPlanCklstResultItemSchema.extend({
cklstSn: z.number(), cklstSn: z.number(),
cklstArtclSn: z.number(), cklstArtclSn: z.number(),
cklstRvwSn: z.number(), cklstRvwSn: z.number(),
rvwStpComCd: z.string(),
selected: z.enum(["Y", "N"]).nullable(), selected: z.enum(["Y", "N"]).nullable(),
picRvwCn: z.string().nullable().optional(), picRvwCn: z.string().nullable().optional(),
}), }),
), ),
esntlAtchFileList: z.array( esntlAtchFileList: z.array(
z.object({ sewerBscPlanEsntlAtchFileItemSchema.extend({
esntlAtchFileId: z.string(), esntlAtchFileId: z.string(),
atchFileGroupId: z.string(), atchFileGroupId: z.string(),
}), }),
), ),
splmntDmndList: z.array( splmntDmndList: z.array(
z.object({ sewerBscPlanSplmntDmndItemSchema.extend({
splmntDmndSn: z.number().optional(),
splmntArtclComCd: z.string().optional(),
splmntArtclCdNm: z.string().nullish(), splmntArtclCdNm: z.string().nullish(),
splmntDmndCn: z.string().optional(),
splmntDmndDt: z.string().optional(),
splmntDmndAtchFileGroupId: z.string().optional(),
splmntDmndAtchFileNm: 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(), splmntAtchFileNm: z.string().optional(),
splmntAtchFileAtflId: z.string().optional(), splmntAtchFileAtflId: z.string().optional(),
cmptnYn: z.string().optional(),
}), }),
), ),
privacyConsent: z.enum(["Y", "N"], { privacyConsent: z.enum(["Y", "N"], {
@@ -130,6 +101,3 @@ export const sewerBscPlanRegistSchema = z.object({
}); });
export type SewerBscPlanRegistFormData = z.infer<typeof sewerBscPlanRegistSchema>; export type SewerBscPlanRegistFormData = z.infer<typeof sewerBscPlanRegistSchema>;
/** 체크리스트·필수첨부 docSeComCd — CMS 합의 전 임시값 */
export const SEWER_BSC_PLAN_DOC_SE_COM_CD = "SEWER";
@@ -0,0 +1,61 @@
import { useCallback, useEffect, useState } from "react";
import type { CklstRvwRsltTreeDto } from "@/entities/sewer-bsc-plan";
import {
type ChecklistFormState,
DEFAULT_CHECKLIST_PROGRESS,
type ReviewStepCode,
} from "../model/view-types";
import { mergeChecklistFormStateFromServer, patchChecklistFormState } from "./checklist-utils";
const EMPTY_CKLST_RESULT_LIST: CklstRvwRsltTreeDto[] = [];
function isSameChecklistFormState(a: ChecklistFormState, b: ChecklistFormState): boolean {
if (
a.progress.total !== b.progress.total ||
a.progress.completed !== b.progress.completed ||
a.progress.percent !== b.progress.percent
) {
return false;
}
const aResultKeys = Object.keys(a.results);
if (aResultKeys.length !== Object.keys(b.results).length) return false;
if (!aResultKeys.every((key) => a.results[key] === b.results[key])) return false;
const aCommentKeys = Object.keys(a.comments);
if (aCommentKeys.length !== Object.keys(b.comments).length) return false;
return aCommentKeys.every((key) => a.comments[key] === b.comments[key]);
}
function useChecklistFormState(cklstResultList: CklstRvwRsltTreeDto[] = EMPTY_CKLST_RESULT_LIST) {
const [formState, setFormState] = useState<ChecklistFormState>({
results: {},
comments: {},
progress: DEFAULT_CHECKLIST_PROGRESS,
});
useEffect(() => {
setFormState((prev) => {
const next = mergeChecklistFormStateFromServer(prev, cklstResultList);
return isSameChecklistFormState(prev, next) ? prev : next;
});
}, [cklstResultList]);
const patchFormState = useCallback(
(
patch: Partial<Pick<ChecklistFormState, "results" | "comments">>,
reviewStep?: ReviewStepCode,
) => {
setFormState((prev) => patchChecklistFormState(prev, patch, cklstResultList, reviewStep));
},
[cklstResultList],
);
return { formState, setFormState, patchFormState };
}
export { EMPTY_CKLST_RESULT_LIST, useChecklistFormState };
@@ -19,7 +19,7 @@ const MAIN_STEP_LABELS = {
supplement: "보완요청", supplement: "보완요청",
} as const; } as const;
const TECH_REVIEW_STATUS_CODES = new Set(["EE", "ER", "TE", "TS", "TR", "TP"]); const TECH_REVIEW_STATUS_CODES = new Set(["ER", "TE", "TP"]);
type BadgeState = "completed" | "current" | "pending" | "action"; type BadgeState = "completed" | "current" | "pending" | "action";
@@ -42,7 +42,7 @@ function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): Pro
]; ];
} }
if (prgrsSttsComCd === "ST") { if (prgrsSttsComCd === "LE") {
return [ return [
{ label: MAIN_STEP_LABELS.save, state: "completed" }, { label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
@@ -52,22 +52,13 @@ function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): Pro
]; ];
} }
if (prgrsSttsComCd === "LE") {
return [
{ label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.reply, state: "action" },
];
}
if (prgrsSttsComCd === "SL") { if (prgrsSttsComCd === "SL") {
return [ return [
{ label: MAIN_STEP_LABELS.save, state: "completed" }, { label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" }, { label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.supplement, state: "action" }, { label: MAIN_STEP_LABELS.supplement, state: "action" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
@@ -81,17 +72,7 @@ function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): Pro
]; ];
} }
if (prgrsSttsComCd === "EP") { if (prgrsSttsComCd === "TA") {
return [
{ label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
{ label: replyLabel, state: "action" },
];
}
if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") {
return [ return [
{ label: MAIN_STEP_LABELS.save, state: "completed" }, { label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
@@ -101,6 +82,16 @@ function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): Pro
]; ];
} }
if (prgrsSttsComCd === "EP") {
return [
{ label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
{ label: replyLabel, state: "completed" },
];
}
return [{ label: prgrsSttsCdNm || prgrsSttsComCd || "-", state: "current" }]; return [{ label: prgrsSttsCdNm || prgrsSttsComCd || "-", state: "current" }];
} }
@@ -0,0 +1,69 @@
import type {
AtflMngClientResponseDto,
CklstRvwRsltTreeDto,
EsntlAtchFileMngTreeResponseDto,
SewerBscPlanDetResponseDto,
SewerBscPlanResponseDto,
SewerBscPlanRvwOpnnResponseDto,
} from "@/entities/sewer-bsc-plan";
export type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
export type {
AtflMngClientResponseDto,
CklstRvwRsltTreeDto,
EsntlAtchFileMngTreeResponseDto,
SewerBscPlanDetResponseDto,
SewerBscPlanResponseDto,
SewerBscPlanRvwOpnnResponseDto,
};
export type ChecklistRowKey = string;
export interface ChecklistProgress {
total: number;
completed: number;
percent: number;
}
export const DEFAULT_CHECKLIST_PROGRESS: ChecklistProgress = {
total: 0,
completed: 0,
percent: 0,
};
export interface ChecklistFormState {
results: Record<ChecklistRowKey, "Y" | "N">;
comments: Record<ChecklistRowKey, string>;
progress: ChecklistProgress;
}
export type ChecklistStepMap = Record<ReviewStepCode, CklstRvwRsltTreeDto[]>;
export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체",
ENV: "환경청",
KECO: "환경공단",
};
export const TASK_CODE_LABELS: Record<string, string> = {
CP: "부분변경",
AP: "전체변경",
MP: "병합",
};
export const PBADMS_PROGRESS_ORDER = ["SV", "SL", "TA", "EP", "LE", "TP", "ER", "TE"] as const;
export const ROLE_TO_REVIEW_STEP: Record<string, ReviewStepCode> = {
ROLE_USER05: "LOCAL",
ROLE_USER03: "ENV",
ROLE_USER01: "KECO",
};
export const REVIEW_STEP_ROLE_RANK: Record<ReviewStepCode, number> = {
LOCAL: 1,
ENV: 2,
KECO: 3,
};
@@ -0,0 +1,196 @@
import type { Dispatch, SetStateAction } from "react";
import type { CklstRvwRsltTreeDto } from "@/entities/sewer-bsc-plan";
import {
Button,
cn,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import { buildChecklistRowKey, patchChecklistFormState } from "../../lib/checklist-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { type ChecklistFormState, ROLE_TO_REVIEW_STEP } from "../../model/view-types";
interface ChecklistTaskPanelProps {
selectedReviewStep: ReviewStepCode;
selectedRows: CklstRvwRsltTreeDto[];
allRows: CklstRvwRsltTreeDto[];
formState: ChecklistFormState;
setFormState: Dispatch<SetStateAction<ChecklistFormState>>;
userRoleCode?: string;
}
function ChecklistTaskPanel({
selectedReviewStep,
selectedRows,
allRows,
formState,
setFormState,
userRoleCode,
}: ChecklistTaskPanelProps) {
const currentUserReviewStep = userRoleCode ? ROLE_TO_REVIEW_STEP[userRoleCode] : undefined;
const isStepEditable = currentUserReviewStep === selectedReviewStep;
function handleSelectAll(value: "Y" | "N") {
if (!isStepEditable) return;
setFormState((prev) => {
const nextResults = { ...prev.results };
selectedRows
.filter((row) => row.bchckUseYn === "Y")
.forEach((row) => {
nextResults[buildChecklistRowKey(row, selectedReviewStep)] = value;
});
return patchChecklistFormState(prev, { results: nextResults }, allRows, selectedReviewStep);
});
}
return (
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[20rem]" rowSpan={2}>
검토항목
</TableHead>
<TableHead rowSpan={2}>주요 검토내용</TableHead>
<TableHead colSpan={2}>검토 결과</TableHead>
{selectedReviewStep !== "LOCAL" ? (
<TableHead rowSpan={2} className="w-[20rem]">
기술검토 의견
</TableHead>
) : null}
</TableRow>
<TableRow>
<TableHead className="w-[12rem]">
<Button
type="button"
size="sm"
color="white"
disabled={!isStepEditable}
onClick={() => handleSelectAll("Y")}
>
전체 예
</Button>
</TableHead>
<TableHead className="w-[12rem]">
<Button
type="button"
size="sm"
color="gray"
disabled={!isStepEditable}
onClick={() => handleSelectAll("N")}
>
전체 아니오
</Button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center text-muted-foreground">
조회된 체크리스트 항목이 없습니다.
</TableCell>
</TableRow>
) : (
selectedRows.map((row) => {
const key = buildChecklistRowKey(row, selectedReviewStep);
const isFieldVisible = row.bchckUseYn === "Y";
const isLocalReviewStep = selectedReviewStep === "LOCAL";
return (
<TableRow key={key}>
<TableCell className="text-center text-[1.2rem]">{row.cklstArtclNm}</TableCell>
<TableCell
className="pl-2 !text-left text-[1.2rem] text-gray-600"
style={{ paddingLeft: `${Math.max(0, row.treeLevel - 1) * 16 + 8}px` }}
colSpan={isFieldVisible ? 0 : isLocalReviewStep ? 3 : 4}
>
{row.rvwCn}
</TableCell>
{isFieldVisible && (
<>
<TableCell className="text-center">
<input
type="radio"
name={`chk_${key}`}
value="Y"
checked={formState.results[key] === "Y"}
readOnly={!isStepEditable}
onChange={() => {
if (!isStepEditable) return;
setFormState((prev) =>
patchChecklistFormState(
prev,
{ results: { ...prev.results, [key]: "Y" } },
allRows,
selectedReviewStep,
),
);
}}
className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")}
/>
</TableCell>
<TableCell className="text-center">
<input
type="radio"
name={`chk_${key}`}
value="N"
checked={formState.results[key] === "N"}
readOnly={!isStepEditable}
onChange={() => {
if (!isStepEditable) return;
setFormState((prev) =>
patchChecklistFormState(
prev,
{ results: { ...prev.results, [key]: "N" } },
allRows,
selectedReviewStep,
),
);
}}
className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")}
/>
</TableCell>
</>
)}
{!isLocalReviewStep && isFieldVisible && (
<TableCell className="text-left">
<Textarea
className={cn(
"min-h-[6rem] w-[32rem] !text-xl",
isStepEditable ? "cursor-pointer" : "cursor-not-allowed",
)}
value={formState.comments[key] ?? ""}
readOnly={!isStepEditable}
onChange={(event) => {
if (!isStepEditable) return;
setFormState((prev) =>
patchChecklistFormState(
prev,
{ comments: { ...prev.comments, [key]: event.target.value } },
allRows,
selectedReviewStep,
),
);
}}
/>
</TableCell>
)}
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
);
}
export { ChecklistTaskPanel };
@@ -0,0 +1,550 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import {
buildElaprPrgrsSttsListFromAplnRows,
ElaprApprovalDialog,
type ElaprDmndSaveRequestDto,
elaprDmndService,
type ElaprTmpltPickerConfirmPayload,
ElaprTmpltPickerDialog,
hasCurrentUserPendingApprovalRequest,
useElaprDocPrgrsByDocNo,
} from "@/entities/elapr";
import { instlSncnService } from "@/entities/instl-sncn";
import {
type SewerBscPlanApplySaveRequestDto,
sewerBscPlanBaseKey,
type SewerBscPlanDetResponseDto,
sewerBscPlanService,
} from "@/entities/sewer-bsc-plan";
import { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import {
getButtonConfigs,
needsRoleUser01AtrzResolve,
SEWER_BSC_PLAN_VIEW_BUTTON_ACTION,
type SewerBscPlanViewButtonAction,
} from "../../lib/button-config";
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
import {
buildSavePayload,
buildSupplementReqPayload,
buildTechReviewReplyPayload,
toSplmntDmndSaveItem,
} from "../../lib/payload-utils";
import { type ChecklistFormState } from "../../model/view-types";
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
interface FloatingActionButtonsProps {
data: SewerBscPlanDetResponseDto;
docSeComCd: string;
sewerBscPlanDocNo: string;
chgCycl: number;
prgrsSttsComCd: string;
dtlInstCd: string | undefined;
atrzSeComCdList: string[];
isRvwLoginUserLoading: boolean;
checklistFormState: ChecklistFormState;
techReviewOpinionRef?: React.RefObject<TechReviewOpinionSectionHandle | null>;
opnrNm?: string;
}
type ActiveDialog = "elaprTmplt" | "dmndSplmnt" | "elaprApproval" | null;
function FloatingActionButtons({
data,
docSeComCd,
sewerBscPlanDocNo,
chgCycl,
prgrsSttsComCd,
dtlInstCd,
atrzSeComCdList,
isRvwLoginUserLoading,
checklistFormState,
techReviewOpinionRef,
opnrNm,
}: FloatingActionButtonsProps) {
const router = useRouter();
const { confirm } = useConfirm();
const queryClient = useQueryClient();
const userInfo = useUserStore((state) => state.userInfo);
const alert = useAlertStore((state) => state.setMessage);
const [isProcessing, setIsProcessing] = useState(false);
const [activeDialog, setActiveDialog] = useState<ActiveDialog>(null);
const docNo = data.detDto?.sewerBscPlanDocNo ?? sewerBscPlanDocNo;
const isRegister = data.detDto?.rgtr === userInfo?.userUnqId;
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE";
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
useElaprDocPrgrsByDocNo(needsTeApprovalCheck ? docNo : null);
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
elaprDocPrgrsList,
userInfo?.userUnqId,
);
const actionButtons = useMemo(() => {
if (needsAtrzResolve && isRvwLoginUserLoading) return [];
if (needsTeApprovalCheck && isElaprDocPrgrsLoading) return [];
return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, {
hasPendingApprovalRequest: needsTeApprovalCheck ? hasPendingApprovalRequest : undefined,
});
}, [
atrzSeComCdList,
dtlInstCd,
hasPendingApprovalRequest,
isElaprDocPrgrsLoading,
isRvwLoginUserLoading,
needsAtrzResolve,
needsTeApprovalCheck,
prgrsSttsComCd,
]);
const canEdit =
isRegister && actionButtons.some((button) => button === SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg);
function handleGoToList() {
router.push("/sewer-bsc-plan");
}
function handleEdit() {
router.push(`/sewer-bsc-plan/${sewerBscPlanDocNo}/edit?chgCycl=${chgCycl}`);
}
function shouldShowButton(action: SewerBscPlanViewButtonAction) {
return actionButtons.some((button) => button === action);
}
async function invalidateDetail() {
await queryClient.invalidateQueries({
queryKey: [...sewerBscPlanBaseKey, "findDetails", sewerBscPlanDocNo, chgCycl],
});
await queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
}
async function executeAction({
action,
request,
successMessage,
}: {
action: SewerBscPlanViewButtonAction;
request?: unknown;
successMessage?: string;
}) {
const handler = buttonActionHandlers[action];
if (!handler) return;
setIsProcessing(true);
try {
await handler({
request,
data,
chgCycl,
router,
queryClient,
onSuccess: () => {
if (successMessage) alert(successMessage);
void invalidateDetail();
},
onError: (error) => {
let errorMessage = "";
if (error instanceof AxiosError) {
errorMessage = error.response?.data?.message ?? "";
} else {
errorMessage = error.message ?? "처리 중 오류가 발생했습니다.";
}
alert(errorMessage);
},
});
} finally {
setIsProcessing(false);
}
}
async function handleStrgClick() {
const payload = buildSavePayload(data, checklistFormState, "LOCAL", "SV");
await executeAction({
action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg,
request: payload,
successMessage: "저장되었습니다.",
});
}
async function handleAplyClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultRows ?? [],
checklistFormState.results,
"LOCAL",
);
const payload = buildSavePayload(data, checklistFormState, "LOCAL", "LE");
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return;
}
await executeAction({
action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.aply,
request: payload,
successMessage: "신청 완료되었습니다.",
});
}
async function handleDelClick() {
if (!(await confirm("삭제하시겠습니까?"))) return;
await executeAction({
action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/sewer-bsc-plan");
}
function getReviewOpinionFormData() {
return techReviewOpinionRef?.current?.getFormData() ?? null;
}
async function handleRvwRplyClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultRows ?? [],
checklistFormState.results,
"ENV",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토결과 회신이 가능합니다.");
return;
}
const payload = buildTechReviewReplyPayload(
data,
checklistFormState,
"ENV",
"EP",
"CP",
getReviewOpinionFormData(),
opnrNm,
);
await executeAction({
action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwRply,
request: payload,
successMessage: "검토결과 회신이 완료되었습니다.",
});
techReviewOpinionRef?.current?.resetForm();
}
async function handleRqstTechRvwClick() {
const payload = buildSavePayload(data, checklistFormState, "LOCAL", "ER");
await executeAction({
action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rqstTechRvw,
request: payload,
successMessage: "기술검토 의뢰가 완료되었습니다.",
});
}
async function handleElaprTmpltConfirm({ aplnRows }: ElaprTmpltPickerConfirmPayload) {
const payload: ElaprDmndSaveRequestDto = {
elaprDocNo: docNo,
dmndTtl: data.detDto?.sewerBscPlanNm ?? "",
dmndCn: data.activeDtlDto?.bizOtlnCn ?? "",
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
};
await executeAction({
action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dsgntRvwr,
request: payload,
successMessage: "검토자가 지정되었습니다.",
});
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", docNo],
});
}
async function handleRvwStrgClick() {
const payload = buildTechReviewReplyPayload(
data,
checklistFormState,
"KECO",
"TE",
"SV",
getReviewOpinionFormData(),
opnrNm,
);
await executeAction({
action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwStrg,
request: payload,
successMessage: "저장되었습니다.",
});
techReviewOpinionRef?.current?.resetForm();
}
async function handleRvwCmptnClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultRows ?? [],
checklistFormState.results,
"KECO",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
return;
}
const payload = {
...buildTechReviewReplyPayload(
data,
checklistFormState,
"KECO",
"TE",
"SV",
getReviewOpinionFormData(),
opnrNm,
),
splmntDmndList: (data.splmntDmndList ?? []).map(toSplmntDmndSaveItem),
};
await executeAction({
action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwCmptn,
request: payload,
});
techReviewOpinionRef?.current?.resetForm();
setActiveDialog("elaprApproval");
}
function handleDmndSplmntClick() {
setActiveDialog("dmndSplmnt");
}
async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
setActiveDialog(null);
const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
await executeAction({
action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dmndSplmnt,
request: requestPayload,
successMessage: "보완요청이 처리되었습니다.",
});
}
return (
<>
<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">
<Button color="white" type="button" onClick={handleGoToList} disabled={isProcessing}>
목록
</Button>
{canEdit && (
<Button color="gray" onClick={handleEdit} disabled={isProcessing}>
수정
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg) && (
<Button onClick={handleStrgClick} disabled={isProcessing}>
저장
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.aply) && (
<Button color="green" onClick={handleAplyClick} disabled={isProcessing}>
신청완료
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.del) && (
<Button color="red" onClick={handleDelClick} disabled={isProcessing}>
삭제
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwRply) && (
<Button color="green" onClick={handleRvwRplyClick} disabled={isProcessing}>
검토결과 회신
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rqstTechRvw) && (
<Button onClick={handleRqstTechRvwClick} disabled={isProcessing}>
기술검토 의뢰
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dsgntRvwr) && (
<Button onClick={() => setActiveDialog("elaprTmplt")} disabled={isProcessing}>
검토자지정
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwStrg) && (
<Button onClick={handleRvwStrgClick} disabled={isProcessing}>
저장
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.rvwCmptn) && (
<Button color="green" onClick={handleRvwCmptnClick} disabled={isProcessing}>
검토완료
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
<Button color="gray" onClick={handleDmndSplmntClick} disabled={isProcessing}>
보완요청
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.atrzAprv) && (
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
결재(승인)
</Button>
)}
{shouldShowButton(SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.atrzRply) && (
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
결재(회신)
</Button>
)}
</div>
</div>
<ElaprTmpltPickerDialog
open={activeDialog === "elaprTmplt"}
onClose={() => setActiveDialog(null)}
docSeComCd={docSeComCd}
onConfirm={handleElaprTmpltConfirm}
/>
<SplmntDmndDialog
splmntArtclUpCd="SEWERSPLMNTARTCL"
open={activeDialog === "dmndSplmnt"}
onOpenChange={() => setActiveDialog(null)}
isSubmitting={isProcessing}
onConfirm={handleDmndSplmntConfirm}
/>
<ElaprApprovalDialog
open={activeDialog === "elaprApproval"}
onOpenChange={(open) => setActiveDialog(open ? "elaprApproval" : null)}
elaprDocNo={docNo}
currentUserUuid={userInfo?.userUnqId ?? ""}
onSuccess={() => {
void invalidateDetail();
}}
/>
</>
);
}
export { FloatingActionButtons };
async function handleSaveApply(
context: ActionHandlerContext<SewerBscPlanApplySaveRequestDto>,
): Promise<void> {
try {
await sewerBscPlanService.saveApply(context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleDel(context: ActionHandlerContext): Promise<void> {
try {
await sewerBscPlanService.delete(context.data.detDto?.sewerBscPlanDocNo ?? "");
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleTechReview(
context: ActionHandlerContext<SewerBscPlanApplySaveRequestDto>,
): Promise<void> {
try {
await sewerBscPlanService.techReview(
context.data.detDto?.sewerBscPlanDocNo ?? "",
context.request,
);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleRequestTechReview(context: ActionHandlerContext): Promise<void> {
try {
await instlSncnService.changePrgrsSttsComCd({
docNo: context.data.detDto?.sewerBscPlanDocNo ?? "",
docSeComCd: "SEWER",
prgrsSttsComCd: "ER",
});
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleDsgntRvwr(
context: ActionHandlerContext<ElaprDmndSaveRequestDto>,
): Promise<void> {
try {
await elaprDmndService.save(context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleDmndSplmnt(
context: ActionHandlerContext<SewerBscPlanApplySaveRequestDto>,
): Promise<void> {
try {
const docNo = context.data.detDto?.sewerBscPlanDocNo;
if (!docNo) throw new Error("문서번호가 없습니다.");
const result = await sewerBscPlanService.submitReqSupplement(
docNo,
context.chgCycl,
context.request,
);
await context.queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
context.router.replace(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl}`);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleAtrzAprv(context: ActionHandlerContext): Promise<void> {
context.onSuccess?.();
}
async function handleAtrzRply(context: ActionHandlerContext): Promise<void> {
context.onSuccess?.();
}
const buttonActionHandlers: Record<SewerBscPlanViewButtonAction, ActionHandler> = {
strg: handleSaveApply,
aply: handleSaveApply,
del: handleDel,
rvwRply: handleTechReview,
rqstTechRvw: handleRequestTechReview,
dsgntRvwr: handleDsgntRvwr,
rvwStrg: handleTechReview,
rvwCmptn: handleTechReview,
dmndSplmnt: handleDmndSplmnt,
atrzAprv: handleAtrzAprv,
atrzRply: handleAtrzRply,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface ActionHandlerContext<T = any> {
request: T;
data: SewerBscPlanDetResponseDto;
chgCycl: number;
router: ReturnType<typeof useRouter>;
queryClient: QueryClient;
onSuccess?: () => void;
onError?: (error: Error) => void;
}
type ActionHandler = (context: ActionHandlerContext) => Promise<void>;
@@ -0,0 +1,127 @@
"use client";
import { Paperclip } from "lucide-react";
import { formatFileSize } from "@/shared/lib/file-utils";
import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
import type {
AtflMngClientResponseDto,
EsntlAtchFileMngTreeResponseDto,
} from "@/entities/sewer-bsc-plan";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
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 };
@@ -2,6 +2,7 @@
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { useUserStore } from "@/shared/lib";
import { Field, FieldError } from "@/shared/ui/field"; import { Field, FieldError } from "@/shared/ui/field";
import { SectionCollapsible } from "@/widgets/pbadms-sprt"; import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import { import {
@@ -23,6 +24,7 @@ import type { SewerBscPlanRegistFormData } from "../../lib/sewer-bsc-plan-regist
import { getGoalYearOptions, getLinkedGoalYears, getPlanYearOptions } from "../../lib/year-options"; import { getGoalYearOptions, getLinkedGoalYears, getPlanYearOptions } from "../../lib/year-options";
function BasicInfoFormSection() { function BasicInfoFormSection() {
const userInfo = useUserStore((state) => state.userInfo);
const { control, setValue, watch } = useFormContext<SewerBscPlanRegistFormData>(); const { control, setValue, watch } = useFormContext<SewerBscPlanRegistFormData>();
const planYearOptions = getPlanYearOptions(); const planYearOptions = getPlanYearOptions();
const goalYearOptions = getGoalYearOptions(); const goalYearOptions = getGoalYearOptions();
@@ -265,7 +267,7 @@ function BasicInfoFormSection() {
<TableHead>구분</TableHead> <TableHead>구분</TableHead>
<TableCell>{sewerBscPlanSeLabel}</TableCell> <TableCell>{sewerBscPlanSeLabel}</TableCell>
<TableHead>신청 지자체</TableHead> <TableHead>신청 지자체</TableHead>
<TableCell>{localGovLabel}</TableCell> <TableCell>{localGovLabel || userInfo?.laeSeNm}</TableCell>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
</Table> </Table>
@@ -0,0 +1,69 @@
"use client";
import type {
SewerBscPlanDtlResponseDto,
SewerBscPlanResponseDto,
} from "@/entities/sewer-bsc-plan";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface BasicInfoSectionProps {
det: SewerBscPlanResponseDto;
dtl?: SewerBscPlanDtlResponseDto;
}
function BasicInfoSection({ det, dtl }: BasicInfoSectionProps) {
const sewerBscPlanSeLabel = [det.sewerBscPlanSeCdNm, det.taskSeCdNm].filter(Boolean).join(" ");
const localGovLabel = [det.sido, det.gugun].filter(Boolean).join(" ");
return (
<SectionCollapsible title="기본계획 신청">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
<col width="15%" />
<col width="35%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>기본계획명</TableHead>
<TableCell colSpan={3}>{det.sewerBscPlanNm || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>계획년도</TableHead>
<TableCell>{dtl?.planYr || "-"}</TableCell>
<TableHead>기준년도</TableHead>
<TableCell>{dtl?.crtrYr || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>1단계 목표연도</TableHead>
<TableCell>{dtl?.frlvlGoalYr || "-"}</TableCell>
<TableHead>2단계 목표연도</TableHead>
<TableCell>{dtl?.sclvlGoalYr || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>3단계 목표연도</TableHead>
<TableCell>{dtl?.thlvlGoalYr || "-"}</TableCell>
<TableHead>4단계 목표연도</TableHead>
<TableCell>{dtl?.folvlGoalYr || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>계획개요</TableHead>
<TableCell colSpan={3} className="whitespace-pre-wrap">
{dtl?.bizOtlnCn || "-"}
</TableCell>
</TableRow>
<TableRow>
<TableHead>구분</TableHead>
<TableCell>{sewerBscPlanSeLabel || "-"}</TableCell>
<TableHead>신청 지자체</TableHead>
<TableCell>{localGovLabel || "-"}</TableCell>
</TableRow>
</TableHeader>
</Table>
</SectionCollapsible>
);
}
export { BasicInfoSection };
@@ -0,0 +1,114 @@
"use client";
import { useMemo } from "react";
import { useAcntgSeMngTree } from "@/entities/acntg";
import type {
SewerBscPlanBizCstResponseDto,
SewerBscPlanDtlResponseDto,
} from "@/entities/sewer-bsc-plan";
import { buildAcntgCostItems, fmtNumber, SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { getAmt } from "../../lib/biz-cst-matrix-utils";
interface BizCstMatrixSectionProps {
bizCstList: SewerBscPlanBizCstResponseDto[];
dtl?: SewerBscPlanDtlResponseDto;
}
function BizCstMatrixSection({ bizCstList, dtl }: BizCstMatrixSectionProps) {
const { data: acntgTree = [] } = useAcntgSeMngTree();
const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
const matrixList = useMemo(
() =>
bizCstList.map((item) => ({
planStpNo: item.planStpNo ?? 1,
acntgSeCd: item.acntgSeCd ?? "",
cstrnAmt: item.cstrnAmt ?? 0,
})),
[bizCstList],
);
const stepHeaders = [
{ planStpNo: 1, label: "1단계", year: dtl?.frlvlGoalYr },
{ planStpNo: 2, label: "2단계", year: dtl?.sclvlGoalYr },
{ planStpNo: 3, label: "3단계", year: dtl?.thlvlGoalYr },
{ planStpNo: 4, label: "4단계", year: dtl?.folvlGoalYr },
];
if (costItems.length === 0) {
return (
<SectionCollapsible title="총사업비">
<div className="space-y-2 p-2">
<Skeleton className="h-[8rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</SectionCollapsible>
);
}
return (
<SectionCollapsible title="총사업비">
<div className="overflow-x-auto">
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="12%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[28rem]">구분</TableHead>
{stepHeaders.map((step) => (
<TableHead key={step.planStpNo} className="text-center">
{step.label}
<br />
<span className="font-normal text-muted-foreground">({step.year || "-"})</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{costItems.map((item) => {
const paddingLeft = `${item.level * 1.6}rem`;
const showSubIcon = item.level >= 2;
return (
<TableRow key={item.code}>
<TableHead className="text-left" style={{ paddingLeft }}>
<div className="relative">
{showSubIcon && (
<span className="absolute -left-6 top-0 text-gray-400">└</span>
)}
{item.label}
</div>
</TableHead>
{stepHeaders.map((step) => (
<TableCell key={step.planStpNo} className="text-right">
{fmtNumber(getAmt(matrixList, item.code, step.planStpNo))}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</SectionCollapsible>
);
}
export { BizCstMatrixSection };
@@ -0,0 +1,141 @@
"use client";
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from "react";
import type { CklstRvwRsltTreeDto } from "@/entities/sewer-bsc-plan";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import {
computeReviewStepChecklistProgress,
extractTaskCodes,
getChecklistDisplayRows,
} from "../../lib/checklist-utils";
import { getVisibleReviewSteps } from "../../lib/review-step-utils";
import type { ReviewStepCode } from "../../model/view-types";
import {
type ChecklistFormState,
REVIEW_STEP_LABELS,
ROLE_TO_REVIEW_STEP,
TASK_CODE_LABELS,
} from "../../model/view-types";
import { ChecklistTaskPanel } from "../components/checklist-task-panel";
interface ChecklistSectionProps {
cklstResultList: CklstRvwRsltTreeDto[];
prgrsSttsComCd: string;
userRoleCode?: string;
formState: ChecklistFormState;
setFormState: Dispatch<SetStateAction<ChecklistFormState>>;
}
function ChecklistSection({
cklstResultList,
prgrsSttsComCd,
userRoleCode,
formState,
setFormState,
}: ChecklistSectionProps) {
const visibleReviewSteps = useMemo(
() => getVisibleReviewSteps(prgrsSttsComCd, userRoleCode),
[prgrsSttsComCd, userRoleCode],
);
const taskCodes = useMemo(() => extractTaskCodes(cklstResultList), [cklstResultList]);
const defaultReviewStep = ROLE_TO_REVIEW_STEP[userRoleCode ?? ""] ?? "LOCAL";
const [selectedReviewStep, setSelectedReviewStep] = useState<ReviewStepCode>(defaultReviewStep);
const [selectedTaskCode, setSelectedTaskCode] = useState<string | null>(null);
useEffect(() => {
if (visibleReviewSteps.length === 0) return;
if (!visibleReviewSteps.includes(selectedReviewStep)) {
setSelectedReviewStep(visibleReviewSteps[visibleReviewSteps.length - 1]);
}
}, [selectedReviewStep, visibleReviewSteps]);
useEffect(() => {
if (taskCodes.length === 0) {
setSelectedTaskCode(null);
return;
}
if (!selectedTaskCode || !taskCodes.includes(selectedTaskCode)) {
setSelectedTaskCode(taskCodes[0]);
}
}, [taskCodes, selectedTaskCode]);
const progress = useMemo(
() =>
computeReviewStepChecklistProgress(cklstResultList, formState.results, selectedReviewStep),
[cklstResultList, formState.results, selectedReviewStep],
);
const { total, completed, percent } = progress;
if (cklstResultList.length === 0) {
return null;
}
return (
<SectionCollapsible title="체크리스트">
<div className="flex flex-col gap-4 p-2">
{visibleReviewSteps.length > 0 ? (
<Tabs
value={selectedReviewStep}
onValueChange={(value) => setSelectedReviewStep(value as ReviewStepCode)}
className="w-full"
>
<TabsList>
{visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl">
{REVIEW_STEP_LABELS[stepCode]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{taskCodes.length > 0 ? (
<Tabs
value={selectedTaskCode ?? taskCodes[0]}
onValueChange={setSelectedTaskCode}
className="w-full"
variant="outline"
>
<div className="flex items-end justify-between gap-4">
<TabsList className="p-0">
{taskCodes.map((code) => (
<TabsTrigger
key={code}
value={code}
className="!px-6 !py-3 text-xl [&:after]:hidden"
>
{TASK_CODE_LABELS[code] ?? code}
</TabsTrigger>
))}
</TabsList>
<div className="shrink-0 pb-1 text-lg text-muted-foreground">
총합: <strong>{total}</strong> · 완료: <strong>{completed}</strong> · 진행률:{" "}
<strong>{percent}%</strong>
</div>
</div>
{taskCodes.map((code) => (
<TabsContent key={code} value={code} className="mt-0 border-0 p-0 shadow-none ring-0">
<ChecklistTaskPanel
selectedReviewStep={selectedReviewStep}
selectedRows={getChecklistDisplayRows(cklstResultList, code)}
allRows={cklstResultList}
formState={formState}
setFormState={setFormState}
userRoleCode={userRoleCode}
/>
</TabsContent>
))}
</Tabs>
) : null}
</div>
</SectionCollapsible>
);
}
export { ChecklistSection };
@@ -0,0 +1,58 @@
"use client";
import type { SewerBscPlanDtlResponseDto } from "@/entities/sewer-bsc-plan";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface ContactInfoSectionProps {
dtl?: SewerBscPlanDtlResponseDto;
}
function ContactInfoSection({ dtl }: ContactInfoSectionProps) {
if (!dtl) return null;
return (
<SectionCollapsible title="담당자 정보">
<Table variant="secondary">
<TableHeader>
{dtl.aplcntNm ? (
<>
<TableRow>
<TableHead rowSpan={2}>신청자</TableHead>
<TableHead>이름</TableHead>
<TableCell>{dtl.aplcntNm || "-"}</TableCell>
<TableHead>연락처</TableHead>
<TableCell>{dtl.aplcntMblTelno || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>소속</TableHead>
<TableCell>{dtl.aplcntOgdpNm || "-"}</TableCell>
<TableHead>이메일</TableHead>
<TableCell>{dtl.aplcntEmlAddr || "-"}</TableCell>
</TableRow>
</>
) : null}
{dtl.dsgnrNm ? (
<>
<TableRow>
<TableHead rowSpan={2}>설계자</TableHead>
<TableHead>이름</TableHead>
<TableCell>{dtl.dsgnrNm || "-"}</TableCell>
<TableHead>연락처</TableHead>
<TableCell>{dtl.dsgnrTelno || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>소속</TableHead>
<TableCell>{dtl.dsgnrOgdpNm || "-"}</TableCell>
<TableHead>이메일</TableHead>
<TableCell>{dtl.dsgnrEmlAddr || "-"}</TableCell>
</TableRow>
</>
) : null}
</TableHeader>
</Table>
</SectionCollapsible>
);
}
export { ContactInfoSection };
@@ -0,0 +1,122 @@
"use client";
import { useMemo } from "react";
import { useAcntgSeMngTree } from "@/entities/acntg";
import type {
SewerBscPlanDtlResponseDto,
SewerBscPlanFnrsCstResponseDto,
} from "@/entities/sewer-bsc-plan";
import { buildAcntgCostItems, fmtNumber, SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { PLAN_STEPS } from "../../lib/biz-cst-matrix-utils";
interface FnrsCstSectionProps {
fnrsCstList: SewerBscPlanFnrsCstResponseDto[];
dtl?: SewerBscPlanDtlResponseDto;
}
const FNRS_CST_ROWS = [
{ key: "sumAmt" as const, label: "총사업비" },
{ key: "gvexpAmt" as const, label: "국고" },
{ key: "lcexpAmt" as const, label: "지방비" },
{ key: "orgprBdchgAmt" as const, label: "원인자부담금" },
{ key: "etcAmt" as const, label: "기타" },
];
function FnrsCstSection({ fnrsCstList, dtl }: FnrsCstSectionProps) {
const { data: acntgTree = [] } = useAcntgSeMngTree();
const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
const matrixFnrsList = useMemo(
() =>
PLAN_STEPS.map((planStpNo) => {
const item = fnrsCstList.find((row) => row.planStpNo === planStpNo);
return {
planStpNo,
gvexpAmt: item?.gvexpAmt ?? 0,
lcexpAmt: item?.lcexpAmt ?? 0,
orgprBdchgAmt: item?.orgprBdchgAmt ?? 0,
etcAmt: item?.etcAmt ?? 0,
sumAmt: item?.sumAmt ?? 0,
};
}),
[fnrsCstList],
);
const stepHeaders = [
{ planStpNo: 1, label: "1단계", year: dtl?.frlvlGoalYr },
{ planStpNo: 2, label: "2단계", year: dtl?.sclvlGoalYr },
{ planStpNo: 3, label: "3단계", year: dtl?.thlvlGoalYr },
{ planStpNo: 4, label: "4단계", year: dtl?.folvlGoalYr },
];
if (costItems.length === 0) {
return (
<SectionCollapsible title="재원분담">
<div className="space-y-2 p-2">
<Skeleton className="h-[8rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</SectionCollapsible>
);
}
return (
<SectionCollapsible title="재원분담">
<div className="overflow-x-auto">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xl font-semibold">세부별 총사업비(①) - 재원분담 총사업비(②)</h3>
<span className="font-normal text-muted-foreground">(단위: 백만원)</span>
</div>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="12%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>구분</TableHead>
{stepHeaders.map((step) => (
<TableHead key={step.planStpNo} className="text-center">
{step.label}
<br />
<span className="font-normal text-muted-foreground">({step.year || "-"})</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{FNRS_CST_ROWS.map((row) => (
<TableRow key={row.key}>
<TableHead>{row.label}</TableHead>
{stepHeaders.map((step) => {
const item = matrixFnrsList.find((fnrs) => fnrs.planStpNo === step.planStpNo);
return (
<TableCell key={step.planStpNo} className="text-right">
{fmtNumber(item?.[row.key])}
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionCollapsible>
);
}
export { FnrsCstSection };
@@ -0,0 +1,71 @@
"use client";
import type {
SewerBscPlanDtlResponseDto,
SewerBscPlanOtlnResponseDto,
} from "@/entities/sewer-bsc-plan";
import { fmtNumber, SectionCollapsible } from "@/widgets/pbadms-sprt";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface OutlineSectionProps {
otlnList: SewerBscPlanOtlnResponseDto[];
dtl?: SewerBscPlanDtlResponseDto;
}
const OTLN_ROWS = [
{ key: "sewerPlanPpltnCnt" as const, label: "계획인구" },
{ key: "sewerPrcsPpltnCnt" as const, label: "처리인구" },
{ key: "sewerPsplyRt" as const, label: "하수도보급율" },
];
function OutlineSection({ otlnList, dtl }: OutlineSectionProps) {
const stepHeaders = [
{ planStpNo: 1, label: "1단계", year: dtl?.frlvlGoalYr },
{ planStpNo: 2, label: "2단계", year: dtl?.sclvlGoalYr },
{ planStpNo: 3, label: "3단계", year: dtl?.thlvlGoalYr },
{ planStpNo: 4, label: "4단계", year: dtl?.folvlGoalYr },
];
return (
<SectionCollapsible title="사업개요">
<Table variant="secondary">
<colgroup>
<col width="12%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
<col width="22%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[20rem]">구분</TableHead>
{stepHeaders.map((step) => (
<TableHead key={step.planStpNo} className="text-center">
{step.label}
<br />
<span className="font-normal text-muted-foreground">({step.year || "-"})</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{OTLN_ROWS.map((row) => (
<TableRow key={row.key}>
<TableHead>{row.label}</TableHead>
{stepHeaders.map((step) => {
const item = otlnList.find((otln) => otln.planStpNo === step.planStpNo);
return (
<TableCell key={step.planStpNo} className="text-right">
{fmtNumber(item?.[row.key])}
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</SectionCollapsible>
);
}
export { OutlineSection };
@@ -0,0 +1,365 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { Paperclip } from "lucide-react";
import { PROXY_HOST } from "@/shared/config";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService } from "@/entities/attach";
import type { SewerBscPlanRvwOpnnResponseDto } from "@/entities/sewer-bsc-plan";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
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 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 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 ? (
<a
className="inline-flex cursor-pointer items-start gap-1 text-left text-blue-600 hover:underline"
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${item.atchFileInfo.atflCd}`}
target="_blank"
rel="noreferrer"
>
<Paperclip className="my-1 size-5 shrink-0" />
{item.atchFileInfo.orgnlFileNm || "다운로드"}
</a>
) : (
"-"
)}
</TableCell>
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
@@ -4,11 +4,11 @@ import { useId, useMemo, useState } from "react";
import { Controller, useFieldArray, useFormContext, useWatch } from "react-hook-form"; import { Controller, useFieldArray, useFormContext, useWatch } from "react-hook-form";
import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Paperclip } from "lucide-react";
import { getDefaultFileKey, useTus } from "@/shared/lib"; import { getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { Field } from "@/shared/ui/field"; import { Field } from "@/shared/ui/field";
import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach"; import { attachService } from "@/entities/attach";
import { useFacilityList } from "@/entities/bns-if"; import { useFacilityList } from "@/entities/bns-if";
import { useNowDmgMtncGnsvy } from "@/entities/dc"; import { useNowDmgMtncGnsvy } from "@/entities/dc";
import { SectionCollapsible } from "@/widgets/pbadms-sprt"; import { SectionCollapsible } from "@/widgets/pbadms-sprt";
@@ -226,7 +226,6 @@ function TmplntFacilityAttachments({
}: TmplntFacilityAttachmentsProps) { }: TmplntFacilityAttachmentsProps) {
const formId = useId(); const formId = useId();
const { setValue } = useFormContext<SewerBscPlanRegistFormData>(); const { setValue } = useFormContext<SewerBscPlanRegistFormData>();
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
const [uploadedFile, setUploadedFile] = useState<UploadedTmplntFile | null>(() => const [uploadedFile, setUploadedFile] = useState<UploadedTmplntFile | null>(() =>
atchFileGroupId && atchFileNm atchFileGroupId && atchFileNm
? { ? {
@@ -254,7 +253,6 @@ function TmplntFacilityAttachments({
const fileInputId = `${formId}-tmplnt-atch-${facilityIndex}`; const fileInputId = `${formId}-tmplnt-atch-${facilityIndex}`;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
const displayFileName = uploadedFile?.fileName ?? atchFileNm ?? ""; const displayFileName = uploadedFile?.fileName ?? atchFileNm ?? "";
const downloadAtflCd = uploadedFile?.atflCd || atchFileGroupId;
const hasFile = Boolean(uploadedFile || atchFileNm); const hasFile = Boolean(uploadedFile || atchFileNm);
async function handleFileUpload(files: FileList | null) { async function handleFileUpload(files: FileList | null) {
@@ -315,22 +313,16 @@ function TmplntFacilityAttachments({
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow> <TableRow>
<TableCell>{facilityName} 계획평면도</TableCell> <TableHead data-required align="left">
{facilityName} 계획평면도
</TableHead>
<TableCell className="text-center">{TMPLNT_ATCH_FORMAT_LABEL}</TableCell> <TableCell className="text-center">{TMPLNT_ATCH_FORMAT_LABEL}</TableCell>
<TableCell> <TableCell>
{hasFile ? ( {hasFile ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{downloadAtflCd ? ( <TextShimmer className="text-xl" active={uploading}>
<a {displayFileName}
className="inline-flex cursor-pointer items-center gap-1 text-blue-600 hover:underline" </TextShimmer>
onClick={() => downloadAttachment(downloadAtflCd)}
>
<Paperclip className="size-5 shrink-0" />
{displayFileName}
</a>
) : (
<span className="text-xl">{displayFileName}</span>
)}
<Button <Button
type="button" type="button"
size="icon" size="icon"
@@ -0,0 +1,287 @@
"use client";
import { useMemo, useState } from "react";
import { Paperclip } from "lucide-react";
import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
import { useFacilityList } from "@/entities/bns-if";
import { useNowDmgMtncGnsvy } from "@/entities/dc";
import type {
SewerBscPlanDtlResponseDto,
SewerBscPlanResponseDto,
SewerBscPlanTmplntResponseDto,
} from "@/entities/sewer-bsc-plan";
import { fmtNumber, SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@common/components";
import {
getOriginalValue,
getOriginalYear,
PLAN_STP_NOS,
type TmplntFieldKey,
} from "../../lib/tmplnt-original-mapper";
interface FacilityOption {
code: string;
name: string;
}
interface TmplntSectionProps {
tmplntList: SewerBscPlanTmplntResponseDto[];
det: SewerBscPlanResponseDto;
dtl?: SewerBscPlanDtlResponseDto;
}
function resolveFacilityName(
item: SewerBscPlanTmplntResponseDto,
facilityOptions: FacilityOption[],
) {
return (
item.swtrmtFcltNm || facilityOptions.find((option) => option.code === item.swtrmtFcltCd)?.name
);
}
const TMPLNT_FIELD_ROWS: { key: TmplntFieldKey; label: string }[] = [
{ key: "prcsZoneArea", label: "처리구역면적(km²)" },
{ key: "swtrmtPpltnCnt", label: "하수처리인구(명)" },
{ key: "planSwqty", label: "계획하수량(㎥/일)" },
{ key: "fcltCpct", label: "시설용량(㎥/일)" },
{ key: "sprsysDrtwPplLen", label: "분류식 오수(km)" },
{ key: "sprsysExlnPplLen", label: "분류식 우수(km)" },
{ key: "cbsysPplLen", label: "합류식(km)" },
{ key: "icptgPplLen", label: "차집관로(km)" },
{ key: "drgCnt", label: "배수설비(개소)" },
{ key: "drtwRlyPumpCnt", label: "오수 중계 펌프(개소)" },
{ key: "drtwPumpCnt", label: "오수 일반 펌프(개소)" },
{ key: "exlnPumpCnt", label: "우수 펌프(개소)" },
];
function getDtlValue(
item: SewerBscPlanTmplntResponseDto,
planStpNo: number,
fieldKey: TmplntFieldKey,
): number | undefined {
return item.sewerBscPlanTmplntDtlList?.find((dtl) => dtl.planStpNo === planStpNo)?.[fieldKey];
}
interface TmplntFacilityMatrixProps {
item: SewerBscPlanTmplntResponseDto;
sido?: string;
gugun?: string;
crtrYr?: string;
changeStepHeaders: { planStpNo: number; year?: string }[];
}
function TmplntFacilityMatrix({
item,
sido,
gugun,
crtrYr,
changeStepHeaders,
}: TmplntFacilityMatrixProps) {
const searchParams = useMemo(
() => ({
swtrmtFcltCd: item.swtrmtFcltCd ?? "",
ctpvNm: sido ?? "",
sggNm: gugun ?? "",
}),
[item.swtrmtFcltCd, sido, gugun],
);
const { data } = useNowDmgMtncGnsvy(searchParams);
const originalData = Array.isArray(data) ? (data[0] ?? null) : null;
return (
<Table variant="secondary">
<colgroup>
<col width="12%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="10%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="9.75%" />
<col width="9.75%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[22rem]" rowSpan={2}>
구분
</TableHead>
<TableHead className="text-center" colSpan={4}>
당초
</TableHead>
<TableHead className="text-center" rowSpan={2}>
현재
<br />
<span className="font-normal text-muted-foreground">({crtrYr || "-"}년)</span>
</TableHead>
<TableHead className="text-center" colSpan={4}>
변경
</TableHead>
</TableRow>
<TableRow>
{PLAN_STP_NOS.map((planStpNo) => (
<TableHead key={`original-${planStpNo}`} className="text-center">
{planStpNo}단계
<br />
<span className="font-normal text-muted-foreground">
({getOriginalYear(originalData, planStpNo)})
</span>
</TableHead>
))}
{changeStepHeaders.map((step) => (
<TableHead key={`change-${step.planStpNo}`} className="text-center">
{step.planStpNo}단계
<br />
<span className="font-normal text-muted-foreground">({step.year || "-"})</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{TMPLNT_FIELD_ROWS.map((row) => (
<TableRow key={row.key}>
<TableHead>{row.label}</TableHead>
{PLAN_STP_NOS.map((planStpNo) => (
<TableCell key={`original-${row.key}-${planStpNo}`} className="text-right">
{getOriginalValue(originalData, row.key, planStpNo)}
</TableCell>
))}
<TableCell className="text-right">{fmtNumber(getDtlValue(item, 0, row.key))}</TableCell>
{changeStepHeaders.map((step) => (
<TableCell key={`change-${row.key}-${step.planStpNo}`} className="text-right">
{fmtNumber(getDtlValue(item, step.planStpNo, row.key))}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
function TmplntSection({ tmplntList, det, dtl }: TmplntSectionProps) {
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
const [selectedCode, setSelectedCode] = useState(tmplntList[0]?.swtrmtFcltCd ?? "");
const { data: facilityListData } = useFacilityList(
{ sido: det.sido ?? "", gugun: det.gugun ?? "" },
{ enabled: Boolean(det.sido) },
);
const facilityOptions =
facilityListData?.fctList
?.filter((facility) => facility.bscFctCd)
.map((facility) => ({
code: facility.bscFctCd!,
name: facility.bscFctNm ?? facility.bscFctCd!,
})) ?? [];
const changeStepHeaders = [
{ planStpNo: 1, year: dtl?.frlvlGoalYr },
{ planStpNo: 2, year: dtl?.sclvlGoalYr },
{ planStpNo: 3, year: dtl?.thlvlGoalYr },
{ planStpNo: 4, year: dtl?.folvlGoalYr },
];
if (tmplntList.length === 0) {
return (
<SectionCollapsible title="처리시설별 하수도정비기본계획">
<p className="py-8 text-center text-muted-foreground">선택된 처리시설이 없습니다.</p>
</SectionCollapsible>
);
}
return (
<SectionCollapsible title="처리시설별 하수도정비기본계획">
<Tabs
value={selectedCode || tmplntList[0]?.swtrmtFcltCd}
onValueChange={setSelectedCode}
className="w-full"
>
<TabsList>
{tmplntList.map((item) => {
const facilityName = resolveFacilityName(item, facilityOptions);
return (
<TabsTrigger
key={item.swtrmtFcltCd}
value={item.swtrmtFcltCd ?? ""}
className="!px-6 !py-3 text-xl"
>
{facilityName}
</TabsTrigger>
);
})}
</TabsList>
{tmplntList.map((item) => {
const facilityName = resolveFacilityName(item, facilityOptions);
return (
<TabsContent key={item.swtrmtFcltCd} value={item.swtrmtFcltCd ?? ""}>
<div className="overflow-x-auto">
<TmplntFacilityMatrix
item={item}
sido={det.sido}
gugun={det.gugun}
crtrYr={dtl?.crtrYr}
changeStepHeaders={changeStepHeaders}
/>
</div>
{(item.atchFileGroupId || item.atchFileInfo?.orgnlFileNm) && (
<div className="mt-6 space-y-2">
<h3 className="mb-4 text-xl font-semibold">처리구역 필수 첨부파일</h3>
<Table variant="secondary">
<colgroup>
<col width="35%" />
<col width="65%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead align="left">파일명</TableHead>
<TableHead>파일첨부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>{facilityName} 계획평면도</TableCell>
<TableCell>
{item.atchFileGroupId ? (
<a
className="inline-flex cursor-pointer items-center gap-1 text-blue-600 hover:underline"
onClick={() => downloadAttachment(item.atchFileGroupId ?? "")}
>
<Paperclip className="size-5 shrink-0" />
{item.atchFileInfo?.orgnlFileNm || "다운로드"}
</a>
) : (
"-"
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)}
</TabsContent>
);
})}
</Tabs>
</SectionCollapsible>
);
}
export { TmplntSection };
@@ -79,7 +79,9 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
setTaskSeComCd(""); setTaskSeComCd("");
}, [open]); }, [open]);
const canConfirm = Boolean(sidoComCd && sewerBscPlanSeComCd && taskSeComCd); const canSidoGugunSelectable = userInfo?.dtlInstCd !== "ROLE_USER05";
const canConfirm =
!!sewerBscPlanSeComCd && !!taskSeComCd && (!canSidoGugunSelectable || !!sidoComCd);
function handleConfirm() { function handleConfirm() {
if (!canConfirm) return; if (!canConfirm) return;
@@ -118,48 +120,50 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
<col width="35%" /> <col width="35%" />
</colgroup> </colgroup>
<TableHeader> <TableHeader>
<TableRow> {canSidoGugunSelectable && (
<TableHead data-required>시도</TableHead> <TableRow>
<TableCell> <TableHead data-required>시도</TableHead>
<Select <TableCell>
value={sidoComCd || undefined} <Select
onValueChange={(value) => { value={sidoComCd || undefined}
setSidoComCd(value); onValueChange={(value) => {
setSggComCd(""); setSidoComCd(value);
}} setSggComCd("");
> }}
<SelectTrigger className="w-full"> >
<SelectValue placeholder="선택" /> <SelectTrigger className="w-full">
</SelectTrigger> <SelectValue placeholder="선택" />
<SelectContent> </SelectTrigger>
{sidoOptions.map((option) => ( <SelectContent>
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}> {sidoOptions.map((option) => (
{option.adstDstrNm} <SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
</SelectItem> {option.adstDstrNm}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</TableCell> </Select>
<TableHead>시군구</TableHead> </TableCell>
<TableCell> <TableHead>시군구</TableHead>
<Select <TableCell>
value={sggComCd || undefined} <Select
onValueChange={setSggComCd} value={sggComCd || undefined}
disabled={!sidoComCd || isGugunPending} onValueChange={setSggComCd}
> disabled={!sidoComCd || isGugunPending}
<SelectTrigger className="w-full"> >
<SelectValue placeholder="선택" /> <SelectTrigger className="w-full">
</SelectTrigger> <SelectValue placeholder="선택" />
<SelectContent> </SelectTrigger>
{gugunOptions.map((option) => ( <SelectContent>
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}> {gugunOptions.map((option) => (
{option.adstDstrNm} <SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
</SelectItem> {option.adstDstrNm}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</TableCell> </Select>
</TableRow> </TableCell>
</TableRow>
)}
<TableRow> <TableRow>
<TableHead data-required>계획구분</TableHead> <TableHead data-required>계획구분</TableHead>
<TableCell colSpan={3}> <TableCell colSpan={3}>
@@ -10,6 +10,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib"; import { useAlertStore, useUserStore } from "@/shared/lib";
import { useAcntgSeMngTree } from "@/entities/acntg"; import { useAcntgSeMngTree } from "@/entities/acntg";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import { import {
SewerBscPlanApplySaveResponseDto, SewerBscPlanApplySaveResponseDto,
sewerBscPlanBaseKey, sewerBscPlanBaseKey,
@@ -22,6 +23,7 @@ import {
ChecklistFormSection, ChecklistFormSection,
ContactInfoFormSection, ContactInfoFormSection,
isFormChecklistComplete, isFormChecklistComplete,
isFormEsntlAtchComplete,
PrivacyConsentSection, PrivacyConsentSection,
SplmntDmndFormSection, SplmntDmndFormSection,
} from "@/widgets/pbadms-sprt"; } from "@/widgets/pbadms-sprt";
@@ -34,7 +36,6 @@ import {
mapDetailToFormData, mapDetailToFormData,
} from "../lib/form-data-mapper"; } from "../lib/form-data-mapper";
import { import {
SEWER_BSC_PLAN_DOC_SE_COM_CD,
type SewerBscPlanRegistFormData, type SewerBscPlanRegistFormData,
sewerBscPlanRegistSchema, sewerBscPlanRegistSchema,
} from "../lib/sewer-bsc-plan-regist-schema"; } from "../lib/sewer-bsc-plan-regist-schema";
@@ -76,6 +77,7 @@ function SewerBscPlanFormPage({
const isSplmntPrgrsStts = initialData?.detDto?.prgrsSttsComCd === "SL"; const isSplmntPrgrsStts = initialData?.detDto?.prgrsSttsComCd === "SL";
const { data: acntgTree = [] } = useAcntgSeMngTree(); const { data: acntgTree = [] } = useAcntgSeMngTree();
const { data: essentialFiles = [] } = useEsntlAtchFileMngTree({ docSeComCd: "SEWER" });
const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]); const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
const form = useForm<SewerBscPlanRegistFormData>({ const form = useForm<SewerBscPlanRegistFormData>({
@@ -140,18 +142,28 @@ function SewerBscPlanFormPage({
return; return;
} }
if (data.sewerBscPlanTmplntList.some((item) => !item.atchFileGroupId?.trim())) {
alert("처리구역 필수 첨부파일을 모두 등록해야 신청이 가능합니다.");
return;
}
if (!isFormChecklistComplete(data.cklstResultList)) { if (!isFormChecklistComplete(data.cklstResultList)) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다."); alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return; return;
} }
if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) {
alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다.");
return;
}
try { try {
setIsSubmitting(true); setIsSubmitting(true);
const docNo = await submitForm(data, "LE"); const result = await submitForm(data, "LE");
if (!docNo) return; if (!result?.sewerBscPlanDocNo) return;
alert("신청 완료되었습니다."); alert("신청 완료되었습니다.");
router.push(`/sewer-bsc-plan/${docNo}`); router.push(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl ?? 1}`);
queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey }); queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
} catch (error) { } catch (error) {
console.error("신청 실패:", error); console.error("신청 실패:", error);
@@ -165,10 +177,10 @@ function SewerBscPlanFormPage({
if (isSubmitting) return; if (isSubmitting) return;
const hasEmptySplmntCn = data.splmntDmndList?.some( const hasEmptySplmntCn = data.splmntDmndList?.some(
(item) => !item.splmntCn || !item.splmntCn.trim(), (item) => item.cmptnYn === "N" && (!item.splmntCn || !item.splmntCn.trim()),
); );
if (hasEmptySplmntCn) { if (hasEmptySplmntCn) {
alert("보완요청내용이 있습니다. 보완완료내용을 입력해 주세요."); alert("보완요청 항목이 있습니다.\n보완완료 내용을 입력해 주세요.");
return; return;
} }
@@ -187,7 +199,8 @@ function SewerBscPlanFormPage({
if (!result) return; if (!result) return;
alert("보완완료 처리되었습니다."); alert("보완완료 처리되었습니다.");
router.push(`/sewer-bsc-plan/${docNo}`); const chgCycl = data.sewerBscPlanDtl.chgCycl ?? initialData?.detDto?.chgCycl ?? 1;
router.push(`/sewer-bsc-plan/${docNo}?chgCycl=${chgCycl}`);
queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey }); queryClient.invalidateQueries({ queryKey: sewerBscPlanBaseKey });
} catch (error) { } catch (error) {
console.error("보완완료 실패:", error); console.error("보완완료 실패:", error);
@@ -236,12 +249,9 @@ function SewerBscPlanFormPage({
<BizCstMatrixFormSection /> <BizCstMatrixFormSection />
<FnrsCstFormSection /> <FnrsCstFormSection />
<TmplntFormSection /> <TmplntFormSection />
<ChecklistFormSection <ChecklistFormSection docSeComCd="SEWER" fieldPrefix="sewerBscPlan" />
docSeComCd={SEWER_BSC_PLAN_DOC_SE_COM_CD}
fieldPrefix="sewerBscPlan"
/>
<AttachmentsFormSection <AttachmentsFormSection
docSeComCd={SEWER_BSC_PLAN_DOC_SE_COM_CD} docSeComCd="SEWER"
fieldPrefix="sewerBscPlanDtl" fieldPrefix="sewerBscPlanDtl"
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined} initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined} initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
@@ -55,7 +55,7 @@ function SewerBscPlanList({ searchParams, pagination, onPaginationChange }: Sewe
router.push( router.push(
canEdit canEdit
? `/sewer-bsc-plan/${row.sewerBscPlanDocNo}/edit?chgCycl=${row.chgCycl}` ? `/sewer-bsc-plan/${row.sewerBscPlanDocNo}/edit?chgCycl=${row.chgCycl}`
: `/sewer-bsc-plan/${row.sewerBscPlanDocNo}`, : `/sewer-bsc-plan/${row.sewerBscPlanDocNo}?chgCycl=${row.chgCycl}`,
); );
}; };
@@ -0,0 +1,138 @@
"use client";
import { useMemo, useRef } from "react";
import { useUserStore } from "@/shared/lib";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { useSewerBscPlanDetails } from "@/entities/sewer-bsc-plan";
import { SectionCollapsible, SupplementDemandSection } from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
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";
import { ContactInfoSection } from "./sections/contact-info-section";
import { FnrsCstSection } from "./sections/fnrs-cst-section";
import { OutlineSection } from "./sections/outline-section";
import {
TechReviewOpinionSection,
type TechReviewOpinionSectionHandle,
} from "./sections/tech-review-opinion-section";
import { TmplntSection } from "./sections/tmplnt-section";
interface SewerBscPlanViewPageProps {
id: string;
propChgCycl?: number;
}
function SewerBscPlanViewPage({ id, propChgCycl }: SewerBscPlanViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const chgCycl = propChgCycl ?? 1;
const { data, isLoading, isError } = useSewerBscPlanDetails(id, chgCycl);
const { formState: checklistFormState, setFormState: setChecklistFormState } =
useChecklistFormState(data?.cklstResultRows ?? EMPTY_CKLST_RESULT_LIST);
const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
{},
{ enabled: needsAtrzResolve },
);
const atrzSeComCdList = useMemo(
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
[rvwLoginUserData],
);
if (isLoading) {
return (
<div className="mt-6 flex flex-col gap-4 px-6">
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</div>
);
}
if (isError || !data?.detDto) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
}
const detDto = data.detDto;
const activeDtlDto = data.activeDtlDto;
const otlnList = data.otlnList ?? [];
const bizCstList = data.bizCstList ?? [];
const fnrsCstList = data.fnrsCstList ?? [];
const tmplntList = data.tmplntList ?? [];
const cklstResultRows = data.cklstResultRows ?? [];
const esntlFileList = data.esntlFileList ?? [];
const etcFileList = data.etcFileList ?? [];
const rvwOpnnList = data.rvwOpnnList ?? [];
const splmntDmndList = data.splmntDmndList ?? [];
const editableReviewStep = getEditableReviewStep(
userInfo?.dtlInstCd,
detDto.prgrsSttsComCd || "",
);
return (
<div className="mt-6 space-y-4">
<BasicInfoSection det={detDto} dtl={activeDtlDto} />
<ContactInfoSection dtl={activeDtlDto} />
<SectionCollapsible title="결재현황">
<ElaprDocPrgrsStatus elaprDocNo={detDto.sewerBscPlanDocNo} />
</SectionCollapsible>
<OutlineSection otlnList={otlnList} dtl={activeDtlDto} />
<BizCstMatrixSection bizCstList={bizCstList} dtl={activeDtlDto} />
<FnrsCstSection fnrsCstList={fnrsCstList} dtl={activeDtlDto} />
<TmplntSection tmplntList={tmplntList} det={detDto} dtl={activeDtlDto} />
<ChecklistSection
cklstResultList={cklstResultRows}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
userRoleCode={userInfo?.dtlInstCd}
formState={checklistFormState}
setFormState={setChecklistFormState}
/>
<AttachmentsSection esntlFileList={esntlFileList} etcFileList={etcFileList} />
<SupplementDemandSection splmntDmndList={splmntDmndList} />
<TechReviewOpinionSection
ref={techReviewOpinionRef}
rvwList={rvwOpnnList}
editableReviewStep={editableReviewStep}
atrzSeComCdList={atrzSeComCdList}
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
/>
<FloatingActionButtons
data={data}
docSeComCd="SEWER"
sewerBscPlanDocNo={detDto.sewerBscPlanDocNo}
chgCycl={chgCycl}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
dtlInstCd={userInfo?.dtlInstCd}
atrzSeComCdList={atrzSeComCdList}
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
checklistFormState={checklistFormState}
techReviewOpinionRef={techReviewOpinionRef}
opnrNm={userInfo?.userName}
/>
</div>
);
}
export { SewerBscPlanViewPage };
+1 -1
View File
@@ -188,7 +188,7 @@ export interface EsntlAtchFileSaveBase {
/** 보완요구 저장 공통 필드 */ /** 보완요구 저장 공통 필드 */
export interface SplmntDmndSaveBase { export interface SplmntDmndSaveBase {
splmntDmndSn?: number; splmntDmndSn?: number | null;
splmntArtclComCd?: string; splmntArtclComCd?: string;
splmntDmndCn?: string; splmntDmndCn?: string;
splmntDmndDt?: string; splmntDmndDt?: string;
+2 -8
View File
@@ -1,12 +1,6 @@
"use client"; "use client";
import { import { Button, Dialog, DialogClose, DialogContent, DialogFooter } from "@common/components";
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
} from "@common/components";
import { useAlertStore } from "../lib/use-alert-store"; import { useAlertStore } from "../lib/use-alert-store";
@@ -20,7 +14,7 @@ function AlertDialog() {
<Dialog open={!!message} onOpenChange={handleOpenChange}> <Dialog open={!!message} onOpenChange={handleOpenChange}>
<DialogContent aria-describedby={undefined}> <DialogContent aria-describedby={undefined}>
<div className="py-10"> <div className="py-10">
<p className="text-foreground whitespace-pre-line text-center text-[1.4rem]"> <p className="whitespace-pre-line break-keep text-center text-[1.4rem] text-foreground">
{message} {message}
</p> </p>
</div> </div>
+3 -1
View File
@@ -1,2 +1,4 @@
export { PageLayout } from "./page-layout";
export { NoData } from "./no-data"; export { NoData } from "./no-data";
export { PageLayout } from "./page-layout";
export type { TextShimmerProps } from "./text-shimmer";
export { TEXT_SHIMMER_CLASS, TextShimmer } from "./text-shimmer";
+19
View File
@@ -0,0 +1,19 @@
import { cn } from "@common/components";
const TEXT_SHIMMER_CLASS =
"animate-text-shimmer bg-gradient-to-r from-muted-foreground via-muted to-muted-foreground bg-[length:200%_100%] bg-clip-text text-transparent";
interface TextShimmerProps extends React.ComponentPropsWithoutRef<"span"> {
active?: boolean;
}
function TextShimmer({ active = false, className, children, ...props }: TextShimmerProps) {
return (
<span className={cn(className, active && TEXT_SHIMMER_CLASS)} {...props}>
{children}
</span>
);
}
export { TEXT_SHIMMER_CLASS, TextShimmer };
export type { TextShimmerProps };
+1
View File
@@ -3,6 +3,7 @@ export {
createEtcUploadKey, createEtcUploadKey,
createUploadKey, createUploadKey,
isEtcFileKey, isEtcFileKey,
isFormEsntlAtchComplete,
} from "./lib/attachments-form-utils"; } from "./lib/attachments-form-utils";
export { export {
buildAcntgCostItems, buildAcntgCostItems,
@@ -1,4 +1,5 @@
import { getDefaultFileKey } from "@/shared/lib"; import { getDefaultFileKey } from "@/shared/lib";
import type { EsntlAtchFileMngTreeResponseDto } from "@/shared/model";
export type FieldPrefix = export type FieldPrefix =
| "fnrsCnsltnMng" | "fnrsCnsltnMng"
@@ -9,6 +10,11 @@ export type FieldPrefix =
export const ETC_FILE_KEY_PREFIX = "etc::"; export const ETC_FILE_KEY_PREFIX = "etc::";
interface EsntlAtchFileListItem {
esntlAtchFileId: string;
atchFileGroupId: string;
}
export function atchFileGroupIdPath(fieldPrefix: FieldPrefix): string { export function atchFileGroupIdPath(fieldPrefix: FieldPrefix): string {
return `${fieldPrefix}.atchFileGroupId`; return `${fieldPrefix}.atchFileGroupId`;
} }
@@ -35,3 +41,21 @@ export function toFileAccept(rmrk: string | null | undefined): string {
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)) .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
.join(","); .join(",");
} }
export function isFormEsntlAtchComplete(
essentialFiles: EsntlAtchFileMngTreeResponseDto[],
esntlAtchFileList: EsntlAtchFileListItem[],
): boolean {
if (essentialFiles.length === 0) return true;
const requiredFiles = essentialFiles.filter((file) => file.esntlAtchYn === "Y");
if (requiredFiles.length === 0) return true;
const attachedIds = new Set(
esntlAtchFileList
.filter((item) => Boolean(item.atchFileGroupId))
.map((item) => item.esntlAtchFileId),
);
return requiredFiles.every((file) => attachedIds.has(file.esntlAtchFileId));
}
@@ -8,6 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config"; import { ENDPOINT } from "@/shared/config";
import { useTus } from "@/shared/lib"; import { useTus } from "@/shared/lib";
import type { AtflMngClientResponseDto, EsntlAtchFileMngTreeResponseDto } from "@/shared/model"; import type { AtflMngClientResponseDto, EsntlAtchFileMngTreeResponseDto } from "@/shared/model";
import { TextShimmer } from "@/shared/ui";
import { attachService } from "@/entities/attach"; import { attachService } from "@/entities/attach";
import { useEsntlAtchFileMngTree } from "@/entities/esntl"; import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import { import {
@@ -413,20 +414,21 @@ function AttachmentsFormSection({
return ( return (
<div key={item.fileKey} className="flex items-center gap-2"> <div key={item.fileKey} className="flex items-center gap-2">
<span className="text-xl"> <TextShimmer className="text-xl" active={uploading}>
{item.file?.name ?? item.fileName} {item.file?.name ?? item.fileName}
</span> </TextShimmer>
<Button {!uploading && (
size="icon" <Button
onClick={(e) => { size="icon"
e.preventDefault(); onClick={(e) => {
void removeEssentialFile(file.esntlAtchFileId, idx); e.preventDefault();
}} void removeEssentialFile(file.esntlAtchFileId, idx);
disabled={uploading} }}
className="size-8 !bg-destructive" className="size-8 !bg-destructive"
> >
<i className="diveicon di-x text-xs" aria-label="삭제" /> <i className="diveicon di-x text-xs" aria-label="삭제" />
</Button> </Button>
)}
</div> </div>
); );
})} })}
@@ -489,7 +491,9 @@ function AttachmentsFormSection({
key={item.fileKey} key={item.fileKey}
className="flex items-center justify-between rounded-sm border p-2" className="flex items-center justify-between rounded-sm border p-2"
> >
<span className="px-2 text-xl">{item.file?.name ?? item.fileName}</span> <TextShimmer className="px-2 text-xl" active={uploading}>
{item.file?.name ?? item.fileName}
</TextShimmer>
<Button <Button
size="sm" size="sm"
color="gray" color="gray"
@@ -25,7 +25,11 @@ import {
TabsTrigger, TabsTrigger,
} from "@common/components"; } from "@common/components";
import { buildChecklistTabs, normalizeTaskSeComCdArray, syncCklstResultListFromTree } from "../lib/checklist-form-utils"; import {
buildChecklistTabs,
normalizeTaskSeComCdArray,
syncCklstResultListFromTree,
} from "../lib/checklist-form-utils";
import { SectionCollapsible } from "./section-collapsible"; import { SectionCollapsible } from "./section-collapsible";
@@ -49,10 +53,7 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
const { watch, setValue, control, getValues } = useFormContext(); const { watch, setValue, control, getValues } = useFormContext();
const [activeTab, setActiveTab] = useState(""); const [activeTab, setActiveTab] = useState("");
const taskSeComCd = watch(`${fieldPrefix}.taskSeComCd`) as string | string[] | undefined; const taskSeComCd = watch(`${fieldPrefix}.taskSeComCd`) as string | string[] | undefined;
const taskSeComCdArray = useMemo( const taskSeComCdArray = useMemo(() => normalizeTaskSeComCdArray(taskSeComCd), [taskSeComCd]);
() => normalizeTaskSeComCdArray(taskSeComCd),
[taskSeComCd],
);
const cklstResultList = const cklstResultList =
(useWatch({ control, name: "cklstResultList" }) as CklstResultListItem[] | undefined) ?? []; (useWatch({ control, name: "cklstResultList" }) as CklstResultListItem[] | undefined) ?? [];
@@ -206,6 +207,7 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
<Button <Button
type="button" type="button"
size="sm" size="sm"
color="white"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
handleSelectAll(tab.code, "Y"); handleSelectAll(tab.code, "Y");
@@ -218,11 +220,11 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
<Button <Button
type="button" type="button"
size="sm" size="sm"
color="gray"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
handleSelectAll(tab.code, "N"); handleSelectAll(tab.code, "N");
}} }}
className="!bg-destructive"
> >
전체 아니오 전체 아니오
</Button> </Button>
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form"; import { Controller, useFormContext, useWatch } from "react-hook-form";
import { getDefaultFileKey, useTus } from "@/shared/lib"; import { getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { Field } from "@/shared/ui/field"; import { Field } from "@/shared/ui/field";
import { useAcntgSeMngTree } from "@/entities/acntg"; import { useAcntgSeMngTree } from "@/entities/acntg";
import type { AtflMngClientResponseDto } from "@/entities/fnrs-cnsltn"; import type { AtflMngClientResponseDto } from "@/entities/fnrs-cnsltn";
@@ -257,12 +258,18 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
<div className="mt-6 px-2 pb-2"> <div className="mt-6 px-2 pb-2">
<h3 className="mb-2 text-xl font-semibold">첨부파일</h3> <h3 className="mb-2 text-xl font-semibold">첨부파일</h3>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Input {isSharUploading && uploadedFileName ? (
value={uploadedFileName} <TextShimmer className="max-w-lg text-xl" active>
readOnly {uploadedFileName}
placeholder="총사업비 신청서(.hwp) 파일을 등록해주세요." </TextShimmer>
className="max-w-lg rounded-sm" ) : (
/> <Input
value={uploadedFileName}
readOnly
placeholder="총사업비 신청서(.hwp) 파일을 등록해주세요."
className="max-w-lg rounded-sm"
/>
)}
<Button type="button" color="white" disabled={isSharUploading} asChild> <Button type="button" color="white" disabled={isSharUploading} asChild>
<label className="cursor-pointer"> <label className="cursor-pointer">
첨부파일 첨부파일
@@ -72,7 +72,8 @@ function PbadmsTaskApplyDialog({
const sidoOptions = sidoData ?? EMPTY_ADST_LIST; const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST; const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const isPending = isSidoPending || isTaskSePending; const isPending = isSidoPending || isTaskSePending;
const canConfirm = Boolean(sidoComCd && selectedCodes.length > 0); const canSidoGugunSelectable = userInfo?.dtlInstCd !== "ROLE_USER05";
const canConfirm = selectedCodes.length > 0 && (!canSidoGugunSelectable || !!sidoComCd);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -121,48 +122,50 @@ function PbadmsTaskApplyDialog({
<col width="35%" /> <col width="35%" />
</colgroup> </colgroup>
<TableHeader> <TableHeader>
<TableRow> {canSidoGugunSelectable && (
<TableHead data-required>시도</TableHead> <TableRow>
<TableCell> <TableHead data-required>시도</TableHead>
<Select <TableCell>
value={sidoComCd || undefined} <Select
onValueChange={(value) => { value={sidoComCd || undefined}
setSidoComCd(value); onValueChange={(value) => {
setSggComCd(""); setSidoComCd(value);
}} setSggComCd("");
> }}
<SelectTrigger className="w-full"> >
<SelectValue placeholder="선택" /> <SelectTrigger className="w-full">
</SelectTrigger> <SelectValue placeholder="선택" />
<SelectContent> </SelectTrigger>
{sidoOptions.map((option) => ( <SelectContent>
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}> {sidoOptions.map((option) => (
{option.adstDstrNm} <SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
</SelectItem> {option.adstDstrNm}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</TableCell> </Select>
<TableHead>시군구</TableHead> </TableCell>
<TableCell> <TableHead>시군구</TableHead>
<Select <TableCell>
value={sggComCd || undefined} <Select
onValueChange={setSggComCd} value={sggComCd || undefined}
disabled={!sidoComCd || isGugunPending} onValueChange={setSggComCd}
> disabled={!sidoComCd || isGugunPending}
<SelectTrigger className="w-full"> >
<SelectValue placeholder="선택" /> <SelectTrigger className="w-full">
</SelectTrigger> <SelectValue placeholder="선택" />
<SelectContent> </SelectTrigger>
{gugunOptions.map((option) => ( <SelectContent>
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}> {gugunOptions.map((option) => (
{option.adstDstrNm} <SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
</SelectItem> {option.adstDstrNm}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</TableCell> </Select>
</TableRow> </TableCell>
</TableRow>
)}
<TableRow> <TableRow>
<TableHead data-required>업무구분</TableHead> <TableHead data-required>업무구분</TableHead>
<TableCell colSpan={3}> <TableCell colSpan={3}>
@@ -3,6 +3,7 @@
import { useEffect, useId, useState } from "react"; import { useEffect, useId, useState } from "react";
import { getDefaultFileKey, useTus } from "@/shared/lib"; import { getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { attachService } from "@/entities/attach"; import { attachService } from "@/entities/attach";
import { CodeSelect } from "@/entities/code"; import { CodeSelect } from "@/entities/code";
import { import {
@@ -21,7 +22,6 @@ import {
Textarea, Textarea,
} from "@common/components"; } from "@common/components";
const SPLMNT_ARTCL_UP_CD = "SPLMNTARTCL";
const SPLMNT_DMND_FILE_KEY_PREFIX = "splmnt-dmnd::"; const SPLMNT_DMND_FILE_KEY_PREFIX = "splmnt-dmnd::";
interface UploadedSplmntFile { interface UploadedSplmntFile {
@@ -50,6 +50,7 @@ interface SplmntDmndDialogConfirmPayload {
} }
interface SplmntDmndDialogProps { interface SplmntDmndDialogProps {
splmntArtclUpCd: string;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
isSubmitting?: boolean; isSubmitting?: boolean;
@@ -86,7 +87,13 @@ function isRowsValid(rows: SplmntDmndRow[]) {
return rows.every((row) => row.splmntArtclComCd.trim() && row.splmntDmndCn.trim()); return rows.every((row) => row.splmntArtclComCd.trim() && row.splmntDmndCn.trim());
} }
function SplmntDmndDialog({ open, onOpenChange, isSubmitting, onConfirm }: SplmntDmndDialogProps) { function SplmntDmndDialog({
splmntArtclUpCd,
open,
onOpenChange,
isSubmitting,
onConfirm,
}: SplmntDmndDialogProps) {
const formId = useId(); const formId = useId();
const [rows, setRows] = useState<SplmntDmndRow[]>([createEmptyRow()]); const [rows, setRows] = useState<SplmntDmndRow[]>([createEmptyRow()]);
@@ -239,7 +246,7 @@ function SplmntDmndDialog({ open, onOpenChange, isSubmitting, onConfirm }: Splmn
<TableRow key={row.rowKey}> <TableRow key={row.rowKey}>
<TableCell align="center" className="align-top"> <TableCell align="center" className="align-top">
<CodeSelect <CodeSelect
upCd={SPLMNT_ARTCL_UP_CD} upCd={splmntArtclUpCd}
value={row.splmntArtclComCd || undefined} value={row.splmntArtclComCd || undefined}
onValueChange={(value) => onValueChange={(value) =>
updateRow(row.rowKey, { splmntArtclComCd: value }) updateRow(row.rowKey, { splmntArtclComCd: value })
@@ -263,7 +270,9 @@ function SplmntDmndDialog({ open, onOpenChange, isSubmitting, onConfirm }: Splmn
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{row.uploadedFile ? ( {row.uploadedFile ? (
<div className="flex items-stretch gap-2"> <div className="flex items-stretch gap-2">
<span className="text-xl">{row.uploadedFile.fileName}</span> <TextShimmer className="text-xl" active={uploading}>
{row.uploadedFile.fileName}
</TextShimmer>
<Button <Button
type="button" type="button"
size="icon" size="icon"
@@ -6,6 +6,7 @@ import { Controller, useFormContext, useWatch } from "react-hook-form";
import { Paperclip } from "lucide-react"; import { Paperclip } from "lucide-react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach"; import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach";
import { import {
Badge, Badge,
@@ -272,7 +273,9 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
{uploaded.fileName} {uploaded.fileName}
</a> </a>
) : ( ) : (
<span className="text-xl">{uploaded.fileName}</span> <TextShimmer className="text-xl" active={uploading}>
{uploaded.fileName}
</TextShimmer>
)} )}
{!isRowReadOnly ? ( {!isRowReadOnly ? (
<Button <Button
@@ -26,6 +26,10 @@ interface SupplementDemandSectionProps {
function SupplementDemandSection({ splmntDmndList }: SupplementDemandSectionProps) { function SupplementDemandSection({ splmntDmndList }: SupplementDemandSectionProps) {
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd(); const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
if (splmntDmndList.length === 0) {
return null;
}
return ( return (
<SectionCollapsible title="보완요청 및 완료 현황"> <SectionCollapsible title="보완요청 및 완료 현황">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
+5
View File
@@ -83,12 +83,17 @@ export default {
from: { transform: "translate(0,0) rotate(0deg)" }, from: { transform: "translate(0,0) rotate(0deg)" },
to: { transform: "translate(0,0) rotate(360deg)" }, to: { transform: "translate(0,0) rotate(360deg)" },
}, },
"text-shimmer": {
"0%": { backgroundPosition: "200% center" },
"100%": { backgroundPosition: "-200% center" },
},
}, },
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", "accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out",
"spin-parent": "spin-parent 6s linear infinite", "spin-parent": "spin-parent 6s linear infinite",
"spin-child": "spin-child 1s ease-in-out infinite", "spin-child": "spin-child 1s ease-in-out infinite",
"text-shimmer": "text-shimmer 1.5s linear infinite",
}, },
backgroundImage: { backgroundImage: {
"gradient-blue": "linear-gradient(.25turn, #2094fa, #00e5dd)", "gradient-blue": "linear-gradient(.25turn, #2094fa, #00e5dd)",