This commit is contained in:
2026-06-30 09:05:23 +09:00
16 changed files with 670 additions and 196 deletions
@@ -1,3 +1,14 @@
import { InstlRegistPage } from "@/pages/instl-sncn";
export default InstlRegistPage;
interface InstlSncnRegistRouteProps {
searchParams: Promise<{ taskSeComCd?: string }>;
}
export default async function Page({ searchParams }: InstlSncnRegistRouteProps) {
const params = await searchParams;
const taskSeComCd = params.taskSeComCd
? params.taskSeComCd.split(",").filter(Boolean)
: undefined;
return <InstlRegistPage taskSeComCd={taskSeComCd} />;
}
@@ -23,7 +23,7 @@ export const instlSncnKeys = {
export function useInstlSncnList(params: InstlSncnMngSearchParams) {
return useQuery({
queryKey: instlSncnKeys.list(),
queryKey: [...instlSncnKeys.list(), params],
queryFn: () => instlSncnService.getPage(params),
});
}
@@ -1,15 +1,6 @@
import type {
BizCstTreeDto,
BizSclTreeDto,
CklstRvwRsltTreeDto,
EsntlAtchFileMngTreeResponseDto,
InstlSncnApplySaveRequestDto,
InstlSncnBizCstSaveRequestDto,
InstlSncnBzScaleSaveDto,
InstlSncnCklstResultSaveDto,
InstlSncnEsntlAtchFileSaveDto,
InstlSncnMngDetResponse,
InstlSncnMngResponseDto,
ReviewStepCode,
} from "@/entities/instl-sncn";
@@ -18,10 +9,7 @@ import {
type ChecklistProgress,
type ChecklistRowKey,
type ChecklistStepMap,
INSTL_PROGRESS_ORDER,
REVIEW_STEP_CODES,
REVIEW_STEP_ROLE_RANK,
ROLE_TO_REVIEW_STEP,
} from "../model/view-types";
export function buildChecklistRowKey(
@@ -41,26 +29,6 @@ export function groupByReviewStep(rows: CklstRvwRsltTreeDto[]): ChecklistStepMap
return base;
}
export function getProgressStepCount(prgrsSttsComCd?: string): number {
const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd as (typeof INSTL_PROGRESS_ORDER)[number]);
if (idx <= INSTL_PROGRESS_ORDER.indexOf("SV")) return 1;
if (idx <= INSTL_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 extractTaskCodes(rows: CklstRvwRsltTreeDto[]): string[] {
return Array.from(new Set(rows.map((row) => row.taskSeComCd).filter(Boolean)));
}
@@ -69,15 +37,7 @@ export function filterChecklistRows(
rows: CklstRvwRsltTreeDto[],
taskCode: string | null,
): CklstRvwRsltTreeDto[] {
return rows
.filter((row) => (taskCode ? row.taskSeComCd === taskCode : true))
.slice()
.sort(
(a, b) =>
(a.cklstArtclSn ?? 0) - (b.cklstArtclSn ?? 0) ||
(a.sortSeq ?? 0) - (b.sortSeq ?? 0) ||
(a.cklstRvwSn ?? 0) - (b.cklstRvwSn ?? 0),
);
return rows.filter((row) => (taskCode ? row.taskSeComCd === taskCode : true)).slice();
}
export function getChecklistDisplayRows(
@@ -219,86 +179,3 @@ export function toCklstSaveDto(
};
});
}
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] {
return bizSclList
.filter((node) => node.useYn === "Y")
.map((node) => ({
cnstpSeCd: node.cnstpSeCd,
bscPl: node.bscPlanQty,
budgetReflct: node.bgtRfltQty,
thtiDsgn: node.tstmDesignQty,
diff: node.tstmDesignQty - node.bgtRfltQty,
}));
}
export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] {
return bizCstList
.filter((node) => node.useYn === "Y")
.map((node) => ({
acntgSeCd: node.acntgSeCd,
bscPlanAmt: node.bscPlanAmt,
bgtRfltAmt: node.bgtRfltAmt,
tstmDesignAmt: node.tstmDesignAmt,
}));
}
export function toEsntlAtchFileSaveDto(
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
): InstlSncnEsntlAtchFileSaveDto[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function buildSavePayload(
data: InstlSncnMngDetResponse,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
): InstlSncnApplySaveRequestDto {
const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data;
return {
instlSncnMng: { ...detDto, prgrsSttsComCd },
bzScaleList: toBzScaleSaveDto(bizSclList),
bizCstList: toBizCstSaveDto(bizCstList),
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
};
}
export function buildTechReviewReplyPayload(
data: InstlSncnMngDetResponse,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
techReViewSaveCd: string,
): InstlSncnApplySaveRequestDto {
return {
...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
techReViewSaveCd,
rvwStpCd: reviewStep,
};
}
export function buildTechReviewPayload(
det: InstlSncnMngResponseDto,
cklstResultList: InstlSncnCklstResultSaveDto[],
): InstlSncnApplySaveRequestDto {
return {
instlSncnMng: { ...det, prgrsSttsComCd: "ER" },
bzScaleList: [],
bizCstList: [],
cklstResultList,
esntlAtchFileList: [],
};
}
@@ -89,14 +89,17 @@ export function mapDetailToFormData(data: InstlSncnMngDetResponse): CnfmRegistFo
};
}
export function getCreateDefaultValues(userInfo: IUserInfo | null): CnfmRegistFormData {
export function getCreateDefaultValues(
userInfo: IUserInfo | null,
taskSeComCd?: string[],
): CnfmRegistFormData {
return {
instlSncnMng: {
bizNm: "",
bizBgngYr: "",
bizEndYr: "",
bizCn: "",
taskSeComCd: [],
taskSeComCd: taskSeComCd ?? [],
sncnSeComCd: "",
dsgnrNm: "",
dsgnrTelno: "",
@@ -0,0 +1,37 @@
import type {
InstlSncnMngDetResponse,
InstlSncnRvwOpnnSaveRequestDto,
} from "@/entities/instl-sncn";
export interface ReviewOpinionFormData {
rvwRsltCn: string;
rvwRsltAnmlyYn: "Y" | "N" | "";
atchFileGroupId: string;
opnrSeComCd: string;
}
export function toRvwOpnnSaveDto(
data: InstlSncnMngDetResponse,
reviewOpinionFormData: ReviewOpinionFormData | null | undefined,
opnrNm?: string,
): InstlSncnRvwOpnnSaveRequestDto[] | undefined {
if (!reviewOpinionFormData) return undefined;
const hasContent =
reviewOpinionFormData.rvwRsltCn.trim() ||
reviewOpinionFormData.rvwRsltAnmlyYn ||
reviewOpinionFormData.atchFileGroupId;
if (!hasContent) return undefined;
return [
{
instlSncnDocNo: data.detDto.instlSncnDocNo,
opnrSeComCd: reviewOpinionFormData.opnrSeComCd,
opnrNm: opnrNm ?? "",
rvwRsltCn: reviewOpinionFormData.rvwRsltCn,
rvwRsltAnmlyYn: reviewOpinionFormData.rvwRsltAnmlyYn || undefined,
atchFileGroupId: reviewOpinionFormData.atchFileGroupId || undefined,
},
];
}
@@ -0,0 +1,43 @@
import type { ReviewStepCode } from "@/entities/instl-sncn";
import {
INSTL_PROGRESS_ORDER,
REVIEW_STEP_CODES,
REVIEW_STEP_ROLE_RANK,
ROLE_TO_REVIEW_STEP,
} from "../model/view-types";
export function getProgressStepCount(prgrsSttsComCd?: string): number {
const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd as (typeof INSTL_PROGRESS_ORDER)[number]);
if (idx <= INSTL_PROGRESS_ORDER.indexOf("SV")) return 1;
if (idx <= INSTL_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;
}
@@ -0,0 +1,104 @@
import type {
BizCstTreeDto,
BizSclTreeDto,
EsntlAtchFileMngTreeResponseDto,
InstlSncnApplySaveRequestDto,
InstlSncnBizCstSaveRequestDto,
InstlSncnBzScaleSaveDto,
InstlSncnCklstResultSaveDto,
InstlSncnEsntlAtchFileSaveDto,
InstlSncnMngDetResponse,
InstlSncnMngResponseDto,
ReviewStepCode,
} from "@/entities/instl-sncn";
import type { ChecklistFormState } from "../model/view-types";
import { toCklstSaveDto } from "./checklist-utils";
import { type ReviewOpinionFormData, toRvwOpnnSaveDto } from "./review-opinion-utils";
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] {
return bizSclList
.filter((node) => node.useYn === "Y")
.map((node) => ({
cnstpSeCd: node.cnstpSeCd,
bscPl: node.bscPlanQty,
budgetReflct: node.bgtRfltQty,
thtiDsgn: node.tstmDesignQty,
diff: node.tstmDesignQty - node.bgtRfltQty,
}));
}
export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] {
return bizCstList
.filter((node) => node.useYn === "Y")
.map((node) => ({
acntgSeCd: node.acntgSeCd,
bscPlanAmt: node.bscPlanAmt,
bgtRfltAmt: node.bgtRfltAmt,
tstmDesignAmt: node.tstmDesignAmt,
}));
}
export function toEsntlAtchFileSaveDto(
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
): InstlSncnEsntlAtchFileSaveDto[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function buildSavePayload(
data: InstlSncnMngDetResponse,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
): InstlSncnApplySaveRequestDto {
const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data;
return {
instlSncnMng: { ...detDto, prgrsSttsComCd },
bzScaleList: toBzScaleSaveDto(bizSclList),
bizCstList: toBizCstSaveDto(bizCstList),
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
};
}
export function buildTechReviewReplyPayload(
data: InstlSncnMngDetResponse,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
techReViewSaveCd: string,
reviewOpinionFormData?: ReviewOpinionFormData | null,
opnrNm?: string,
): InstlSncnApplySaveRequestDto {
return {
...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
techReViewSaveCd,
rvwStpCd: reviewStep,
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
};
}
export function buildTechReviewPayload(
det: InstlSncnMngResponseDto,
cklstResultList: InstlSncnCklstResultSaveDto[],
): InstlSncnApplySaveRequestDto {
return {
instlSncnMng: { ...det, prgrsSttsComCd: "ER" },
bzScaleList: [],
bizCstList: [],
cklstResultList,
esntlAtchFileList: [],
};
}
@@ -25,12 +25,10 @@ import {
type InstlSncnViewButtonAction,
needsRoleUser01AtrzResolve,
} from "../../lib/button-config";
import {
buildSavePayload,
buildTechReviewReplyPayload,
computeReviewStepChecklistProgress,
} from "../../lib/checklist-utils";
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
import { buildSavePayload, buildTechReviewReplyPayload } from "../../lib/save-payload-utils";
import { type ChecklistFormState } from "../../model/view-types";
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
import { ElaprApprovalDialog } from "./elapr-approval-dialog";
import { ElaprTmpltPickerDialog } from "./elapr-tmplt-picker-dialog";
@@ -43,6 +41,8 @@ interface FloatingActionButtonsProps {
prgrsSttsComCd: string;
dtlInstCd: string | undefined;
checklistFormState: ChecklistFormState;
techReviewOpinionRef?: React.RefObject<TechReviewOpinionSectionHandle | null>;
opnrNm?: string;
}
type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | "elaprApproval" | null;
@@ -54,6 +54,8 @@ function FloatingActionButtons({
prgrsSttsComCd,
dtlInstCd,
checklistFormState,
techReviewOpinionRef,
opnrNm,
}: FloatingActionButtonsProps) {
const router = useRouter();
const { confirm } = useConfirm();
@@ -251,6 +253,10 @@ function FloatingActionButtons({
});
}
function getReviewOpinionFormData() {
return techReviewOpinionRef?.current?.getFormData() ?? null;
}
async function handleRvwRplyClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
@@ -263,13 +269,22 @@ function FloatingActionButtons({
return;
}
const payload = buildTechReviewReplyPayload(data, checklistFormState, "ENV", "EP", "CP");
const payload = buildTechReviewReplyPayload(
data,
checklistFormState,
"ENV",
"EP",
"CP",
getReviewOpinionFormData(),
opnrNm,
);
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply,
request: payload,
successMessage: "검토결과 회신이 완료되었습니다.",
});
techReviewOpinionRef?.current?.resetForm();
}
async function handleRqstTechRvwClick() {
@@ -303,13 +318,22 @@ function FloatingActionButtons({
}
async function handleRvwStrgClick() {
const payload = buildTechReviewReplyPayload(data, checklistFormState, "KECO", "TE", "SV");
const payload = buildTechReviewReplyPayload(
data,
checklistFormState,
"KECO",
"TE",
"SV",
getReviewOpinionFormData(),
opnrNm,
);
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwStrg,
request: payload,
successMessage: "저장되었습니다.",
});
techReviewOpinionRef?.current?.resetForm();
}
async function handleRvwCmptnClick() {
@@ -324,13 +348,22 @@ function FloatingActionButtons({
return;
}
const payload = buildTechReviewReplyPayload(data, checklistFormState, "KECO", "TE", "CP");
const payload = buildTechReviewReplyPayload(
data,
checklistFormState,
"KECO",
"TE",
"CP",
getReviewOpinionFormData(),
opnrNm,
);
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn,
request: payload,
successMessage: "검토완료 처리되었습니다.",
});
techReviewOpinionRef?.current?.resetForm();
}
function handleDmndSplmntClick() {
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import { Field, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
import { useCodeList } from "@/entities/code";
import {
Button,
Checkbox,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Skeleton,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
interface TaskSePickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (selectedCodes: string[]) => void;
}
function TaskSePickerDialog({ open, onOpenChange, onConfirm }: TaskSePickerDialogProps) {
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
const { data: taskOptions, isPending } = useCodeList("INSTL");
useEffect(() => {
if (!open) return;
setSelectedCodes([]);
}, [open]);
function handleCheckedChange(code: string, checked: boolean) {
setSelectedCodes((prev) =>
checked ? [...prev, code] : prev.filter((value) => value !== code),
);
}
function handleConfirm() {
onConfirm(selectedCodes);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>설치인가 신청</DialogTitle>
</DialogHeader>
{isPending ? (
<Skeleton className="h-[16rem] w-full" />
) : (
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>업무구분</TableHead>
<TableCell>
<FieldSet className="px-2">
<FieldGroup data-slot="checkbox-group" className="flex-row flex-wrap">
{taskOptions?.map((option) => (
<Field key={option.lwpoCd} orientation="horizontal" className="w-auto">
<Checkbox
id={`task-se-picker-${option.lwpoCd}`}
checked={selectedCodes.includes(option.lwpoCd)}
onCheckedChange={(checked) =>
handleCheckedChange(option.lwpoCd, checked === true)
}
/>
<FieldLabel
htmlFor={`task-se-picker-${option.lwpoCd}`}
className="font-normal"
>
{option.lwpoCdNm}
</FieldLabel>
</Field>
))}
</FieldGroup>
</FieldSet>
</TableCell>
</TableRow>
</TableHeader>
</Table>
)}
<DialogFooter>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={isPending || selectedCodes.length === 0}
>
확인
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { TaskSePickerDialog };
@@ -28,9 +28,10 @@ import { PrivacyConsentSection } from "./sections/privacy-consent-section";
interface InstlFormPageProps {
mode: "create" | "edit";
initialData?: InstlSncnMngDetResponse;
taskSeComCd?: string[];
}
function InstlFormPage({ mode, initialData }: InstlFormPageProps) {
function InstlFormPage({ mode, initialData, taskSeComCd }: InstlFormPageProps) {
const router = useRouter();
const userInfo = useUserStore((state) => state.userInfo);
const alert = useAlertStore((state) => state.setMessage);
@@ -43,7 +44,7 @@ function InstlFormPage({ mode, initialData }: InstlFormPageProps) {
defaultValues:
isEditMode && initialData
? mapDetailToFormData(initialData)
: getCreateDefaultValues(userInfo),
: getCreateDefaultValues(userInfo, taskSeComCd),
});
async function onSave(data: CnfmRegistFormData) {
+27 -11
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
@@ -11,6 +11,8 @@ import { Button } from "@common/components";
import { createInstlSncnColumns } from "../model/columns";
import { TaskSePickerDialog } from "./components/task-se-picker-dialog";
interface InstlListProps {
searchParams: InstlSncnMngSearchParams;
pagination: PaginationState;
@@ -19,6 +21,7 @@ interface InstlListProps {
function InstlList({ searchParams, pagination, onPaginationChange }: InstlListProps) {
const router = useRouter();
const [isTaskSeDialogOpen, setIsTaskSeDialogOpen] = useState(false);
const queryParams = useMemo(
() => ({
@@ -55,19 +58,32 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
});
const handleRegisterClick = () => {
router.push("/instl-sncn/regist");
setIsTaskSeDialogOpen(true);
};
const handleConfirmTaskSe = (selectedCodes: string[]) => {
setIsTaskSeDialogOpen(false);
const query = selectedCodes.join(",");
router.push(`/instl-sncn/regist?taskSeComCd=${query}`);
};
return (
<section className="card card-border mx-0 bg-background pb-8 pt-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">설치인가 목록</h3>
<Button type="button" color="green" onClick={handleRegisterClick}>
신청
</Button>
</div>
{tableComponent}
</section>
<>
<section className="card card-border mx-0 bg-background pb-8 pt-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">설치인가 목록</h3>
<Button type="button" color="green" onClick={handleRegisterClick}>
신청
</Button>
</div>
{tableComponent}
</section>
<TaskSePickerDialog
open={isTaskSeDialogOpen}
onOpenChange={setIsTaskSeDialogOpen}
onConfirm={handleConfirmTaskSe}
/>
</>
);
}
@@ -1,7 +1,11 @@
import { InstlFormPage } from "./instl-form-page";
function InstlRegistPage() {
return <InstlFormPage mode="create" />;
interface InstlRegistPageProps {
taskSeComCd?: string[];
}
function InstlRegistPage({ taskSeComCd }: InstlRegistPageProps) {
return <InstlFormPage mode="create" taskSeComCd={taskSeComCd} />;
}
export { InstlRegistPage };
@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { useRef } from "react";
import { useUserStore } from "@/shared/lib";
import { useInstlSncnDetail } from "@/entities/instl-sncn";
import { Skeleton } from "@common/components";
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";
@@ -15,7 +16,10 @@ import { BusinessCostSection } from "./sections/business-cost-section";
import { BusinessScaleSection } from "./sections/business-scale-section";
import { ChecklistSection } from "./sections/checklist-section";
import { ContactInfoSection } from "./sections/contact-info-section";
import { TechReviewOpinionSection } from "./sections/tech-review-opinion-section";
import {
TechReviewOpinionSection,
type TechReviewOpinionSectionHandle,
} from "./sections/tech-review-opinion-section";
interface InstlViewPageProps {
id: string;
@@ -23,6 +27,7 @@ interface InstlViewPageProps {
function InstlViewPage({ id }: InstlViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const { data, isLoading, isError } = useInstlSncnDetail(id);
const { formState: checklistFormState, setFormState: setChecklistFormState } =
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
@@ -60,6 +65,11 @@ function InstlViewPage({ id }: InstlViewPageProps) {
rvwList = [],
} = data;
const editableReviewStep = getEditableReviewStep(
userInfo?.dtlInstCd,
detDto.prgrsSttsComCd || "",
);
return (
<div className="mt-6 space-y-4">
<BasicInfoSection det={detDto} />
@@ -74,7 +84,11 @@ function InstlViewPage({ id }: InstlViewPageProps) {
setFormState={setChecklistFormState}
/>
<AttachmentsSection esntlFileList={esntlFileList} etcFileList={etcFileList} />
<TechReviewOpinionSection rvwList={rvwList} />
<TechReviewOpinionSection
ref={techReviewOpinionRef}
rvwList={rvwList}
editableReviewStep={editableReviewStep}
/>
<FloatingActionButtons
data={data}
docSeComCd="INSTL"
@@ -82,6 +96,8 @@ function InstlViewPage({ id }: InstlViewPageProps) {
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
dtlInstCd={userInfo?.dtlInstCd}
checklistFormState={checklistFormState}
techReviewOpinionRef={techReviewOpinionRef}
opnrNm={userInfo?.userName}
/>
</div>
);
@@ -4,7 +4,7 @@ import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
import { useCodeList } from "@/entities/code";
import { findCodeLabel, useCodeList } from "@/entities/code";
import {
Button,
Checkbox,
@@ -41,6 +41,12 @@ function BasicInfoFormSection() {
const { data: rtlztnFcltOptions } = useCodeList("RTLZTNFCLTSE");
const { data: rtlztnUsgOptions } = useCodeList("RTLZTNUSGSE");
const taskSeComCdLabel =
taskSeComCd
.map((code) => findCodeLabel(taskOptions, code))
.filter(Boolean)
.join(", ") || "-";
return (
<SectionCollapsible title="기본 정보">
<Table variant="secondary">
@@ -139,36 +145,11 @@ function BasicInfoFormSection() {
<Controller
name="instlSncnMng.taskSeComCd"
control={control}
render={({ field, fieldState }) => (
<FieldSet>
<FieldGroup data-slot="checkbox-group" className="flex-row">
{taskOptions?.map((option) => (
<Field
key={option.lwpoCd}
orientation="horizontal"
data-invalid={fieldState.invalid}
className="w-auto"
>
<Checkbox
id={`task-${option.lwpoCd}`}
name={field.name}
aria-invalid={fieldState.invalid}
checked={field.value.includes(option.lwpoCd)}
onCheckedChange={(checked) => {
const newValue = checked
? [...field.value, option.lwpoCd]
: field.value.filter((value) => value !== option.lwpoCd);
field.onChange(newValue);
}}
/>
<FieldLabel htmlFor={`task-${option.lwpoCd}`} className="font-normal">
{option.lwpoCdNm}
</FieldLabel>
</Field>
))}
</FieldGroup>
render={({ fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<span>{taskSeComCdLabel}</span>
<FieldError errors={[fieldState.error]} />
</FieldSet>
</Field>
)}
/>
</TableCell>
@@ -9,8 +9,8 @@ import {
computeReviewStepChecklistProgress,
extractTaskCodes,
getChecklistDisplayRows,
getVisibleReviewSteps,
} from "../../lib/checklist-utils";
import { getVisibleReviewSteps } from "../../lib/review-step-utils";
import {
type ChecklistFormState,
REVIEW_STEP_LABELS,
@@ -1,44 +1,285 @@
"use client";
import { PROXY_HOST } from "@/shared/config";
import { dayjs } from "@/shared/lib";
import type { InstlSncnRvwOpnnResponseDto } from "@/entities/instl-sncn";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { PROXY_HOST } from "@/shared/config";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService } from "@/entities/attach";
import type { InstlSncnRvwOpnnResponseDto, ReviewStepCode } from "@/entities/instl-sncn";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
import { SectionCollapsible } from "../components/section-collapsible";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: InstlSncnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionFormData | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: InstlSncnRvwOpnnResponseDto[];
}
function TechReviewOpinionSection({ rvwList }: TechReviewOpinionSectionProps) {
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 }, ref) {
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;
}
return {
...formData,
opnrSeComCd: editableReviewStep,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[editableReviewStep, formData],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
if (envRvwList.length === 0 && kecoRvwList.length === 0) {
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="p-2">
<h4 className="mb-4 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">
<span className="text-xl">{uploadedFile.fileName}</span>
<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 (
<>
{envRvwList.length > 0 ? (
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<TechReviewOpinionTable items={envRvwList} />
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
{renderOpinionForm("ENV")}
</SectionCollapsible>
) : null}
{kecoRvwList.length > 0 ? (
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<TechReviewOpinionTable items={kecoRvwList} />
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{renderOpinionForm("KECO")}
</SectionCollapsible>
) : null}
</>
);
}
});
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
return (