Compare commits
143
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df154a45b6 | ||
|
|
c5dbea334d | ||
|
|
85f535eb5d | ||
|
|
9334514c71 | ||
|
|
d438755616 | ||
|
|
54595fb7b6 | ||
|
|
4ee8d5a099 | ||
|
|
abe5fae4c4 | ||
|
|
aeb48661b1 | ||
|
|
4bdf68c2b6 | ||
|
|
c5a0e378fd | ||
|
|
a323c42acf | ||
|
|
095101ad2f | ||
|
|
7ee5ebd506 | ||
|
|
f90ef6297d | ||
|
|
4167107a91 | ||
|
|
90c1cb1f52 | ||
|
|
8c614b84a7 | ||
|
|
7d1a501ad8 | ||
|
|
48589274c8 | ||
|
|
83f7676db4 | ||
|
|
c8b30810f6 | ||
|
|
f71fa7d97a | ||
|
|
4c7c4c00ab | ||
|
|
d2645a9c08 | ||
|
|
ae341d66ba | ||
|
|
fce4def951 | ||
|
|
ba0964f772 | ||
|
|
e346417c29 | ||
|
|
5fcc184a88 | ||
|
|
f6d3f89b5b | ||
|
|
106e09a7a2 | ||
|
|
d1b4600392 | ||
|
|
4204dfa624 | ||
|
|
d7fd5be068 | ||
|
|
735bb397ec | ||
|
|
8b5a2b713c | ||
|
|
f2ec9327fd | ||
|
|
5331e10c96 | ||
|
|
7090f02a52 | ||
|
|
12152a6810 | ||
|
|
c0c2d3d1c9 | ||
|
|
858844e81a | ||
|
|
1ff2731121 | ||
|
|
70db7662bb | ||
|
|
bb57ff72e7 | ||
|
|
8df79fd624 | ||
|
|
7946c08c72 | ||
|
|
6f20ef7273 | ||
|
|
657ae77dde | ||
|
|
4ff80dd257 | ||
|
|
9bb1069d26 | ||
|
|
730be25948 | ||
|
|
78d8fea7f5 | ||
|
|
22ed78c87b | ||
|
|
f5331d4c50 | ||
|
|
f32fe0ec5b | ||
|
|
3b914fbc2e | ||
|
|
533df0eb16 | ||
|
|
9c38165afb | ||
|
|
7b506320b4 | ||
|
|
2b7c497c3e | ||
|
|
4dee1309eb | ||
|
|
f3f8909e45 | ||
|
|
d45cd79422 | ||
|
|
7cbb58ff32 | ||
|
|
9242e1fb47 | ||
|
|
f5ce74d0ee | ||
|
|
3114280e83 | ||
|
|
c7b8b36f4a | ||
|
|
4f50593eac | ||
|
|
8818dd21c6 | ||
|
|
db8ee298d9 | ||
|
|
9eef99a435 | ||
|
|
271b5d445d | ||
|
|
2457d14ae9 | ||
|
|
b73b83a49f | ||
|
|
549debf8bf | ||
|
|
9739609de4 | ||
|
|
c799c199c5 | ||
|
|
5b3aaa463d | ||
|
|
947dba5934 | ||
|
|
4816a512bf | ||
|
|
4fad163b14 | ||
|
|
da2191f365 | ||
|
|
55ab2b8562 | ||
|
|
526392c370 | ||
|
|
6020c80a7a | ||
|
|
32b778f961 | ||
|
|
3946fda013 | ||
|
|
db30b45267 | ||
|
|
6cc2a02df4 | ||
|
|
0f107ff29d | ||
|
|
5e69a9b6f0 | ||
|
|
ec9311c76b | ||
|
|
4c85a7d249 | ||
|
|
09d4c2d26e | ||
|
|
b8dac5dc72 | ||
|
|
73cafc9786 | ||
|
|
c276ae0c5e | ||
|
|
b4ed061a53 | ||
|
|
ddaaa8fc92 | ||
|
|
3a253df58a | ||
|
|
21debe2f77 | ||
|
|
0e4f662dfd | ||
|
|
4a797808c1 | ||
|
|
3a81b822f2 | ||
|
|
f5a7efb324 | ||
|
|
abc3ca184d | ||
|
|
267c022d58 | ||
|
|
0fc2844928 | ||
|
|
7f1fe42ace | ||
|
|
c83a13d680 | ||
|
|
3d7dcb6d0e | ||
|
|
a46f24d6d7 | ||
|
|
2e4f2428f0 | ||
|
|
706cebfeae | ||
|
|
ee7f5e3a07 | ||
|
|
3d0160f21f | ||
|
|
0aa0126caf | ||
|
|
5ecdd37187 | ||
|
|
20cc2ca248 | ||
|
|
5cfd7d65ec | ||
|
|
3b8307b674 | ||
|
|
2f800c960d | ||
|
|
2728b4058b | ||
|
|
b842905572 | ||
|
|
85fa9a8658 | ||
|
|
5ae56481c2 | ||
|
|
8079d7d6af | ||
|
|
7c8eef1715 | ||
|
|
f10b3179c2 | ||
|
|
2e0bb5aa84 | ||
|
|
7ae8d42731 | ||
|
|
a9e65af77b | ||
|
|
d21ecc975f | ||
|
|
452509281a | ||
|
|
2e3602eee6 | ||
|
|
7dfd535841 | ||
|
|
39d928b5b1 | ||
|
|
525695072a | ||
|
|
972a4ce789 | ||
|
|
879aadc068 |
@@ -27,6 +27,7 @@ settings.json
|
||||
.npmrc
|
||||
.cursor
|
||||
.agent
|
||||
CLAUDE.MD
|
||||
|
||||
apps/**/tsconfig.tsbuildinfo
|
||||
packages/**/tsconfig.tsbuildinfo
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { DesignListPage } from "@/pages/design-cnstn";
|
||||
|
||||
export default DesignListPage;
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<DesignListPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { FnrsListPage } from "@/pages/fnrs-cnsltn";
|
||||
|
||||
export default FnrsListPage;
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<FnrsListPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { InstlListPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default InstlListPage;
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<InstlListPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { SewerBscPlanListPage } from "@/pages/sewer-bsc-plan";
|
||||
|
||||
export default SewerBscPlanListPage;
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SewerBscPlanListPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
useFindUserGugunCombo,
|
||||
useFindUserSidoCombo,
|
||||
} from "./api/queries";
|
||||
export { useFindUserSidoGugunCombo } from "./lib/use-find-user-sido-gugun-combo";
|
||||
export type {
|
||||
CmCdAdstDstrComboResponseDto,
|
||||
CmCdAdstDstrService,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
|
||||
import { useFindUserGugunCombo, useFindUserSidoCombo } from "../api/queries";
|
||||
import type { CmCdAdstDstrComboResponseDto } from "../model/adstdstr-interface";
|
||||
|
||||
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
|
||||
|
||||
export function useFindUserSidoGugunCombo(sidoComCd: string) {
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const sidoFilterUserInfo =
|
||||
userInfo?.dtlInstCd === "ROLE_USER04" || userInfo?.dtlInstCd === "ROLE_USER05"
|
||||
? userInfo
|
||||
: undefined;
|
||||
|
||||
const { data: userSidoData, isPending: isUserSidoPending } =
|
||||
useFindUserSidoCombo(sidoFilterUserInfo);
|
||||
const { data: userGugunData, isPending: isUserGugunPending } =
|
||||
useFindUserGugunCombo(sidoComCd);
|
||||
|
||||
return {
|
||||
userSidoData: userSidoData ?? EMPTY_ADST_LIST,
|
||||
userGugunData: userGugunData ?? EMPTY_ADST_LIST,
|
||||
isUserSidoPending,
|
||||
isUserGugunPending,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export interface CnstpSeMngResponseDto {
|
||||
cnstpSeCdNm: string;
|
||||
upCnstpCd?: string;
|
||||
cdLvNo: number;
|
||||
numYn: string;
|
||||
dcptUnitCnt: number;
|
||||
useYn: string;
|
||||
expln?: string;
|
||||
sortSeq?: number;
|
||||
@@ -41,6 +43,8 @@ export interface CnstpSeMngSaveRequestDto {
|
||||
useYn?: string;
|
||||
expln?: string;
|
||||
sortSeq?: number;
|
||||
numYn?: string;
|
||||
dcptUnitCnt?: number;
|
||||
}
|
||||
|
||||
/** PUT /{cnstpSeCd} 요청 (mode는 서비스에서 U로 설정) */
|
||||
@@ -51,6 +55,8 @@ export interface CnstpSeMngPutRequestDto {
|
||||
cdLvNo?: number;
|
||||
useYn?: string;
|
||||
expln?: string;
|
||||
numYn?: string;
|
||||
dcptUnitCnt?: number;
|
||||
}
|
||||
|
||||
/** 백엔드 CnstpSeMngTreeDto */
|
||||
@@ -60,8 +66,11 @@ export interface CnstpSeMngTreeDto {
|
||||
cnstpSeCdNm: string;
|
||||
upCnstpCd?: string;
|
||||
useYn: string;
|
||||
numYn: string;
|
||||
dcptUnitCnt?: number;
|
||||
treeLevel: number;
|
||||
treePath: string;
|
||||
sortPath: string;
|
||||
}
|
||||
|
||||
/** 백엔드 BizSclTreeDto */
|
||||
@@ -70,8 +79,11 @@ export interface BizSclTreeDto {
|
||||
cnstpSeCd: string;
|
||||
cnstpSeCdNm: string;
|
||||
useYn: string;
|
||||
numYn: string;
|
||||
dcptUnitCnt?: number;
|
||||
treeLevel: number;
|
||||
treePath: string;
|
||||
sortPath: string;
|
||||
bscPlanQty?: number;
|
||||
bgtRfltQty?: number;
|
||||
tstmDesignQty?: number;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { dashboardService } from "./api/dashboard-service";
|
||||
export { dashboardBaseKey, useGetMainDashboard } from "./api/queries";
|
||||
export type {
|
||||
BasinDocPrgrsResponseDto,
|
||||
DashboardMainResponseDto,
|
||||
DashboardMainSearchDto,
|
||||
DashboardService,
|
||||
|
||||
@@ -18,6 +18,18 @@ export interface DocPrgrsSttsResponseDto {
|
||||
totCnt: number;
|
||||
}
|
||||
|
||||
/** 백엔드 BasinDocPrgrsResponseDto */
|
||||
export interface BasinDocPrgrsResponseDto {
|
||||
basinKey: string;
|
||||
basinNm: string;
|
||||
docSeComCd: string;
|
||||
docSeNm: string;
|
||||
reqCnt: number;
|
||||
prgrsCnt: number;
|
||||
cmptnCnt: number;
|
||||
totCnt: number;
|
||||
}
|
||||
|
||||
/** 백엔드 NotiAsUserResponseDto */
|
||||
export interface NotiAsUserResponseDto {
|
||||
notiId: number;
|
||||
@@ -48,6 +60,7 @@ export interface NotiAsStatResponseDto {
|
||||
/** 백엔드 DashboardMainResponseDto */
|
||||
export interface DashboardMainResponseDto {
|
||||
docPrgrsSttsList: DocPrgrsSttsResponseDto[];
|
||||
basinDocPrgrsList: BasinDocPrgrsResponseDto[];
|
||||
notiList: NotiAsUserResponseDto[];
|
||||
notiStats: NotiAsStatResponseDto;
|
||||
}
|
||||
|
||||
@@ -103,6 +103,8 @@ function ElaprApprovalDialog({
|
||||
|
||||
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(cyclDocPrgrsList, currentUserUuid);
|
||||
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
|
||||
const canReject =
|
||||
currentUserPrgrs?.atrzSeComCd === "ATRZ03" || currentUserPrgrs?.atrzSeComCd === "ATRZ99";
|
||||
|
||||
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
|
||||
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
|
||||
@@ -392,9 +394,11 @@ function ElaprApprovalDialog({
|
||||
<Button onClick={handleApprove} disabled={isBusy}>
|
||||
결재
|
||||
</Button>
|
||||
<Button color="red" onClick={handleReject} disabled={isBusy}>
|
||||
반려
|
||||
</Button>
|
||||
{canReject && (
|
||||
<Button color="red" onClick={handleReject} disabled={isBusy}>
|
||||
반려
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { handleTusUpload, type TusUploadOptions } from "./lib/utils";
|
||||
export { handleTusUpload, type TusErrorResponse, type TusUploadOptions } from "./lib/utils";
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { Upload } from "tus-js-client";
|
||||
import { type DetailedError, Upload } from "tus-js-client";
|
||||
|
||||
import { ENDPOINT, PROXY_HOST } from "@/shared/config";
|
||||
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config/env";
|
||||
import { getCookie } from "@/shared/lib";
|
||||
|
||||
interface TusFieldError {
|
||||
message: string;
|
||||
field: string;
|
||||
rejectedValue: string;
|
||||
}
|
||||
|
||||
interface TusErrorResponse {
|
||||
timestamp: number[];
|
||||
message: string;
|
||||
status: number;
|
||||
code: string;
|
||||
errors: TusFieldError[];
|
||||
}
|
||||
|
||||
interface TusUploadOptions {
|
||||
metadata?: Record<string, string>;
|
||||
handleUploadStart?: (upload: Upload) => void;
|
||||
handleProgress?: (percentage: number) => void;
|
||||
handleSuccess?: (upload: Upload, atflCd: string, atflId: string) => void;
|
||||
handleError?: (error: Error) => void;
|
||||
handleError?: (error: Error, response: TusErrorResponse | null) => void;
|
||||
}
|
||||
|
||||
function handleTusUpload(file: File, options?: TusUploadOptions) {
|
||||
@@ -32,7 +46,7 @@ function handleTusUpload(file: File, options?: TusUploadOptions) {
|
||||
metadata,
|
||||
onError(error) {
|
||||
console.error("TUS upload failed:", error);
|
||||
options?.handleError?.(error);
|
||||
options?.handleError?.(error, parseTusErrorResponse(error));
|
||||
},
|
||||
onProgress(bytesUploaded, bytesTotal) {
|
||||
const percentage = (bytesUploaded / bytesTotal) * 100;
|
||||
@@ -57,5 +71,22 @@ function handleTusUpload(file: File, options?: TusUploadOptions) {
|
||||
return upload;
|
||||
}
|
||||
|
||||
function parseTusErrorResponse(error: Error | DetailedError): TusErrorResponse | null {
|
||||
if (!("originalResponse" in error)) return null;
|
||||
|
||||
const body = error.originalResponse?.getBody();
|
||||
if (!body) return null;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body);
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
if (typeof (parsed as TusErrorResponse).message !== "string") return null;
|
||||
|
||||
return parsed as TusErrorResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { handleTusUpload };
|
||||
export type { TusUploadOptions };
|
||||
export type { TusErrorResponse, TusUploadOptions };
|
||||
|
||||
@@ -21,6 +21,8 @@ const buildSavePayload = createSavePayloadBuilder<CnstpSeRow, CnstpSeMngSaveRequ
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
sortSeq: row.sortSeq,
|
||||
numYn: row.numYn,
|
||||
dcptUnitCnt: row.dcptUnitCnt,
|
||||
}),
|
||||
toCreateDto: (row) => ({
|
||||
mode: CNSTP_SE_SAVE_MODE.CREATE,
|
||||
@@ -31,6 +33,8 @@ const buildSavePayload = createSavePayloadBuilder<CnstpSeRow, CnstpSeMngSaveRequ
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
sortSeq: row.sortSeq,
|
||||
numYn: row.numYn,
|
||||
dcptUnitCnt: row.dcptUnitCnt,
|
||||
}),
|
||||
toUpdateDto: (row) => ({
|
||||
mode: CNSTP_SE_SAVE_MODE.UPDATE,
|
||||
@@ -41,6 +45,8 @@ const buildSavePayload = createSavePayloadBuilder<CnstpSeRow, CnstpSeMngSaveRequ
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
sortSeq: row.sortSeq,
|
||||
numYn: row.numYn,
|
||||
dcptUnitCnt: row.dcptUnitCnt,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -62,6 +68,8 @@ export const cnstpGridConfig: SeGridConfig<
|
||||
cnstpSeCd: "",
|
||||
cnstpSeCdNm: "",
|
||||
expln: "",
|
||||
numYn: "Y",
|
||||
dcptUnitCnt: 0,
|
||||
useYn: "Y",
|
||||
}),
|
||||
applyDraftPatch: (draft, patch) => ({
|
||||
@@ -69,6 +77,8 @@ export const cnstpGridConfig: SeGridConfig<
|
||||
...(patch.cnstpSeCd !== undefined ? { cnstpSeCd: patch.cnstpSeCd } : {}),
|
||||
...(patch.cnstpSeCdNm !== undefined ? { cnstpSeCdNm: patch.cnstpSeCdNm ?? "" } : {}),
|
||||
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
|
||||
...(patch.numYn !== undefined ? { numYn: patch.numYn } : {}),
|
||||
...(patch.dcptUnitCnt !== undefined ? { dcptUnitCnt: patch.dcptUnitCnt } : {}),
|
||||
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
|
||||
...(patch.sortSeq !== undefined ? { sortSeq: patch.sortSeq } : {}),
|
||||
}),
|
||||
@@ -77,6 +87,8 @@ export const cnstpGridConfig: SeGridConfig<
|
||||
cnstpSeCd: draft.cnstpSeCd,
|
||||
cnstpSeCdNm: draft.cnstpSeCdNm,
|
||||
expln: draft.expln,
|
||||
numYn: draft.numYn,
|
||||
dcptUnitCnt: draft.dcptUnitCnt,
|
||||
useYn: draft.useYn,
|
||||
cdLvNo: draft.cdLvNo,
|
||||
upCnstpCd: parentCode,
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface CnstpSeDraft {
|
||||
cnstpSeCd: string;
|
||||
cnstpSeCdNm: string;
|
||||
expln: string;
|
||||
numYn: string;
|
||||
dcptUnitCnt: number;
|
||||
useYn: string;
|
||||
sortSeq?: number;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ function CnstpCodeTabPanel() {
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>코드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>숫자 여부</TableHead>
|
||||
<TableHead>소숫점 단위</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>정렬순서</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
|
||||
@@ -62,6 +62,33 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
|
||||
maxLength={1000}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<RadioGroup
|
||||
value={row.numYn === "N" ? "N" : "Y"}
|
||||
onValueChange={(v) => onChange({ numYn: v })}
|
||||
className="flex justify-center gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="Y" id={`${row.rowKey}-num-y`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="N" id={`${row.rowKey}-num-n`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={row.dcptUnitCnt ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ dcptUnitCnt: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
className="w-20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<RadioGroup
|
||||
value={row.useYn === "N" ? "N" : "Y"}
|
||||
@@ -89,7 +116,9 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
|
||||
className="w-20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">{levelLabel}</TableCell>
|
||||
<TableCell align="center" className="whitespace-nowrap">
|
||||
{levelLabel}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { BasinDocPrgrsResponseDto } from "@/entities/dashboard";
|
||||
|
||||
import {
|
||||
BASIN_DATA,
|
||||
BASIN_DOC_SE_SLOTS,
|
||||
BASIN_IDS,
|
||||
type BasinKey,
|
||||
MANUAL_LABEL_POS,
|
||||
@@ -15,8 +18,6 @@ const POPUP_EDGE_PADDING_PX = 12;
|
||||
const LABEL_OFFSET_Y_PX = 18;
|
||||
const DEFAULT_VIEWBOX = "0 0 100 100";
|
||||
|
||||
export const PLACEHOLDER_POPUP_ITEMS = [{ header: "현황", data: "-" }] as const;
|
||||
|
||||
export interface MapPopupItem {
|
||||
header: string;
|
||||
data: string;
|
||||
@@ -27,10 +28,28 @@ export interface MapPopupContent {
|
||||
items: MapPopupItem[];
|
||||
}
|
||||
|
||||
const INITIAL_POPUP_CONTENT: MapPopupContent = {
|
||||
title: "",
|
||||
items: [...PLACEHOLDER_POPUP_ITEMS],
|
||||
};
|
||||
interface ActiveBasin {
|
||||
key: string | undefined;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const INITIAL_ACTIVE_BASIN: ActiveBasin = { key: undefined, title: "" };
|
||||
|
||||
function buildPopupItems(
|
||||
basinDocPrgrsList: BasinDocPrgrsResponseDto[] | undefined,
|
||||
basinKey: string | undefined,
|
||||
): MapPopupItem[] {
|
||||
const itemByCd = new Map(
|
||||
basinDocPrgrsList
|
||||
?.filter((item) => item.basinKey === basinKey)
|
||||
.map((item) => [item.docSeComCd, item]),
|
||||
);
|
||||
|
||||
return BASIN_DOC_SE_SLOTS.map((slot) => {
|
||||
const item = itemByCd.get(slot.docSeComCd);
|
||||
return { header: slot.label, data: item ? String(item.totCnt) : "-" };
|
||||
});
|
||||
}
|
||||
|
||||
function isBasinKey(key: string | undefined): key is BasinKey {
|
||||
return Boolean(key && key in BASIN_DATA);
|
||||
@@ -172,22 +191,25 @@ function positionLabels(
|
||||
});
|
||||
}
|
||||
|
||||
export function useNationalAsMap() {
|
||||
export function useNationalAsMap(basinDocPrgrsList: BasinDocPrgrsResponseDto[] | undefined) {
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const svgLayerRef = useRef<HTMLDivElement>(null);
|
||||
const labelsLayerRef = useRef<HTMLDivElement>(null);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
const [popupContent, setPopupContent] = useState<MapPopupContent>(INITIAL_POPUP_CONTENT);
|
||||
const [activeBasin, setActiveBasin] = useState<ActiveBasin>(INITIAL_ACTIVE_BASIN);
|
||||
const popupContent: MapPopupContent = {
|
||||
title: activeBasin.title,
|
||||
items: buildPopupItems(basinDocPrgrsList, activeBasin.key),
|
||||
};
|
||||
|
||||
const showFor = useCallback((group: SVGGElement) => {
|
||||
const stage = stageRef.current;
|
||||
const popup = popupRef.current;
|
||||
if (!stage || !popup) return;
|
||||
|
||||
setPopupContent({
|
||||
title: resolveBasinName(group),
|
||||
items: [...PLACEHOLDER_POPUP_ITEMS],
|
||||
});
|
||||
const key = group.dataset.key;
|
||||
const title = resolveBasinName(group);
|
||||
setActiveBasin((prev) => (prev.key === key && prev.title === title ? prev : { key, title }));
|
||||
showPopupAtGroup(stage, popup, group);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ export const BASIN_IDS = [
|
||||
{ key: "yeongsan", labels: ["영산강", "yeongsan"] },
|
||||
] as const;
|
||||
|
||||
export const BASIN_DOC_SE_SLOTS = [
|
||||
{ docSeComCd: "FNRS", label: "재원협의" },
|
||||
{ docSeComCd: "INSTL", label: "설치인가" },
|
||||
{ docSeComCd: "CNFM", label: "설계자문" },
|
||||
{ docSeComCd: "SEWER", label: "기본계획" },
|
||||
] as const;
|
||||
|
||||
export type BasinKey = keyof typeof BASIN_DATA;
|
||||
|
||||
export interface ManualLabelPos {
|
||||
|
||||
@@ -109,11 +109,11 @@ interface DashboardNotiRowProps {
|
||||
|
||||
function DashboardNotiRow({ row, isActionPending, onAction }: DashboardNotiRowProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 border-b px-2 py-4 last:border-b-0">
|
||||
<span className="shrink-0 text-lg text-muted-foreground">
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-4 border-b px-2 py-4 last:border-b-0">
|
||||
<span className="text-lg text-muted-foreground">
|
||||
{row.regDt ? dayjs(row.regDt).format("YYYY-MM-DD") : "-"}
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 truncate text-xl">{row.notiTtl}</p>
|
||||
<p className="min-w-0 line-clamp-2 text-xl sm:line-clamp-1">{row.notiTtl}</p>
|
||||
<Button type="button" size="sm" color="white" disabled={isActionPending} onClick={onAction}>
|
||||
처리하기
|
||||
</Button>
|
||||
|
||||
@@ -12,8 +12,8 @@ export function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex gap-4 px-[1.8rem] pb-6">
|
||||
<div className="flex-[0.5]">
|
||||
<NationalAsMap />
|
||||
<div className="hidden flex-[0.5] sm:block">
|
||||
<NationalAsMap basinDocPrgrsList={data?.basinDocPrgrsList} />
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<DocPrgrsSttsCards items={data?.docPrgrsSttsList} isPending={isPending} isError={isError} />
|
||||
|
||||
@@ -33,7 +33,7 @@ export function DocPrgrsSttsCards({ items, isPending, isError }: DocPrgrsSttsCar
|
||||
const itemByCd = new Map(items?.map((item) => [item.docSeComCd, item]));
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{DOC_PRGRS_STTS_SLOTS.map((slot) => {
|
||||
const item = itemByCd.get(slot.docSeComCd);
|
||||
return (
|
||||
@@ -97,8 +97,8 @@ function DocPrgrsSttsMetric({
|
||||
}
|
||||
|
||||
const DOC_PRGRS_STTS_SLOTS = [
|
||||
{ docSeComCd: "FNRS", title: "재원협의 진행상황", href: "/fnrs-cnsltn" },
|
||||
{ docSeComCd: "INSTL", title: "설치인가 진행상황", href: "/instl-sncn" },
|
||||
{ docSeComCd: "FNRS", title: "재원협의 진행상황", href: "/fnrs-cnsltn" },
|
||||
{ docSeComCd: "CNFM", title: "설계자문 진행상황", href: "/design-cnstn" },
|
||||
{ docSeComCd: "SEWER", title: "기본계획 진행상황", href: "/sewer-bsc-plan" },
|
||||
] as const;
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { BasinDocPrgrsResponseDto } from "@/entities/dashboard";
|
||||
|
||||
import { useNationalAsMap } from "../lib/use-national-as-map";
|
||||
|
||||
import styles from "../lib/national-as-map.module.css";
|
||||
|
||||
export function NationalAsMap() {
|
||||
interface NationalAsMapProps {
|
||||
basinDocPrgrsList: BasinDocPrgrsResponseDto[] | undefined;
|
||||
}
|
||||
|
||||
export function NationalAsMap({ basinDocPrgrsList }: NationalAsMapProps) {
|
||||
const { stageRef, svgLayerRef, labelsLayerRef, popupRef, popupContent, popupGridStyle } =
|
||||
useNationalAsMap();
|
||||
useNationalAsMap(basinDocPrgrsList);
|
||||
const popupColumnSpan = Math.max(1, popupContent.items.length);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,8 +9,14 @@ import {
|
||||
export const designCnstnRegistSchema = z.object({
|
||||
designCnstnMng: designCnstnMngSaveSchema.extend({
|
||||
bizNm: z.string().min(1, "사업명은 필수입니다"),
|
||||
bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"),
|
||||
bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"),
|
||||
bizBgngYr: z
|
||||
.string()
|
||||
.min(1, "사업시작연도는 필수입니다")
|
||||
.regex(/^\d{4}$/, "연도는 4자리 숫자여야 합니다"),
|
||||
bizEndYr: z
|
||||
.string()
|
||||
.min(1, "사업종료연도는 필수입니다")
|
||||
.regex(/^\d{4}$/, "연도는 4자리 숫자여야 합니다"),
|
||||
bizCn: z.string().min(1, "사업내용은 필수입니다"),
|
||||
taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
|
||||
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
|
||||
|
||||
@@ -17,8 +17,8 @@ const MAIN_STEP_LABELS = {
|
||||
} as const;
|
||||
|
||||
export const PROGRESS_STATUS_COLUMNS = [
|
||||
{ label: "설계자문 신청 (시군)", sttsComCd: "ER" },
|
||||
{ label: "기술검토", sttsComCd: "TE" },
|
||||
{ label: "설계자문 신청 (시군)", sttsComCd: "CT" },
|
||||
{ label: "기술검토", sttsComCd: "CS" },
|
||||
{ label: "기술검토 회신", sttsComCd: "CP" },
|
||||
];
|
||||
|
||||
@@ -38,7 +38,7 @@ function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") {
|
||||
if (prgrsSttsComCd === "CT" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
|
||||
@@ -61,6 +61,13 @@ function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
|
||||
];
|
||||
}
|
||||
|
||||
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
|
||||
if (prgrsSttsComCd === "SL") {
|
||||
return <Badge color="orange">보완요청</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
@@ -110,7 +117,11 @@ export function createDesignCnstnColumns() {
|
||||
columnHelper.accessor("bizNm", {
|
||||
header: "사업명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
cell: ({ row, getValue }) => (
|
||||
<>
|
||||
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("taskSeCdNm", {
|
||||
header: "업무구분",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
|
||||
import {
|
||||
type DesignCnstnApplySaveRequestDto,
|
||||
designCnstnKeys,
|
||||
@@ -103,11 +103,11 @@ function FloatingActionButtons({
|
||||
isRegister && actionButtons.some((button) => button === DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg);
|
||||
|
||||
function handleGoToList() {
|
||||
router.push("/design-cnstn");
|
||||
router.push(getListHref("/design-cnstn"));
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
router.push(`/design-cnstn/${designCnstnDocNo}/edit`);
|
||||
router.push(withListQs(`/design-cnstn/${designCnstnDocNo}/edit`));
|
||||
}
|
||||
|
||||
function shouldShowButton(action: DesignCnstnViewButtonAction) {
|
||||
@@ -177,7 +177,7 @@ function FloatingActionButtons({
|
||||
action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.del,
|
||||
successMessage: "삭제되었습니다.",
|
||||
});
|
||||
router.push("/design-cnstn");
|
||||
router.push(getListHref("/design-cnstn"));
|
||||
}
|
||||
|
||||
function getReviewOpinionFormData() {
|
||||
|
||||
@@ -8,8 +8,8 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
||||
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
|
||||
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
||||
import {
|
||||
designCnstnKeys,
|
||||
type DesignCnstnMngDetResponseDto,
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
} from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { type DesignCnstnRegistFormData, designCnstnRegistSchema } from "../lib/design-cnstn-regist-schema";
|
||||
import {
|
||||
type DesignCnstnRegistFormData,
|
||||
designCnstnRegistSchema,
|
||||
} from "../lib/design-cnstn-regist-schema";
|
||||
import {
|
||||
buildFormPayload,
|
||||
getCreateDefaultValues,
|
||||
@@ -91,7 +94,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
|
||||
|
||||
alert("저장되었습니다.");
|
||||
queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
|
||||
router.push(`/design-cnstn/${designCnstnDocNo}/edit`);
|
||||
router.push(withListQs(`/design-cnstn/${designCnstnDocNo}/edit`));
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
alert("저장 중 오류가 발생했습니다.");
|
||||
@@ -103,6 +106,11 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
|
||||
async function onApply(data: DesignCnstnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (data.privacyConsent !== "Y") {
|
||||
alert("개인정보 수집/이용에 동의해야 신청이 가능합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) {
|
||||
alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다.");
|
||||
return;
|
||||
@@ -115,7 +123,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
|
||||
|
||||
alert("신청 완료되었습니다.");
|
||||
queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
|
||||
router.push(`/design-cnstn/${designCnstnDocNo}`);
|
||||
router.push(withListQs(`/design-cnstn/${designCnstnDocNo}`));
|
||||
} catch (error) {
|
||||
console.error("신청 실패:", error);
|
||||
alert("신청 중 오류가 발생했습니다.");
|
||||
@@ -144,7 +152,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
|
||||
await designCnstnMngService.delete(docNo);
|
||||
alert("삭제되었습니다.");
|
||||
queryClient.invalidateQueries({ queryKey: designCnstnKeys.list() });
|
||||
router.push("/design-cnstn");
|
||||
router.push(getListHref("/design-cnstn"));
|
||||
} catch (error) {
|
||||
console.error("삭제 실패:", error);
|
||||
alert("삭제 중 오류가 발생했습니다.");
|
||||
@@ -184,7 +192,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
|
||||
<Button
|
||||
color="white"
|
||||
type="button"
|
||||
onClick={() => router.push("/design-cnstn")}
|
||||
onClick={() => router.push(getListHref("/design-cnstn"))}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
목록
|
||||
@@ -217,7 +225,6 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
|
||||
</div>
|
||||
</SectionCollapsibleGroup>
|
||||
</form>
|
||||
<RhfDevTool control={form.control} />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
type CmCdAdstDstrComboResponseDto,
|
||||
useFindGugunCombo,
|
||||
useFindSidoCombo,
|
||||
} from "@/entities/adstdstr";
|
||||
import { useSearchParamsState } from "@/shared/lib";
|
||||
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import type { DesignCnstnMngSearchDto } from "@/entities/design-cnstn";
|
||||
import {
|
||||
@@ -27,48 +24,58 @@ import { DesignList } from "./design-list";
|
||||
const DEFAULT_PAGE_INDEX = 0;
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
const DEFAULT_SORT = "designCnstnDocNo,desc";
|
||||
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
|
||||
|
||||
const DEFAULT_SEARCH_PARAMS: DesignCnstnMngSearchDto = {
|
||||
bizNm: "",
|
||||
taskSeComCd: "",
|
||||
admdstCd: "",
|
||||
page: DEFAULT_PAGE_INDEX,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
sort: DEFAULT_SORT,
|
||||
};
|
||||
|
||||
const ALL_SELECT_VALUE = "__ALL__";
|
||||
|
||||
function DesignListPage() {
|
||||
const [bizNm, setBizNm] = useState("");
|
||||
const [taskSeComCd, setTaskSeComCd] = useState("");
|
||||
const [sidoComCd, setSidoComCd] = useState("");
|
||||
const [sggComCd, setSggComCd] = useState("");
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: DEFAULT_PAGE_INDEX,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const [searchParams, setSearchParams] = useState<DesignCnstnMngSearchDto>(DEFAULT_SEARCH_PARAMS);
|
||||
const DEFAULT_PARAMS = {
|
||||
sidoComCd: "",
|
||||
sggComCd: "",
|
||||
taskSeComCd: "",
|
||||
bizNm: "",
|
||||
page: DEFAULT_PAGE_INDEX,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
};
|
||||
|
||||
const { data: sidoData } = useFindSidoCombo();
|
||||
const { data: gugunData } = useFindGugunCombo(sidoComCd);
|
||||
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
|
||||
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
|
||||
function DesignListPage() {
|
||||
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
|
||||
const [bizNm, setBizNm] = useState(params.bizNm);
|
||||
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
|
||||
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
|
||||
const [sggComCd, setSggComCd] = useState(params.sggComCd);
|
||||
|
||||
const { userSidoData, userGugunData } = useFindUserSidoGugunCombo(sidoComCd);
|
||||
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: params.page,
|
||||
pageSize: params.size,
|
||||
};
|
||||
|
||||
const searchParams: DesignCnstnMngSearchDto = useMemo(
|
||||
() => ({
|
||||
bizNm: params.bizNm,
|
||||
taskSeComCd: params.taskSeComCd,
|
||||
admdstCd: params.sggComCd || params.sidoComCd || "",
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
sort: DEFAULT_SORT,
|
||||
}),
|
||||
[params],
|
||||
);
|
||||
|
||||
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
|
||||
setSearchParams({
|
||||
...DEFAULT_SEARCH_PARAMS,
|
||||
setParams({
|
||||
bizNm,
|
||||
taskSeComCd,
|
||||
admdstCd: sggComCd || sidoComCd || "",
|
||||
sidoComCd,
|
||||
sggComCd,
|
||||
page: DEFAULT_PAGE_INDEX,
|
||||
size: pagination.pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePaginationChange = (next: PaginationState) => {
|
||||
setParams({ page: next.pageIndex, size: next.pageSize });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background">
|
||||
@@ -89,7 +96,7 @@ function DesignListPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
|
||||
{sidoOptions.map((option) => (
|
||||
{userSidoData.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
@@ -111,7 +118,7 @@ function DesignListPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
|
||||
{gugunOptions.map((option) => (
|
||||
{userGugunData.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
@@ -143,7 +150,7 @@ function DesignListPage() {
|
||||
<DesignList
|
||||
searchParams={searchParams}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useDataTable, withListQs } from "@/shared/lib";
|
||||
import {
|
||||
type DesignCnstnMngResponseDto,
|
||||
type DesignCnstnMngSearchDto,
|
||||
@@ -43,7 +43,7 @@ function DesignList({ searchParams, pagination, onPaginationChange }: DesignList
|
||||
const columns = useMemo(() => createDesignCnstnColumns(), []);
|
||||
|
||||
const handleRowClick = (row: DesignCnstnMngResponseDto) => {
|
||||
router.push(`/design-cnstn/${row.designCnstnDocNo}`);
|
||||
router.push(withListQs(`/design-cnstn/${row.designCnstnDocNo}`));
|
||||
};
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
|
||||
@@ -83,7 +83,9 @@ function DesignViewPage({ id }: DesignViewPageProps) {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<SectionCollapsibleGroup>
|
||||
<SectionCollapsibleToggle />
|
||||
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
|
||||
<SectionCollapsibleToggle />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<ProgressStatusSection
|
||||
columns={PROGRESS_STATUS_COLUMNS}
|
||||
|
||||
@@ -3,19 +3,12 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldError, FieldGroup } from "@/shared/ui/field";
|
||||
import { findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
import { Input, Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema";
|
||||
|
||||
@@ -69,9 +62,9 @@ function BasicInfoFormSection() {
|
||||
aria-invalid={fieldState.invalid}
|
||||
className="w-[40rem]"
|
||||
/>
|
||||
<Button type="button" onClick={() => setIsPickerOpen(true)}>
|
||||
{/* <Button type="button" onClick={() => setIsPickerOpen(true)}>
|
||||
사업찾기
|
||||
</Button>
|
||||
</Button> */}
|
||||
</div>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
@@ -87,9 +80,11 @@ function BasicInfoFormSection() {
|
||||
name="designCnstnMng.bizBgngYr"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto">
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto !gap-1">
|
||||
<Input
|
||||
{...field}
|
||||
{...NON_NEGATIVE_INPUT_PROPS}
|
||||
type="number"
|
||||
id={field.name}
|
||||
placeholder="시작연도"
|
||||
className="max-w-[12rem]"
|
||||
@@ -104,9 +99,11 @@ function BasicInfoFormSection() {
|
||||
name="designCnstnMng.bizEndYr"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto">
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto !gap-1">
|
||||
<Input
|
||||
{...field}
|
||||
{...NON_NEGATIVE_INPUT_PROPS}
|
||||
type="number"
|
||||
id={field.name}
|
||||
placeholder="종료연도"
|
||||
className="max-w-[12rem]"
|
||||
|
||||
@@ -45,7 +45,7 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, FnrsCnsltnViewBu
|
||||
},
|
||||
ROLE_USER04: {
|
||||
ST: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct],
|
||||
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv, FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
|
||||
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv],
|
||||
},
|
||||
ROLE_USER03: {
|
||||
LE: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw],
|
||||
|
||||
@@ -13,8 +13,14 @@ import {
|
||||
export const fnrsCnsltnRegistSchema = z.object({
|
||||
fnrsCnsltnMng: fnrsCnsltnMngSaveSchema.extend({
|
||||
bizNm: z.string().min(1, "사업명은 필수입니다"),
|
||||
bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"),
|
||||
bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"),
|
||||
bizBgngYr: z
|
||||
.string()
|
||||
.min(1, "사업시작연도는 필수입니다")
|
||||
.regex(/^\d{4}$/, "연도는 4자리 숫자여야 합니다"),
|
||||
bizEndYr: z
|
||||
.string()
|
||||
.min(1, "사업종료연도는 필수입니다")
|
||||
.regex(/^\d{4}$/, "연도는 4자리 숫자여야 합니다"),
|
||||
bizCn: z.string().min(1, "사업내용은 필수입니다"),
|
||||
taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
|
||||
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
|
||||
|
||||
@@ -102,6 +102,13 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
|
||||
];
|
||||
}
|
||||
|
||||
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
|
||||
if (prgrsSttsComCd === "SL") {
|
||||
return <Badge color="orange">보완요청</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
@@ -152,7 +159,11 @@ export function createFnrsCnsltnColumns() {
|
||||
columnHelper.accessor("bizNm", {
|
||||
header: "사업명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
cell: ({ row, getValue }) => (
|
||||
<>
|
||||
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("taskSeCdNm", {
|
||||
header: "업무구분",
|
||||
|
||||
@@ -48,8 +48,8 @@ export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
|
||||
|
||||
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
|
||||
LOCAL: "자가체크",
|
||||
ENV: "기술검토",
|
||||
KECO: "환경청검토",
|
||||
ENV: "환경청검토",
|
||||
KECO: "기술검토",
|
||||
};
|
||||
|
||||
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
|
||||
|
||||
@@ -145,7 +145,7 @@ function FileVrfcSplmntDialog({
|
||||
<DialogTitle>파일보완</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">{tableComponent}</div>
|
||||
<div className="max-h-[70rem] space-y-3 overflow-y-auto">{tableComponent}</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={onClose} disabled={isSubmitting}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
|
||||
import {
|
||||
AI_AUTO_SE,
|
||||
type AiAutoPrcsLatestResponseDto,
|
||||
@@ -97,7 +97,6 @@ type ActiveDialog =
|
||||
| "elaprTmplt"
|
||||
| "elaprDocPrgrs"
|
||||
| "rjct"
|
||||
| "dmndSplmnt"
|
||||
| "fileSplmnt"
|
||||
| "elaprApproval"
|
||||
| "pwrkCnsltnDocMng"
|
||||
@@ -124,6 +123,7 @@ function FloatingActionButtons({
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [activeDialog, setActiveDialog] = useState<ActiveDialog>(null);
|
||||
const [isDmndSplmntDialogOpen, setIsDmndSplmntDialogOpen] = useState(false);
|
||||
|
||||
const docNo = data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo;
|
||||
const isRegister = data.detDto?.rgtr === userInfo?.userUnqId;
|
||||
@@ -217,13 +217,17 @@ function FloatingActionButtons({
|
||||
|
||||
const canEdit =
|
||||
isRegister && actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg);
|
||||
const canDmndSplmntInPwrkCnsltnDoc =
|
||||
prgrsSttsComCd === "RK" &&
|
||||
atrzSeComCdList.includes("ATRZ02") &&
|
||||
actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt);
|
||||
|
||||
function handleGoToList() {
|
||||
router.push("/fnrs-cnsltn");
|
||||
router.push(getListHref("/fnrs-cnsltn"));
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
|
||||
router.push(withListQs(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`));
|
||||
}
|
||||
|
||||
function shouldShowButton(action: FnrsCnsltnViewButtonAction) {
|
||||
@@ -314,7 +318,7 @@ function FloatingActionButtons({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.del,
|
||||
successMessage: "삭제되었습니다.",
|
||||
});
|
||||
router.push("/fnrs-cnsltn");
|
||||
router.push(getListHref("/fnrs-cnsltn"));
|
||||
}
|
||||
|
||||
async function handleEnvoSbmsnClick() {
|
||||
@@ -533,6 +537,7 @@ function FloatingActionButtons({
|
||||
}
|
||||
|
||||
async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
|
||||
setIsDmndSplmntDialogOpen(false);
|
||||
setActiveDialog(null);
|
||||
const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
|
||||
await executeAction({
|
||||
@@ -763,7 +768,7 @@ function FloatingActionButtons({
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
|
||||
<Button
|
||||
color="gray"
|
||||
onClick={() => setActiveDialog("dmndSplmnt")}
|
||||
onClick={() => setIsDmndSplmntDialogOpen(true)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
보완 요청
|
||||
@@ -802,8 +807,8 @@ function FloatingActionButtons({
|
||||
|
||||
<SplmntDmndDialog
|
||||
splmntArtclUpCd="SPLMNTARTCL"
|
||||
open={activeDialog === "dmndSplmnt"}
|
||||
onOpenChange={() => setActiveDialog(null)}
|
||||
open={isDmndSplmntDialogOpen}
|
||||
onOpenChange={() => setIsDmndSplmntDialogOpen(false)}
|
||||
isSubmitting={isBusy}
|
||||
onConfirm={handleDmndSplmntConfirm}
|
||||
/>
|
||||
@@ -839,6 +844,9 @@ function FloatingActionButtons({
|
||||
? false
|
||||
: null
|
||||
}
|
||||
onDmndSplmnt={
|
||||
canDmndSplmntInPwrkCnsltnDoc ? () => setIsDmndSplmntDialogOpen(true) : undefined
|
||||
}
|
||||
onClose={() => setActiveDialog(null)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDo
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={onClose} disabled={isPending}>
|
||||
닫기
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
제출
|
||||
|
||||
@@ -58,6 +58,7 @@ interface PwrkCnsltnDocMngDialogProps {
|
||||
atrzSeComCdList?: string[];
|
||||
prgrsSttsComCd?: string;
|
||||
canSave?: boolean | null;
|
||||
onDmndSplmnt?: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -78,6 +79,7 @@ function PwrkCnsltnDocMngDialog({
|
||||
atrzSeComCdList,
|
||||
prgrsSttsComCd,
|
||||
canSave: canSaveProp,
|
||||
onDmndSplmnt,
|
||||
onClose,
|
||||
}: PwrkCnsltnDocMngDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -309,6 +311,7 @@ function PwrkCnsltnDocMngDialog({
|
||||
canSave={canSave}
|
||||
onSaveDocument={handleSaveDocument}
|
||||
onDownload={handleDownload}
|
||||
onDmndSplmnt={onDmndSplmnt}
|
||||
/>
|
||||
</div>
|
||||
{pjtcoAjmtPage?.content?.length ? (
|
||||
|
||||
@@ -8,8 +8,8 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
||||
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
|
||||
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
|
||||
import {
|
||||
@@ -153,7 +153,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
|
||||
alert("보완 완료 처리되었습니다.");
|
||||
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
router.push(`/fnrs-cnsltn/${docNo}`);
|
||||
router.push(withListQs(`/fnrs-cnsltn/${docNo}`));
|
||||
} catch (error) {
|
||||
console.error("보완 완료 실패:", error);
|
||||
alert("보완 완료 처리 중 오류가 발생했습니다.");
|
||||
@@ -172,7 +172,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
|
||||
alert("저장되었습니다.");
|
||||
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
|
||||
router.push(withListQs(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`));
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
alert("저장 중 오류가 발생했습니다.");
|
||||
@@ -206,7 +206,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
|
||||
alert("신청 완료되었습니다.");
|
||||
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`);
|
||||
router.push(withListQs(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`));
|
||||
} catch (error) {
|
||||
console.error("신청 실패:", error);
|
||||
alert("신청 중 오류가 발생했습니다.");
|
||||
@@ -235,7 +235,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
await fnrsCnsltnMngService.remove(docNo);
|
||||
alert("삭제되었습니다.");
|
||||
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
router.push("/fnrs-cnsltn");
|
||||
router.push(getListHref("/fnrs-cnsltn"));
|
||||
} catch (error) {
|
||||
console.error("삭제 실패:", error);
|
||||
alert("삭제 중 오류가 발생했습니다.");
|
||||
@@ -285,7 +285,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
<Button
|
||||
color="white"
|
||||
type="button"
|
||||
onClick={() => router.push("/fnrs-cnsltn")}
|
||||
onClick={() => router.push(getListHref("/fnrs-cnsltn"))}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
목록
|
||||
@@ -303,7 +303,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
onClick={handleApplyClick}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
신청완료
|
||||
신청 완료
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
@@ -350,7 +350,6 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
onClose={() => setIsPwrkCnsltnDocMngOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
<RhfDevTool control={form.control} />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
type CmCdAdstDstrComboResponseDto,
|
||||
useFindGugunCombo,
|
||||
useFindSidoCombo,
|
||||
} from "@/entities/adstdstr";
|
||||
import { useSearchParamsState } from "@/shared/lib";
|
||||
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import type { FnrsCnsltnMngSearchDto } from "@/entities/fnrs-cnsltn";
|
||||
import {
|
||||
@@ -27,48 +24,58 @@ import { FnrsList } from "./fnrs-list";
|
||||
const DEFAULT_PAGE_INDEX = 0;
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
const DEFAULT_SORT = "fnrsCnsltnDocNo,desc";
|
||||
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
|
||||
|
||||
const DEFAULT_SEARCH_PARAMS: FnrsCnsltnMngSearchDto = {
|
||||
bizNm: "",
|
||||
taskSeComCd: "",
|
||||
admdstCd: "",
|
||||
page: DEFAULT_PAGE_INDEX,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
sort: DEFAULT_SORT,
|
||||
};
|
||||
|
||||
const ALL_SELECT_VALUE = "__ALL__";
|
||||
|
||||
function FnrsListPage() {
|
||||
const [bizNm, setBizNm] = useState("");
|
||||
const [taskSeComCd, setTaskSeComCd] = useState("");
|
||||
const [sidoComCd, setSidoComCd] = useState("");
|
||||
const [sggComCd, setSggComCd] = useState("");
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: DEFAULT_PAGE_INDEX,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const [searchParams, setSearchParams] = useState<FnrsCnsltnMngSearchDto>(DEFAULT_SEARCH_PARAMS);
|
||||
const DEFAULT_PARAMS = {
|
||||
sidoComCd: "",
|
||||
sggComCd: "",
|
||||
taskSeComCd: "",
|
||||
bizNm: "",
|
||||
page: DEFAULT_PAGE_INDEX,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
};
|
||||
|
||||
const { data: sidoData } = useFindSidoCombo();
|
||||
const { data: gugunData } = useFindGugunCombo(sidoComCd);
|
||||
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
|
||||
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
|
||||
function FnrsListPage() {
|
||||
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
|
||||
const [bizNm, setBizNm] = useState(params.bizNm);
|
||||
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
|
||||
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
|
||||
const [sggComCd, setSggComCd] = useState(params.sggComCd);
|
||||
|
||||
const { userSidoData, userGugunData } = useFindUserSidoGugunCombo(sidoComCd);
|
||||
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: params.page,
|
||||
pageSize: params.size,
|
||||
};
|
||||
|
||||
const searchParams: FnrsCnsltnMngSearchDto = useMemo(
|
||||
() => ({
|
||||
bizNm: params.bizNm,
|
||||
taskSeComCd: params.taskSeComCd,
|
||||
admdstCd: params.sggComCd || params.sidoComCd || "",
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
sort: DEFAULT_SORT,
|
||||
}),
|
||||
[params],
|
||||
);
|
||||
|
||||
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
|
||||
setSearchParams({
|
||||
...DEFAULT_SEARCH_PARAMS,
|
||||
setParams({
|
||||
bizNm,
|
||||
taskSeComCd,
|
||||
admdstCd: sggComCd || sidoComCd || "",
|
||||
sidoComCd,
|
||||
sggComCd,
|
||||
page: DEFAULT_PAGE_INDEX,
|
||||
size: pagination.pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePaginationChange = (next: PaginationState) => {
|
||||
setParams({ page: next.pageIndex, size: next.pageSize });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background">
|
||||
@@ -89,7 +96,7 @@ function FnrsListPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
|
||||
{sidoOptions.map((option) => (
|
||||
{userSidoData.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
@@ -111,7 +118,7 @@ function FnrsListPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
|
||||
{gugunOptions.map((option) => (
|
||||
{userGugunData.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
@@ -143,7 +150,7 @@ function FnrsListPage() {
|
||||
<FnrsList
|
||||
searchParams={searchParams}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useDataTable, withListQs } from "@/shared/lib";
|
||||
import {
|
||||
FnrsCnsltnMngResponseDto,
|
||||
type FnrsCnsltnMngSearchDto,
|
||||
@@ -43,7 +43,7 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
|
||||
const columns = useMemo(() => createFnrsCnsltnColumns(), []);
|
||||
|
||||
const handleRowClick = (row: FnrsCnsltnMngResponseDto) => {
|
||||
router.push(`/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`);
|
||||
router.push(withListQs(`/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`));
|
||||
};
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { findCodeLabel, useCodeList } from "@/entities/code";
|
||||
@@ -92,9 +93,11 @@ function BasicInfoFormSection() {
|
||||
name="fnrsCnsltnMng.bizBgngYr"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto">
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto !gap-1">
|
||||
<Input
|
||||
{...field}
|
||||
{...NON_NEGATIVE_INPUT_PROPS}
|
||||
type="number"
|
||||
id={field.name}
|
||||
placeholder="시작연도"
|
||||
className="max-w-[12rem]"
|
||||
@@ -109,9 +112,11 @@ function BasicInfoFormSection() {
|
||||
name="fnrsCnsltnMng.bizEndYr"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto">
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto !gap-1">
|
||||
<Input
|
||||
{...field}
|
||||
{...NON_NEGATIVE_INPUT_PROPS}
|
||||
type="number"
|
||||
id={field.name}
|
||||
placeholder="종료연도"
|
||||
className="max-w-[12rem]"
|
||||
|
||||
@@ -12,8 +12,14 @@ import {
|
||||
export const instlSncnRegistSchema = z.object({
|
||||
instlSncnMng: instlSncnMngSaveSchema.extend({
|
||||
bizNm: z.string().min(1, "사업명은 필수입니다"),
|
||||
bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"),
|
||||
bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"),
|
||||
bizBgngYr: z
|
||||
.string()
|
||||
.min(1, "사업시작연도는 필수입니다")
|
||||
.regex(/^\d{4}$/, "연도는 4자리 숫자여야 합니다"),
|
||||
bizEndYr: z
|
||||
.string()
|
||||
.min(1, "사업종료연도는 필수입니다")
|
||||
.regex(/^\d{4}$/, "연도는 4자리 숫자여야 합니다"),
|
||||
bizCn: z.string().min(1, "사업내용은 필수입니다"),
|
||||
taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
|
||||
sncnSeComCd: z.string().min(1, "사업구분은 필수입니다"),
|
||||
|
||||
@@ -103,6 +103,13 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
|
||||
];
|
||||
}
|
||||
|
||||
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
|
||||
if (prgrsSttsComCd === "SL") {
|
||||
return <Badge color="orange">보완요청</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
@@ -153,7 +160,11 @@ export function createInstlSncnColumns() {
|
||||
columnHelper.accessor("bizNm", {
|
||||
header: "사업명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
cell: ({ row, getValue }) => (
|
||||
<>
|
||||
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("taskSeCdNm", {
|
||||
header: "업무구분",
|
||||
|
||||
@@ -50,8 +50,8 @@ export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
|
||||
|
||||
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
|
||||
LOCAL: "자가체크",
|
||||
ENV: "기술검토",
|
||||
KECO: "환경청검토",
|
||||
ENV: "환경청검토",
|
||||
KECO: "기술검토",
|
||||
};
|
||||
|
||||
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
|
||||
import {
|
||||
buildElaprPrgrsSttsListFromAplnRows,
|
||||
ElaprApprovalDialog,
|
||||
@@ -114,11 +114,11 @@ function FloatingActionButtons({
|
||||
isRegister && actionButtons.some((button) => button === INSTL_SNCN_VIEW_BUTTON_ACTION.strg);
|
||||
|
||||
function handleGoToList() {
|
||||
router.push("/instl-sncn");
|
||||
router.push(getListHref("/instl-sncn"));
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
router.push(`/instl-sncn/${instlSncnDocNo}/edit`);
|
||||
router.push(withListQs(`/instl-sncn/${instlSncnDocNo}/edit`));
|
||||
}
|
||||
|
||||
function shouldShowButton(action: InstlSncnViewButtonAction) {
|
||||
@@ -212,7 +212,7 @@ function FloatingActionButtons({
|
||||
action: INSTL_SNCN_VIEW_BUTTON_ACTION.del,
|
||||
successMessage: "삭제되었습니다.",
|
||||
});
|
||||
router.push("/instl-sncn");
|
||||
router.push(getListHref("/instl-sncn"));
|
||||
}
|
||||
|
||||
async function handleInstlSncnAncmntClick() {
|
||||
|
||||
@@ -8,8 +8,8 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
||||
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
|
||||
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
||||
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
|
||||
import {
|
||||
instlSncnKeys,
|
||||
@@ -30,8 +30,15 @@ import {
|
||||
} from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { buildFormPayload, getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
|
||||
import { type InstlSncnRegistFormData, instlSncnRegistSchema } from "../lib/instl-sncn-regist-schema";
|
||||
import {
|
||||
buildFormPayload,
|
||||
getCreateDefaultValues,
|
||||
mapDetailToFormData,
|
||||
} from "../lib/form-data-mapper";
|
||||
import {
|
||||
type InstlSncnRegistFormData,
|
||||
instlSncnRegistSchema,
|
||||
} from "../lib/instl-sncn-regist-schema";
|
||||
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
|
||||
|
||||
import { BasicInfoFormSection } from "./sections/basic-info-form-section";
|
||||
@@ -94,7 +101,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
|
||||
(item) => !item.splmntCn || !item.splmntCn.trim(),
|
||||
);
|
||||
if (hasEmptySplmntCn) {
|
||||
alert("보완요청내용이 있습니다. 보완완료내용을 입력해 주세요.");
|
||||
alert("보완 요청 내용이 있습니다.\n보완 완료 내용을 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,12 +116,12 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
|
||||
const result = await submitSupplementForm(data);
|
||||
if (!result) return;
|
||||
|
||||
alert("보완완료 처리되었습니다.");
|
||||
router.push(`/instl-sncn/${docNo}`);
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
|
||||
alert("보완 완료 처리되었습니다.");
|
||||
await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
|
||||
router.push(withListQs(`/instl-sncn/${docNo}`));
|
||||
} catch (error) {
|
||||
console.error("보완완료 실패:", error);
|
||||
alert("보완완료 처리 중 오류가 발생했습니다.");
|
||||
console.error("보완 완료 실패:", error);
|
||||
alert("보완 완료 처리 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -129,7 +136,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
|
||||
if (!instlSncnDocNo) return;
|
||||
|
||||
alert("저장되었습니다.");
|
||||
router.push(`/instl-sncn/${instlSncnDocNo}/edit`);
|
||||
router.push(withListQs(`/instl-sncn/${instlSncnDocNo}/edit`));
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
@@ -142,6 +149,11 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
|
||||
async function onApply(data: InstlSncnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (data.privacyConsent !== "Y") {
|
||||
alert("개인정보 수집/이용에 동의해야 신청이 가능합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFormChecklistComplete(data.cklstResultList)) {
|
||||
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
|
||||
return;
|
||||
@@ -158,7 +170,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
|
||||
if (!instlSncnDocNo) return;
|
||||
|
||||
alert("신청 완료되었습니다.");
|
||||
router.push(`/instl-sncn/${instlSncnDocNo}`);
|
||||
router.push(withListQs(`/instl-sncn/${instlSncnDocNo}`));
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
|
||||
} catch (error) {
|
||||
console.error("신청 실패:", error);
|
||||
@@ -184,7 +196,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
|
||||
await instlSncnService.remove(docNo);
|
||||
alert("삭제되었습니다.");
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.list() });
|
||||
router.push("/instl-sncn");
|
||||
router.push(getListHref("/instl-sncn"));
|
||||
} catch (error) {
|
||||
console.error("삭제 실패:", error);
|
||||
alert("삭제 중 오류가 발생했습니다.");
|
||||
@@ -227,7 +239,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
|
||||
<Button
|
||||
color="white"
|
||||
type="button"
|
||||
onClick={() => router.push("/instl-sncn")}
|
||||
onClick={() => router.push(getListHref("/instl-sncn"))}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
목록
|
||||
@@ -245,7 +257,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
|
||||
onClick={form.handleSubmit(onApply)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
신청완료
|
||||
신청 완료
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
@@ -264,7 +276,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
|
||||
onClick={form.handleSubmit(onSplmntCmptn)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
보완완료
|
||||
보완 완료
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -272,7 +284,6 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
|
||||
</div>
|
||||
</SectionCollapsibleGroup>
|
||||
</form>
|
||||
<RhfDevTool control={form.control} />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import type { CmCdAdstDstrComboResponseDto } from "@/entities/adstdstr";
|
||||
import { useFindGugunCombo, useFindSidoCombo } from "@/entities/adstdstr";
|
||||
import { useSearchParamsState } from "@/shared/lib";
|
||||
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import type { InstlSncnMngSearchParams } from "@/entities/instl-sncn";
|
||||
import {
|
||||
@@ -24,46 +24,58 @@ import { InstlList } from "./instl-list";
|
||||
const DEFAULT_PAGE_INDEX = 0;
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
const DEFAULT_SORT = "instlSncnDocNo,desc";
|
||||
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
|
||||
const ALL_SELECT_VALUE = "__ALL__";
|
||||
|
||||
const DEFAULT_SEARCH_PARAMS: InstlSncnMngSearchParams = {
|
||||
bizNm: "",
|
||||
const DEFAULT_PARAMS = {
|
||||
sidoComCd: "",
|
||||
sggComCd: "",
|
||||
taskSeComCd: "",
|
||||
admdstCd: "",
|
||||
bizNm: "",
|
||||
page: DEFAULT_PAGE_INDEX,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
sort: DEFAULT_SORT,
|
||||
};
|
||||
|
||||
function InstlListPage() {
|
||||
const [bizNm, setBizNm] = useState("");
|
||||
const [taskSeComCd, setTaskSeComCd] = useState("");
|
||||
const [sidoComCd, setSidoComCd] = useState("");
|
||||
const [sggComCd, setSggComCd] = useState("");
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: DEFAULT_PAGE_INDEX,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const [searchParams, setSearchParams] = useState<InstlSncnMngSearchParams>(DEFAULT_SEARCH_PARAMS);
|
||||
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
|
||||
const [bizNm, setBizNm] = useState(params.bizNm);
|
||||
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
|
||||
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
|
||||
const [sggComCd, setSggComCd] = useState(params.sggComCd);
|
||||
|
||||
const { data: sidoData } = useFindSidoCombo();
|
||||
const { data: gugunData } = useFindGugunCombo(sidoComCd);
|
||||
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
|
||||
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
|
||||
const { userSidoData, userGugunData } = useFindUserSidoGugunCombo(sidoComCd);
|
||||
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: params.page,
|
||||
pageSize: params.size,
|
||||
};
|
||||
|
||||
const searchParams: InstlSncnMngSearchParams = useMemo(
|
||||
() => ({
|
||||
bizNm: params.bizNm,
|
||||
taskSeComCd: params.taskSeComCd,
|
||||
admdstCd: params.sggComCd || params.sidoComCd || "",
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
sort: DEFAULT_SORT,
|
||||
}),
|
||||
[params],
|
||||
);
|
||||
|
||||
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
|
||||
setSearchParams({
|
||||
...DEFAULT_SEARCH_PARAMS,
|
||||
setParams({
|
||||
bizNm,
|
||||
taskSeComCd,
|
||||
admdstCd: sggComCd || sidoComCd || "",
|
||||
sidoComCd,
|
||||
sggComCd,
|
||||
page: DEFAULT_PAGE_INDEX,
|
||||
size: pagination.pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePaginationChange = (next: PaginationState) => {
|
||||
setParams({ page: next.pageIndex, size: next.pageSize });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background">
|
||||
@@ -75,7 +87,7 @@ function InstlListPage() {
|
||||
<Select
|
||||
value={sidoComCd}
|
||||
onValueChange={(value) => {
|
||||
setSidoComCd(value);
|
||||
setSidoComCd(value === ALL_SELECT_VALUE ? "" : value);
|
||||
setSggComCd("");
|
||||
}}
|
||||
>
|
||||
@@ -83,7 +95,8 @@ function InstlListPage() {
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sidoOptions.map((option) => (
|
||||
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
|
||||
{userSidoData.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
@@ -95,12 +108,17 @@ function InstlListPage() {
|
||||
<Label htmlFor="sggComCd" className="whitespace-nowrap">
|
||||
시군구
|
||||
</Label>
|
||||
<Select value={sggComCd} onValueChange={setSggComCd} disabled={!sidoComCd}>
|
||||
<Select
|
||||
value={sggComCd}
|
||||
onValueChange={(value) => setSggComCd(value === ALL_SELECT_VALUE ? "" : value)}
|
||||
disabled={!sidoComCd}
|
||||
>
|
||||
<SelectTrigger id="sggComCd" className="w-[12rem]">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{gugunOptions.map((option) => (
|
||||
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
|
||||
{userGugunData.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
@@ -132,7 +150,7 @@ function InstlListPage() {
|
||||
<InstlList
|
||||
searchParams={searchParams}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useDataTable, withListQs } from "@/shared/lib";
|
||||
import {
|
||||
type InstlSncnMngResponseDto,
|
||||
type InstlSncnMngSearchParams,
|
||||
@@ -43,7 +43,7 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
|
||||
const columns = useMemo(() => createInstlSncnColumns(), []);
|
||||
|
||||
const handleRowClick = (row: InstlSncnMngResponseDto) => {
|
||||
router.push(`/instl-sncn/${row.instlSncnDocNo}`);
|
||||
router.push(withListQs(`/instl-sncn/${row.instlSncnDocNo}`));
|
||||
};
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
|
||||
@@ -95,7 +95,9 @@ function InstlViewPage({ id }: InstlViewPageProps) {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<SectionCollapsibleGroup>
|
||||
<SectionCollapsibleToggle />
|
||||
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
|
||||
<SectionCollapsibleToggle />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<ProgressStatusSection
|
||||
columns={PROGRESS_STATUS_COLUMNS}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { findCodeLabel, useCodeList } from "@/entities/code";
|
||||
@@ -96,9 +97,11 @@ function BasicInfoFormSection() {
|
||||
name="instlSncnMng.bizBgngYr"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto">
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto !gap-1">
|
||||
<Input
|
||||
{...field}
|
||||
{...NON_NEGATIVE_INPUT_PROPS}
|
||||
type="number"
|
||||
id={field.name}
|
||||
placeholder="시작연도"
|
||||
className="max-w-[12rem]"
|
||||
@@ -113,9 +116,11 @@ function BasicInfoFormSection() {
|
||||
name="instlSncnMng.bizEndYr"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto">
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto !gap-1">
|
||||
<Input
|
||||
{...field}
|
||||
{...NON_NEGATIVE_INPUT_PROPS}
|
||||
type="number"
|
||||
id={field.name}
|
||||
placeholder="종료연도"
|
||||
className="max-w-[12rem]"
|
||||
|
||||
@@ -55,7 +55,7 @@ export function resolveNotiStatus(
|
||||
|
||||
return {
|
||||
label: "미조회",
|
||||
badgeProps: { color: "yellow" },
|
||||
badgeProps: { color: "yellow", size: "lg" },
|
||||
actionLabel: "처리하기",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,6 +101,13 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
|
||||
];
|
||||
}
|
||||
|
||||
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
|
||||
if (prgrsSttsComCd === "SL") {
|
||||
return <Badge color="orange">보완요청</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
@@ -151,7 +158,11 @@ export function createSewerBscPlanColumns() {
|
||||
columnHelper.accessor("sewerBscPlanNm", {
|
||||
header: "기본계획명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
cell: ({ row, getValue }) => (
|
||||
<>
|
||||
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("sewerBscPlanSeCdNm", {
|
||||
header: "계획구분",
|
||||
|
||||
@@ -44,8 +44,8 @@ export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
|
||||
|
||||
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
|
||||
LOCAL: "자가체크",
|
||||
ENV: "기술검토",
|
||||
KECO: "환경청검토",
|
||||
ENV: "환경청검토",
|
||||
KECO: "기술검토",
|
||||
};
|
||||
|
||||
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
|
||||
import {
|
||||
buildElaprPrgrsSttsListFromAplnRows,
|
||||
ElaprApprovalDialog,
|
||||
@@ -116,11 +116,11 @@ function FloatingActionButtons({
|
||||
isRegister && actionButtons.some((button) => button === SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg);
|
||||
|
||||
function handleGoToList() {
|
||||
router.push("/sewer-bsc-plan");
|
||||
router.push(getListHref("/sewer-bsc-plan"));
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
router.push(`/sewer-bsc-plan/${sewerBscPlanDocNo}/edit?chgCycl=${chgCycl}`);
|
||||
router.push(withListQs(`/sewer-bsc-plan/${sewerBscPlanDocNo}/edit?chgCycl=${chgCycl}`));
|
||||
}
|
||||
|
||||
function shouldShowButton(action: SewerBscPlanViewButtonAction) {
|
||||
@@ -201,7 +201,7 @@ function FloatingActionButtons({
|
||||
action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.del,
|
||||
successMessage: "삭제되었습니다.",
|
||||
});
|
||||
router.push("/sewer-bsc-plan");
|
||||
router.push(getListHref("/sewer-bsc-plan"));
|
||||
}
|
||||
|
||||
function getReviewOpinionFormData() {
|
||||
@@ -502,7 +502,9 @@ async function handleDmndSplmnt(
|
||||
context.request,
|
||||
);
|
||||
await context.queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
|
||||
context.router.replace(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl}`);
|
||||
context.router.replace(
|
||||
withListQs(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl}`),
|
||||
);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
import { buildAcntgCostItems } from "@/widgets/pbadms-sprt";
|
||||
import { buildAcntgCostItems, truncateDecimals } from "@/widgets/pbadms-sprt";
|
||||
import {
|
||||
Input,
|
||||
Skeleton,
|
||||
@@ -50,7 +51,8 @@ function BizCstMatrixFormSection() {
|
||||
];
|
||||
|
||||
function handleChange(acntgSeCd: string, planStpNo: number, value: string) {
|
||||
const numValue = Number(value) || 0;
|
||||
const normalized = truncateDecimals(stripMinusSign(value), 3);
|
||||
const numValue = Number(normalized.replace(/,/g, "")) || 0;
|
||||
const next = recalculateBizCstMatrix(
|
||||
setAmt(getValues("bizCstList"), acntgSeCd, planStpNo, numValue),
|
||||
costItems,
|
||||
@@ -112,7 +114,9 @@ function BizCstMatrixFormSection() {
|
||||
<TableCell key={step.planStpNo}>
|
||||
<Field>
|
||||
<Input
|
||||
{...NON_NEGATIVE_INPUT_PROPS}
|
||||
type="number"
|
||||
step={0.001}
|
||||
value={getAmt(bizCstList, item.code, step.planStpNo) || ""}
|
||||
onChange={(e) => handleChange(item.code, step.planStpNo, e.target.value)}
|
||||
readOnly={item.readonly}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
@@ -58,7 +59,7 @@ function FnrsCstFormSection() {
|
||||
);
|
||||
|
||||
function handleChange(planStpNo: number, fieldKey: EditableFnrsKey, value: string) {
|
||||
const numValue = Number(value.replace(/,/g, "")) || 0;
|
||||
const numValue = Number(stripMinusSign(value).replace(/,/g, "")) || 0;
|
||||
const draft = [...getValues("fnrsCstList")];
|
||||
const index = draft.findIndex((item) => item.planStpNo === planStpNo);
|
||||
if (index < 0) return;
|
||||
@@ -154,6 +155,7 @@ function FnrsCstFormSection() {
|
||||
<TableCell key={planStpNo}>
|
||||
<Field>
|
||||
<Input
|
||||
{...NON_NEGATIVE_INPUT_PROPS}
|
||||
type="number"
|
||||
className={cn("text-right", row.readonly && "bg-muted")}
|
||||
readOnly={row.readonly}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useState } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { truncateDecimals } from "@/widgets/pbadms-sprt";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@@ -28,9 +30,9 @@ const PreviousPlanLookupDialog = dynamic(
|
||||
);
|
||||
|
||||
const OTLN_ROWS = [
|
||||
{ key: "sewerPlanPpltnCnt" as const, label: "계획인구" },
|
||||
{ key: "sewerPrcsPpltnCnt" as const, label: "처리인구" },
|
||||
{ key: "sewerPsplyRt" as const, label: "하수도보급율" },
|
||||
{ key: "sewerPlanPpltnCnt" as const, label: "계획인구", decimals: 0 },
|
||||
{ key: "sewerPrcsPpltnCnt" as const, label: "처리인구", decimals: 0 },
|
||||
{ key: "sewerPsplyRt" as const, label: "하수도보급율", step: 0.1, decimals: 1 },
|
||||
];
|
||||
|
||||
function OutlineFormSection() {
|
||||
@@ -58,8 +60,11 @@ function OutlineFormSection() {
|
||||
planStpNo: number,
|
||||
fieldKey: (typeof OTLN_ROWS)[number]["key"],
|
||||
value: string,
|
||||
decimals?: number,
|
||||
) {
|
||||
const numValue = Number(value.replace(/,/g, "")) || 0;
|
||||
const unsigned = stripMinusSign(value);
|
||||
const normalized = decimals === undefined ? unsigned : truncateDecimals(unsigned, decimals);
|
||||
const numValue = Number(normalized.replace(/,/g, "")) || 0;
|
||||
const draft = [...getValues("sewerBscPlanOtlnList")];
|
||||
const index = draft.findIndex((item) => item.planStpNo === planStpNo);
|
||||
if (index < 0) return;
|
||||
@@ -104,11 +109,14 @@ function OutlineFormSection() {
|
||||
<TableCell key={planStpNo}>
|
||||
<Field>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
{...NON_NEGATIVE_INPUT_PROPS}
|
||||
type="number"
|
||||
step={row.step}
|
||||
className="text-right"
|
||||
value={item?.[row.key] ?? ""}
|
||||
onChange={(e) => handleChange(planStpNo, row.key, e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleChange(planStpNo, row.key, e.target.value, row.decimals)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</TableCell>
|
||||
|
||||
@@ -5,13 +5,19 @@ import { Controller, useFieldArray, useFormContext, useWatch } from "react-hook-
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import {
|
||||
getDefaultFileKey,
|
||||
NON_NEGATIVE_INPUT_PROPS,
|
||||
stripMinusSign,
|
||||
useTus,
|
||||
} from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { attachService } from "@/entities/attach";
|
||||
import { useFacilityList } from "@/entities/bns-if";
|
||||
import { useNowDmgMtncGnsvy } from "@/entities/dc";
|
||||
import { truncateDecimals } from "@/widgets/pbadms-sprt";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -57,15 +63,17 @@ interface UploadedTmplntFile {
|
||||
const TMPLNT_ATCH_ACCEPT = ".pdf,.png,.jpg,.jpeg";
|
||||
const TMPLNT_ATCH_FORMAT_LABEL = ".pdf, .png, .jpg, .jpeg";
|
||||
|
||||
const TMPLNT_FIELD_ROWS: { key: TmplntFieldKey; label: string }[] = [
|
||||
{ key: "prcsZoneArea", label: "처리구역면적(km²)" },
|
||||
const TMPLNT_DECIMAL_PLACES = 3;
|
||||
|
||||
const TMPLNT_FIELD_ROWS: { key: TmplntFieldKey; label: string; decimals?: number }[] = [
|
||||
{ key: "prcsZoneArea", label: "처리구역면적(km²)", decimals: TMPLNT_DECIMAL_PLACES },
|
||||
{ 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: "sprsysDrtwPplLen", label: "분류식 오수(km)", decimals: TMPLNT_DECIMAL_PLACES },
|
||||
{ key: "sprsysExlnPplLen", label: "분류식 우수(km)", decimals: TMPLNT_DECIMAL_PLACES },
|
||||
{ key: "cbsysPplLen", label: "합류식(km)", decimals: TMPLNT_DECIMAL_PLACES },
|
||||
{ key: "icptgPplLen", label: "차집관로(km)", decimals: TMPLNT_DECIMAL_PLACES },
|
||||
{ key: "drgCnt", label: "배수설비(개소)" },
|
||||
{ key: "drtwRlyPumpCnt", label: "오수 중계 펌프(개소)" },
|
||||
{ key: "drtwPumpCnt", label: "오수 일반 펌프(개소)" },
|
||||
@@ -76,12 +84,14 @@ interface TmplntDtlInputProps {
|
||||
facilityIndex: number;
|
||||
planStpNo: number;
|
||||
fieldKey: TmplntFieldKey;
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
function TmplntDtlInput({ facilityIndex, planStpNo, fieldKey }: TmplntDtlInputProps) {
|
||||
function TmplntDtlInput({ facilityIndex, planStpNo, fieldKey, decimals }: TmplntDtlInputProps) {
|
||||
const { control } = useFormContext<SewerBscPlanRegistFormData>();
|
||||
const fieldName =
|
||||
`sewerBscPlanTmplntList.${facilityIndex}.sewerBscPlanTmplntDtlList.${planStpNo}.${fieldKey}` as const;
|
||||
const hasDecimals = decimals !== undefined;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
@@ -90,13 +100,16 @@ function TmplntDtlInput({ facilityIndex, planStpNo, fieldKey }: TmplntDtlInputPr
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<Input
|
||||
type="text"
|
||||
{...NON_NEGATIVE_INPUT_PROPS}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step={hasDecimals ? Number(`1e-${decimals}`) : undefined}
|
||||
className="text-right"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => {
|
||||
const numValue = Number(e.target.value.replace(/,/g, "")) || 0;
|
||||
field.onChange(numValue);
|
||||
const value = stripMinusSign(e.target.value.replace(/,/g, ""));
|
||||
const normalized = hasDecimals ? truncateDecimals(value, decimals) : value;
|
||||
field.onChange(Number(normalized) || 0);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
@@ -193,7 +206,12 @@ function TmplntFacilityMatrix({
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell key={`current-${row.key}`}>
|
||||
<TmplntDtlInput facilityIndex={facilityIndex} planStpNo={0} fieldKey={row.key} />
|
||||
<TmplntDtlInput
|
||||
facilityIndex={facilityIndex}
|
||||
planStpNo={0}
|
||||
fieldKey={row.key}
|
||||
decimals={row.decimals}
|
||||
/>
|
||||
</TableCell>
|
||||
{changeStepHeaders.map((step) => (
|
||||
<TableCell key={`change-${row.key}-${step.planStpNo}`}>
|
||||
@@ -201,6 +219,7 @@ function TmplntFacilityMatrix({
|
||||
facilityIndex={facilityIndex}
|
||||
planStpNo={step.planStpNo}
|
||||
fieldKey={row.key}
|
||||
decimals={row.decimals}
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
@@ -4,11 +4,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import {
|
||||
type CmCdAdstDstrComboResponseDto,
|
||||
useFindUserGugunCombo,
|
||||
useFindUserSidoCombo,
|
||||
} from "@/entities/adstdstr";
|
||||
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
|
||||
import { useCodeList } from "@/entities/code";
|
||||
import {
|
||||
Button,
|
||||
@@ -48,8 +44,6 @@ interface SewerBscPlanApplyDialogProps {
|
||||
onConfirm: (selection: SewerBscPlanApplySelection) => void;
|
||||
}
|
||||
|
||||
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
|
||||
|
||||
function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlanApplyDialogProps) {
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const [sidoComCd, setSidoComCd] = useState("");
|
||||
@@ -57,37 +51,48 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
|
||||
const [sewerBscPlanSeComCd, setSewerBscPlanSeComCd] = useState("");
|
||||
const [taskSeComCd, setTaskSeComCd] = useState("");
|
||||
|
||||
const sidoFilterUserInfo =
|
||||
userInfo?.dtlInstCd === "ROLE_USER04" || userInfo?.dtlInstCd === "ROLE_USER05"
|
||||
? userInfo
|
||||
: undefined;
|
||||
|
||||
const { data: sidoData, isPending: isSidoPending } = useFindUserSidoCombo(sidoFilterUserInfo);
|
||||
const { data: gugunData, isPending: isGugunPending } = useFindUserGugunCombo(sidoComCd);
|
||||
const canSidoGugunSelectable = userInfo?.dtlInstCd !== "ROLE_USER05";
|
||||
const {
|
||||
userSidoData,
|
||||
userGugunData,
|
||||
isUserSidoPending,
|
||||
isUserGugunPending,
|
||||
} = useFindUserSidoGugunCombo(sidoComCd);
|
||||
const { data: planSeOptions, isPending: isPlanSePending } = useCodeList("SEWERBSCPLANSE");
|
||||
const { data: taskSeOptions, isPending: isTaskSePending } = useCodeList("SEWER");
|
||||
|
||||
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
|
||||
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
|
||||
const isPending = isSidoPending || isPlanSePending || isTaskSePending;
|
||||
const isPending = isUserSidoPending || isPlanSePending || isTaskSePending;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSidoComCd("");
|
||||
setSggComCd("");
|
||||
setSewerBscPlanSeComCd("");
|
||||
setTaskSeComCd("");
|
||||
}, [open]);
|
||||
|
||||
const canSidoGugunSelectable = userInfo?.dtlInstCd !== "ROLE_USER05";
|
||||
if (!canSidoGugunSelectable) return;
|
||||
setSidoComCd("");
|
||||
setSggComCd("");
|
||||
}, [open, canSidoGugunSelectable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canSidoGugunSelectable || userSidoData.length === 0) return;
|
||||
const nextSidoComCd = userSidoData[0].adstDstrCd;
|
||||
setSidoComCd((current) => (current === nextSidoComCd ? current : nextSidoComCd));
|
||||
}, [canSidoGugunSelectable, userSidoData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canSidoGugunSelectable || !sidoComCd || userGugunData.length === 0) return;
|
||||
const nextSggComCd = userGugunData[0].adstDstrCd;
|
||||
setSggComCd((current) => current || nextSggComCd);
|
||||
}, [canSidoGugunSelectable, sidoComCd, userGugunData]);
|
||||
|
||||
const canConfirm =
|
||||
!!sewerBscPlanSeComCd && !!taskSeComCd && (!canSidoGugunSelectable || !!sidoComCd);
|
||||
|
||||
function handleConfirm() {
|
||||
if (!canConfirm) return;
|
||||
|
||||
const sidoOption = sidoOptions.find((option) => option.adstDstrCd === sidoComCd);
|
||||
const gugunOption = gugunOptions.find((option) => option.adstDstrCd === sggComCd);
|
||||
const sidoOption = userSidoData.find((option) => option.adstDstrCd === sidoComCd);
|
||||
const gugunOption = userGugunData.find((option) => option.adstDstrCd === sggComCd);
|
||||
const planSeOption = planSeOptions?.find((option) => option.lwpoCd === sewerBscPlanSeComCd);
|
||||
const taskSeOption = taskSeOptions?.find((option) => option.lwpoCd === taskSeComCd);
|
||||
|
||||
@@ -135,7 +140,7 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sidoOptions.map((option) => (
|
||||
{userSidoData.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
@@ -148,13 +153,13 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
|
||||
<Select
|
||||
value={sggComCd || undefined}
|
||||
onValueChange={setSggComCd}
|
||||
disabled={!sidoComCd || isGugunPending}
|
||||
disabled={!sidoComCd || isUserGugunPending}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{gugunOptions.map((option) => (
|
||||
{userGugunData.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
|
||||
@@ -8,8 +8,8 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
||||
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
|
||||
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
|
||||
import {
|
||||
@@ -123,7 +123,9 @@ function SewerBscPlanFormPage({
|
||||
if (!result?.sewerBscPlanDocNo) return;
|
||||
|
||||
alert("저장되었습니다.");
|
||||
router.push(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}/edit?chgCycl=${result.chgCycl}`);
|
||||
router.push(
|
||||
withListQs(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}/edit?chgCycl=${result.chgCycl}`),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
@@ -162,7 +164,9 @@ function SewerBscPlanFormPage({
|
||||
if (!result?.sewerBscPlanDocNo) return;
|
||||
|
||||
alert("신청 완료되었습니다.");
|
||||
router.push(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl ?? 1}`);
|
||||
router.push(
|
||||
withListQs(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl ?? 1}`),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
|
||||
} catch (error) {
|
||||
console.error("신청 실패:", error);
|
||||
@@ -179,7 +183,7 @@ function SewerBscPlanFormPage({
|
||||
(item) => item.cmptnYn === "N" && (!item.splmntCn || !item.splmntCn.trim()),
|
||||
);
|
||||
if (hasEmptySplmntCn) {
|
||||
alert("보완요청 항목이 있습니다.\n보완완료 내용을 입력해 주세요.");
|
||||
alert("보완 요청 항목이 있습니다.\n보완 완료 내용을 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -197,13 +201,13 @@ function SewerBscPlanFormPage({
|
||||
const result = await sewerBscPlanService.submitSupplement(docNo, payload);
|
||||
if (!result) return;
|
||||
|
||||
alert("보완완료 처리되었습니다.");
|
||||
alert("보완 완료 처리되었습니다.");
|
||||
await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
|
||||
const chgCycl = data.sewerBscPlanDtl.chgCycl ?? initialData?.detDto?.chgCycl ?? 1;
|
||||
router.push(`/sewer-bsc-plan/${docNo}?chgCycl=${chgCycl}`);
|
||||
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
|
||||
router.push(withListQs(`/sewer-bsc-plan/${docNo}?chgCycl=${chgCycl}`));
|
||||
} catch (error) {
|
||||
console.error("보완완료 실패:", error);
|
||||
alert("보완완료 처리 중 오류가 발생했습니다.");
|
||||
console.error("보완 완료 실패:", error);
|
||||
alert("보완 완료 처리 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -228,7 +232,7 @@ function SewerBscPlanFormPage({
|
||||
setIsSubmitting(true);
|
||||
await sewerBscPlanService.delete(docNo);
|
||||
alert("삭제되었습니다.");
|
||||
router.push("/sewer-bsc-plan");
|
||||
router.push(getListHref("/sewer-bsc-plan"));
|
||||
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.list() });
|
||||
} catch (error) {
|
||||
console.error("삭제 실패:", error);
|
||||
@@ -240,7 +244,7 @@ function SewerBscPlanFormPage({
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
|
||||
<form onSubmit={form.handleSubmit(onSave)} className="mt-4">
|
||||
<SectionCollapsibleGroup>
|
||||
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
|
||||
<SectionCollapsibleToggle />
|
||||
@@ -274,7 +278,7 @@ function SewerBscPlanFormPage({
|
||||
<Button
|
||||
color="white"
|
||||
type="button"
|
||||
onClick={() => router.push("/sewer-bsc-plan")}
|
||||
onClick={() => router.push(getListHref("/sewer-bsc-plan"))}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
목록
|
||||
@@ -292,7 +296,7 @@ function SewerBscPlanFormPage({
|
||||
onClick={handleApplyClick}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
신청완료
|
||||
신청 완료
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
@@ -311,7 +315,7 @@ function SewerBscPlanFormPage({
|
||||
onClick={form.handleSubmit(onSplmntCmptn)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
보완완료
|
||||
보완 완료
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -319,7 +323,6 @@ function SewerBscPlanFormPage({
|
||||
</div>
|
||||
</SectionCollapsibleGroup>
|
||||
</form>
|
||||
<RhfDevTool control={form.control} />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
type CmCdAdstDstrComboResponseDto,
|
||||
useFindGugunCombo,
|
||||
useFindSidoCombo,
|
||||
} from "@/entities/adstdstr";
|
||||
import { useSearchParamsState } from "@/shared/lib";
|
||||
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import type { SewerBscPlanSearchRequestDto } from "@/entities/sewer-bsc-plan";
|
||||
import {
|
||||
@@ -27,52 +24,62 @@ import { SewerBscPlanList } from "./sewer-bsc-plan-list";
|
||||
const DEFAULT_PAGE_INDEX = 0;
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
const DEFAULT_SORT = "sewerBscPlanDocNo,desc";
|
||||
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
|
||||
|
||||
const DEFAULT_SEARCH_PARAMS: SewerBscPlanSearchRequestDto = {
|
||||
sewerBscPlanNm: "",
|
||||
sewerBscPlanSeComCd: "",
|
||||
taskSeComCd: "",
|
||||
admdstCd: "",
|
||||
page: DEFAULT_PAGE_INDEX,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
sort: DEFAULT_SORT,
|
||||
};
|
||||
|
||||
const ALL_SELECT_VALUE = "__ALL__";
|
||||
|
||||
function SewerBscPlanListPage() {
|
||||
const [sewerBscPlanNm, setSewerBscPlanNm] = useState("");
|
||||
const [sewerBscPlanSeComCd, setSewerBscPlanSeComCd] = useState("");
|
||||
const [taskSeComCd, setTaskSeComCd] = useState("");
|
||||
const [sidoComCd, setSidoComCd] = useState("");
|
||||
const [sggComCd, setSggComCd] = useState("");
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: DEFAULT_PAGE_INDEX,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const [searchParams, setSearchParams] =
|
||||
useState<SewerBscPlanSearchRequestDto>(DEFAULT_SEARCH_PARAMS);
|
||||
const DEFAULT_PARAMS = {
|
||||
sidoComCd: "",
|
||||
sggComCd: "",
|
||||
sewerBscPlanSeComCd: "",
|
||||
taskSeComCd: "",
|
||||
sewerBscPlanNm: "",
|
||||
page: DEFAULT_PAGE_INDEX,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
};
|
||||
|
||||
const { data: sidoData } = useFindSidoCombo();
|
||||
const { data: gugunData } = useFindGugunCombo(sidoComCd);
|
||||
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
|
||||
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
|
||||
function SewerBscPlanListPage() {
|
||||
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
|
||||
const [sewerBscPlanNm, setSewerBscPlanNm] = useState(params.sewerBscPlanNm);
|
||||
const [sewerBscPlanSeComCd, setSewerBscPlanSeComCd] = useState(params.sewerBscPlanSeComCd);
|
||||
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
|
||||
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
|
||||
const [sggComCd, setSggComCd] = useState(params.sggComCd);
|
||||
|
||||
const { userSidoData, userGugunData } = useFindUserSidoGugunCombo(sidoComCd);
|
||||
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: params.page,
|
||||
pageSize: params.size,
|
||||
};
|
||||
|
||||
const searchParams: SewerBscPlanSearchRequestDto = useMemo(
|
||||
() => ({
|
||||
sewerBscPlanNm: params.sewerBscPlanNm,
|
||||
sewerBscPlanSeComCd: params.sewerBscPlanSeComCd,
|
||||
taskSeComCd: params.taskSeComCd,
|
||||
admdstCd: params.sggComCd || params.sidoComCd || "",
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
sort: DEFAULT_SORT,
|
||||
}),
|
||||
[params],
|
||||
);
|
||||
|
||||
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
|
||||
setSearchParams({
|
||||
...DEFAULT_SEARCH_PARAMS,
|
||||
setParams({
|
||||
sewerBscPlanNm,
|
||||
sewerBscPlanSeComCd,
|
||||
taskSeComCd,
|
||||
admdstCd: sggComCd || sidoComCd || "",
|
||||
sidoComCd,
|
||||
sggComCd,
|
||||
page: DEFAULT_PAGE_INDEX,
|
||||
size: pagination.pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePaginationChange = (next: PaginationState) => {
|
||||
setParams({ page: next.pageIndex, size: next.pageSize });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background">
|
||||
@@ -93,7 +100,7 @@ function SewerBscPlanListPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
|
||||
{sidoOptions.map((option) => (
|
||||
{userSidoData.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
@@ -115,7 +122,7 @@ function SewerBscPlanListPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
|
||||
{gugunOptions.map((option) => (
|
||||
{userGugunData.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
@@ -163,7 +170,7 @@ function SewerBscPlanListPage() {
|
||||
<SewerBscPlanList
|
||||
searchParams={searchParams}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useDataTable, withListQs } from "@/shared/lib";
|
||||
import {
|
||||
type SewerBscPlanResponseDto,
|
||||
type SewerBscPlanSearchRequestDto,
|
||||
@@ -47,7 +47,7 @@ function SewerBscPlanList({ searchParams, pagination, onPaginationChange }: Sewe
|
||||
const columns = useMemo(() => createSewerBscPlanColumns(), []);
|
||||
|
||||
const handleRowClick = (row: SewerBscPlanResponseDto) => {
|
||||
router.push(`/sewer-bsc-plan/${row.sewerBscPlanDocNo}?chgCycl=${row.chgCycl}`);
|
||||
router.push(withListQs(`/sewer-bsc-plan/${row.sewerBscPlanDocNo}?chgCycl=${row.chgCycl}`));
|
||||
};
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
|
||||
@@ -102,7 +102,9 @@ function SewerBscPlanViewPage({ id, propChgCycl }: SewerBscPlanViewPageProps) {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<SectionCollapsibleGroup>
|
||||
<SectionCollapsibleToggle />
|
||||
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
|
||||
<SectionCollapsibleToggle />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<ProgressStatusSection
|
||||
columns={PROGRESS_STATUS_COLUMNS}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
export { getCookie, removeCookie, setCookie } from "./cookie";
|
||||
export { dayjs } from "./dayjs";
|
||||
export { getListHref, withListQs } from "./list-return-href";
|
||||
export { matchQueryStatus } from "./match-query-status";
|
||||
export { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "./number-input";
|
||||
export { queryClient } from "./query-client";
|
||||
export { useAlertStore } from "./use-alert-store";
|
||||
export { useDataTable } from "./use-data-table";
|
||||
export { useMenuStore } from "./use-menu-store";
|
||||
export { useMutationWrapper } from "./use-mutation-wrapper";
|
||||
export { useSearchParamsState } from "./use-search-params-state";
|
||||
export { getDefaultFileKey, useTus } from "./use-tus";
|
||||
export { useUserStore } from "./use-user-store";
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 목록 조회 조건을 sessionStorage에 저장하고, 목록 복귀 시 복원하기 위한 헬퍼.
|
||||
*
|
||||
* - 목록 → 상세: 현재 목록 쿼리를 `list-qs-{listPath}` 키로 저장
|
||||
* - 상세 → 수정·저장 후 이동: storage를 건드리지 않음 (같은 탭에서 유지)
|
||||
* - 목록/삭제 후 이동: storage에서 읽어 `/listPath?bizNm=...` 로 복원
|
||||
*/
|
||||
|
||||
function toListSlug(listPath: string) {
|
||||
return listPath.replace(/^\/+|\/+$/g, "").replaceAll("/", "-");
|
||||
}
|
||||
|
||||
function storageKey(listPath: string) {
|
||||
return `list-qs-${toListSlug(listPath)}`;
|
||||
}
|
||||
|
||||
/** 상세/수정 href에서 목록 slug를 추출한다. */
|
||||
function inferListPath(href: string) {
|
||||
const path = href.split("?")[0] ?? "";
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
return segments[0] ?? "";
|
||||
}
|
||||
|
||||
function isOnListPage(slug: string) {
|
||||
if (typeof window === "undefined" || !slug) return false;
|
||||
const { pathname } = window.location;
|
||||
return pathname === `/${slug}` || pathname === `/${slug}/`;
|
||||
}
|
||||
|
||||
/** 목록에 있을 때 현재 쿼리를 저장하고, href는 그대로 반환한다. */
|
||||
export function withListQs(href: string) {
|
||||
if (typeof window === "undefined") return href;
|
||||
|
||||
const slug = inferListPath(href);
|
||||
if (!isOnListPage(slug)) return href;
|
||||
|
||||
const search = window.location.search.replace(/^\?/, "");
|
||||
if (search) sessionStorage.setItem(storageKey(slug), search);
|
||||
else sessionStorage.removeItem(storageKey(slug));
|
||||
|
||||
return href;
|
||||
}
|
||||
|
||||
/** sessionStorage에 저장된 목록 쿼리로 href를 만든다. */
|
||||
export function getListHref(listPath: string) {
|
||||
if (typeof window === "undefined") return listPath;
|
||||
|
||||
const listQs = sessionStorage.getItem(storageKey(listPath));
|
||||
return listQs ? `${listPath}?${listQs}` : listPath;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { KeyboardEvent } from "react";
|
||||
|
||||
function preventMinusKey(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "-") e.preventDefault();
|
||||
}
|
||||
|
||||
/** type="number" Input에 스프레드해 음수 입력(키·스피너)을 막는다 */
|
||||
export const NON_NEGATIVE_INPUT_PROPS = { min: 0, onKeyDown: preventMinusKey } as const;
|
||||
|
||||
/** 붙여넣기 등으로 들어온 음수 부호를 제거한다 */
|
||||
export function stripMinusSign(value: string): string {
|
||||
return value.replace(/-/g, "");
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
/**
|
||||
* 조회 조건을 URL searchParams와 동기화하는 훅.
|
||||
*
|
||||
* - URL이 단일 소스 오브 트루스(SSOT)이며, 새로고침·뒤로가기 시에도 조건이 유지된다.
|
||||
* - 기본값과 동일한 값은 URL에서 제거해 쿼리스트링을 깨끗하게 유지한다.
|
||||
* - `window.history.replaceState` 기반 shallow 갱신으로 서버 라운드트립 없이 동작한다.
|
||||
* - 숫자 기본값이 주어진 키는 자동으로 `Number` 파싱한다.
|
||||
*
|
||||
* @example
|
||||
* const { params, setParams, resetParams } = useSearchParamsState({
|
||||
* keyword: "",
|
||||
* page: 0,
|
||||
* size: 10,
|
||||
* });
|
||||
*/
|
||||
export function useSearchParamsState<T extends Record<string, ParamValue>>(
|
||||
defaults: T,
|
||||
): UseSearchParamsStateReturn<T> {
|
||||
const searchParams = useSearchParams();
|
||||
const defaultsRef = useRef(defaults);
|
||||
|
||||
const params = useMemo(
|
||||
() => parseParams(searchParams, defaultsRef.current),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const setParams = useCallback((partial: Partial<T>) => {
|
||||
const next = new URLSearchParams(window.location.search);
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
if (value === undefined || value === defaultsRef.current[key]) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, String(value));
|
||||
}
|
||||
}
|
||||
replaceUrl(next);
|
||||
}, []);
|
||||
|
||||
const resetParams = useCallback(() => {
|
||||
const next = new URLSearchParams(window.location.search);
|
||||
for (const key of Object.keys(defaultsRef.current)) {
|
||||
next.delete(key);
|
||||
}
|
||||
replaceUrl(next);
|
||||
}, []);
|
||||
|
||||
return { params, setParams, resetParams };
|
||||
}
|
||||
|
||||
function parseParams<T extends Record<string, ParamValue>>(
|
||||
searchParams: ReturnType<typeof useSearchParams>,
|
||||
defaults: T,
|
||||
): T {
|
||||
const result = { ...defaults };
|
||||
|
||||
for (const key of Object.keys(defaults) as (keyof T)[]) {
|
||||
const raw = searchParams?.get(key as string) ?? null;
|
||||
if (raw === null) continue;
|
||||
|
||||
if (typeof defaults[key] === "number") {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isNaN(parsed)) result[key] = parsed as T[keyof T];
|
||||
} else {
|
||||
result[key] = raw as T[keyof T];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function replaceUrl(searchParams: URLSearchParams) {
|
||||
const qs = searchParams.toString();
|
||||
const url = qs
|
||||
? `${window.location.pathname}?${qs}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
type ParamValue = string | number | null;
|
||||
|
||||
interface UseSearchParamsStateReturn<T> {
|
||||
params: T;
|
||||
setParams: (partial: Partial<T>) => void;
|
||||
resetParams: () => void;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useState } from "react";
|
||||
|
||||
import { type Upload } from "tus-js-client";
|
||||
|
||||
import { handleTusUpload } from "@/entities/tus";
|
||||
import { handleTusUpload, type TusErrorResponse } from "@/entities/tus";
|
||||
|
||||
import { useAlertStore } from "./use-alert-store";
|
||||
|
||||
@@ -15,7 +15,12 @@ interface UploadFileOptions {
|
||||
|
||||
interface UseTusOptions {
|
||||
onSuccess?: (atflCd: string, atflId: string, file: File, fileKey: string) => void;
|
||||
onError?: (error: Error, file: File, fileKey: string) => void;
|
||||
onError?: (
|
||||
error: Error,
|
||||
file: File,
|
||||
fileKey: string,
|
||||
response: TusErrorResponse | null,
|
||||
) => void;
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
@@ -27,6 +32,28 @@ interface UseTusReturn {
|
||||
uploads: Record<string, Upload>;
|
||||
}
|
||||
|
||||
const ALLOWED_EXTENSIONS: readonly string[] = [
|
||||
// 문서
|
||||
".pdf",
|
||||
".txt",
|
||||
".hwp",
|
||||
".hwpx",
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
// 이미지
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".bmp",
|
||||
// 압축
|
||||
".zip",
|
||||
];
|
||||
|
||||
function getDefaultFileKey(file: File) {
|
||||
return `${file.name}::${file.lastModified}::${file.size}`;
|
||||
}
|
||||
@@ -39,12 +66,16 @@ function useTus(options: UseTusOptions = {}): UseTusReturn {
|
||||
|
||||
const validateFileType = useCallback(
|
||||
(file: File): boolean => {
|
||||
if (!options.accept) return true;
|
||||
|
||||
const allowedExtensions = options.accept.split(",");
|
||||
const fileExtension = file.name.substring(file.name.lastIndexOf(".")).toLowerCase();
|
||||
const acceptExtensions = options.accept
|
||||
?.split(",")
|
||||
.map((extension) => extension.trim().toLowerCase());
|
||||
|
||||
if (!allowedExtensions.includes(fileExtension)) {
|
||||
const isAllowed =
|
||||
ALLOWED_EXTENSIONS.includes(fileExtension) &&
|
||||
(!acceptExtensions || acceptExtensions.includes(fileExtension));
|
||||
|
||||
if (!isAllowed) {
|
||||
alert(`허용되지 않은 파일 형식입니다. (${fileExtension})`);
|
||||
return false;
|
||||
}
|
||||
@@ -56,12 +87,14 @@ function useTus(options: UseTusOptions = {}): UseTusReturn {
|
||||
|
||||
const uploadFileAsync = useCallback(
|
||||
(file: File, uploadOptions?: UploadFileOptions): Promise<string> => {
|
||||
if (!validateFileType(file)) {
|
||||
return Promise.reject(new Error("Invalid file type"));
|
||||
}
|
||||
|
||||
const fileKey = uploadOptions?.fileKey ?? getDefaultFileKey(file);
|
||||
|
||||
if (!validateFileType(file)) {
|
||||
const error = new Error("Invalid file type");
|
||||
options.onError?.(error, file, fileKey, null);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
setIsUploading((prev) => ({ ...prev, [fileKey]: true }));
|
||||
setProgress((prev) => ({ ...prev, [fileKey]: 0 }));
|
||||
|
||||
@@ -80,10 +113,10 @@ function useTus(options: UseTusOptions = {}): UseTusReturn {
|
||||
options.onSuccess?.(atflCd, atflId, file, fileKey);
|
||||
resolve(atflCd);
|
||||
},
|
||||
handleError: (error) => {
|
||||
handleError: (error, response) => {
|
||||
setIsUploading((prev) => ({ ...prev, [fileKey]: false }));
|
||||
alert("파일 업로드 중 오류가 발생했습니다.");
|
||||
options.onError?.(error, file, fileKey);
|
||||
alert(response?.message ?? "파일 업로드 중 오류가 발생했습니다.");
|
||||
options.onError?.(error, file, fileKey, response);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,7 +40,6 @@ function FileAddButton({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
import { Download, FolderOpen, Fullscreen, Save } from "lucide-react";
|
||||
import { Download, FilePen, FolderOpen, Fullscreen, Save } from "lucide-react";
|
||||
|
||||
import { Button, cn } from "@common/components";
|
||||
|
||||
@@ -15,6 +15,7 @@ interface HangulGiangiToolbarProps {
|
||||
onSave: () => void;
|
||||
onDownload: () => void;
|
||||
onFullscreen: () => void;
|
||||
onDmndSplmnt?: () => void;
|
||||
}
|
||||
|
||||
function HangulGiangiToolbar({
|
||||
@@ -24,6 +25,7 @@ function HangulGiangiToolbar({
|
||||
onSave,
|
||||
onDownload,
|
||||
onFullscreen,
|
||||
onDmndSplmnt,
|
||||
}: HangulGiangiToolbarProps) {
|
||||
const openInputId = useId();
|
||||
|
||||
@@ -49,7 +51,7 @@ function HangulGiangiToolbar({
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isBusy}
|
||||
className={cn(
|
||||
@@ -58,7 +60,7 @@ function HangulGiangiToolbar({
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
<span className="text-[#666]">
|
||||
<FolderOpen className="!size-5" />
|
||||
열기
|
||||
</span>
|
||||
@@ -79,6 +81,12 @@ function HangulGiangiToolbar({
|
||||
<Fullscreen className="!size-5" />
|
||||
전체화면
|
||||
</Button>
|
||||
{onDmndSplmnt && (
|
||||
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onDmndSplmnt}>
|
||||
<FilePen className="!size-5" />
|
||||
보완요청
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ function HangulGiangi({
|
||||
documentFileName,
|
||||
onSaveDocument,
|
||||
onDownload,
|
||||
onDmndSplmnt,
|
||||
canSave = true,
|
||||
bridgeUrl = DEFAULT_BRIDGE_URL,
|
||||
ctrlBaseUrl = WEBHYP_CTRL_URL,
|
||||
@@ -206,6 +207,7 @@ function HangulGiangi({
|
||||
onSave={handleSave}
|
||||
onDownload={handleDownload}
|
||||
onFullscreen={handleFullscreen}
|
||||
onDmndSplmnt={onDmndSplmnt}
|
||||
/>
|
||||
|
||||
<iframe
|
||||
@@ -316,6 +318,7 @@ interface HangulGiangiProps {
|
||||
documentFileName: string;
|
||||
onSaveDocument: (file: Blob) => Promise<void>;
|
||||
onDownload: () => Promise<void>;
|
||||
onDmndSplmnt?: () => void;
|
||||
canSave?: boolean;
|
||||
bridgeUrl?: string;
|
||||
ctrlBaseUrl?: string;
|
||||
|
||||
@@ -30,7 +30,13 @@ export {
|
||||
toFnrsSharSaveList,
|
||||
validateBizCstSharBalance,
|
||||
} from "./lib/fnrs-shar-form-utils";
|
||||
export { calcDiffPercent, fmtNumber, indentPx, roundAmount } from "./lib/format-utils";
|
||||
export {
|
||||
calcDiffPercent,
|
||||
fmtNumber,
|
||||
indentPx,
|
||||
roundAmount,
|
||||
truncateDecimals,
|
||||
} from "./lib/format-utils";
|
||||
export { AttachmentsFormSection } from "./ui/attachments-form-section";
|
||||
export { AttachmentsViewSection } from "./ui/attachments-view-section";
|
||||
export { BusinessCostFormSection } from "./ui/business-cost-form-section";
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { AcntgSeMngTreeDto } from "@/entities/acntg";
|
||||
|
||||
import { roundAmount } from "./format-utils";
|
||||
|
||||
/** 세부별 총사업비 소수점 자릿수 (단위: 백만원) */
|
||||
export const COST_DECIMAL_PLACES = 3;
|
||||
|
||||
export interface CostItemDef {
|
||||
code: string;
|
||||
label: string;
|
||||
@@ -32,9 +35,9 @@ function sumChildAmounts(list: BizCstListItem[], childCodes: string[]) {
|
||||
});
|
||||
|
||||
return {
|
||||
bscPlanAmt: roundAmount(bscPlanAmt),
|
||||
bgtRfltAmt: roundAmount(bgtRfltAmt),
|
||||
tstmDesignAmt: roundAmount(tstmDesignAmt),
|
||||
bscPlanAmt: roundAmount(bscPlanAmt, COST_DECIMAL_PLACES),
|
||||
bgtRfltAmt: roundAmount(bgtRfltAmt, COST_DECIMAL_PLACES),
|
||||
tstmDesignAmt: roundAmount(tstmDesignAmt, COST_DECIMAL_PLACES),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,17 @@ export interface ScaleItemDef {
|
||||
code: string;
|
||||
label: string;
|
||||
level: number;
|
||||
numYn: boolean;
|
||||
step: number;
|
||||
dcptUnitCnt: number;
|
||||
readonly?: boolean;
|
||||
children?: string[];
|
||||
}
|
||||
|
||||
function toStep(dcptUnitCnt?: number) {
|
||||
return dcptUnitCnt && dcptUnitCnt > 0 ? Number(`1e-${dcptUnitCnt}`) : 1;
|
||||
}
|
||||
|
||||
export interface BzScaleListItem {
|
||||
cnstpSeCd: string;
|
||||
bscPl: number;
|
||||
@@ -65,6 +72,9 @@ export function buildCnstpScaleItems(tree: CnstpSeMngTreeDto[]): ScaleItemDef[]
|
||||
code: node.cnstpSeCd,
|
||||
label: node.cnstpSeCdNm,
|
||||
level: node.treeLevel,
|
||||
numYn: node.numYn !== "N",
|
||||
step: toStep(node.dcptUnitCnt),
|
||||
dcptUnitCnt: node.dcptUnitCnt ?? 0,
|
||||
readonly: Boolean(children && children.length > 0),
|
||||
children,
|
||||
};
|
||||
|
||||
@@ -20,11 +20,19 @@ export function indentPx(indNm: string | undefined): number {
|
||||
|
||||
const AMOUNT_DECIMAL_PLACES = 2;
|
||||
|
||||
export function roundAmount(value: number): number {
|
||||
const factor = 10 ** AMOUNT_DECIMAL_PLACES;
|
||||
export function roundAmount(value: number, decimals: number = AMOUNT_DECIMAL_PLACES): number {
|
||||
const factor = 10 ** decimals;
|
||||
return Math.round((value + Number.EPSILON) * factor) / factor;
|
||||
}
|
||||
|
||||
/** 입력 문자열의 소수부를 decimals 자리까지 잘라낸다 */
|
||||
export function truncateDecimals(value: string, decimals: number): string {
|
||||
const dotIndex = value.indexOf(".");
|
||||
if (dotIndex < 0) return value;
|
||||
if (decimals <= 0) return value.slice(0, dotIndex);
|
||||
return value.slice(0, dotIndex + 1 + decimals);
|
||||
}
|
||||
|
||||
export function calcDiffPercent(diff: number, baseline: number): string {
|
||||
if (baseline === 0) return "-";
|
||||
return `${Math.round((diff / baseline) * 100)}%`;
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
SectionCollapsible,
|
||||
UploadedFileChip,
|
||||
} from "@/shared/ui";
|
||||
import { attachService, useDownloadAttachmentByAtflCd, useDownloadAttachmentByAtflId } from "@/entities/attach";
|
||||
import {
|
||||
attachService,
|
||||
useDownloadAttachmentByAtflCd,
|
||||
useDownloadAttachmentByAtflId,
|
||||
} from "@/entities/attach";
|
||||
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
|
||||
import {
|
||||
Button,
|
||||
@@ -373,32 +377,26 @@ function AttachmentsFormSection({
|
||||
return (
|
||||
<SectionCollapsible title="첨부파일">
|
||||
<div className="space-y-6">
|
||||
<div className="px-2">
|
||||
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
|
||||
<Table variant="secondary" className="table-fixed">
|
||||
<colgroup>
|
||||
<col width="44%" />
|
||||
<col width="12%" />
|
||||
<col width="44%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30rem]" align="left">
|
||||
파일명
|
||||
</TableHead>
|
||||
<TableHead>파일 확장자</TableHead>
|
||||
<TableHead>파일첨부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{essentialFiles.length === 0 ? (
|
||||
{essentialFiles.length > 0 && (
|
||||
<div className="px-2">
|
||||
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
|
||||
<Table variant="secondary" className="table-fixed">
|
||||
<colgroup>
|
||||
<col width="44%" />
|
||||
<col width="12%" />
|
||||
<col width="44%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-12 text-center text-muted-foreground">
|
||||
조회된 필수 첨부파일 항목이 없습니다.
|
||||
</TableCell>
|
||||
<TableHead className="w-[30rem]" align="left">
|
||||
파일명
|
||||
</TableHead>
|
||||
<TableHead>파일 확장자</TableHead>
|
||||
<TableHead>파일첨부</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
essentialFiles.map((file) => {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{essentialFiles.map((file) => {
|
||||
const atflCd = file.atchFileGroupId ?? "";
|
||||
|
||||
return (
|
||||
@@ -445,11 +443,11 @@ function AttachmentsFormSection({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-2">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
@@ -468,9 +466,7 @@ function AttachmentsFormSection({
|
||||
fileName={item.file?.name ?? item.fileName}
|
||||
isUploading={isUploading[item.fileKey] ?? false}
|
||||
onRemove={() => void removeEtcFile(idx)}
|
||||
onDownload={
|
||||
item.atflId ? () => downloadByAtflId(item.atflId!) : undefined
|
||||
}
|
||||
onDownload={item.atflId ? () => downloadByAtflId(item.atflId!) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
@@ -17,8 +18,12 @@ import {
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { buildAcntgCostItems, recalculateBizCstTotals } from "../lib/business-cost-form-utils";
|
||||
import { calcDiffPercent } from "../lib/format-utils";
|
||||
import {
|
||||
buildAcntgCostItems,
|
||||
COST_DECIMAL_PLACES,
|
||||
recalculateBizCstTotals,
|
||||
} from "../lib/business-cost-form-utils";
|
||||
import { calcDiffPercent, roundAmount, truncateDecimals } from "../lib/format-utils";
|
||||
|
||||
interface BusinessCostFormData {
|
||||
bizCstList: Array<{
|
||||
@@ -31,6 +36,10 @@ interface BusinessCostFormData {
|
||||
|
||||
type CostFieldKey = "bscPlanAmt" | "bgtRfltAmt" | "tstmDesignAmt";
|
||||
|
||||
/** 컬럼 순서: 예산반영(A), 표준사업비(B), 금회설계시(C) */
|
||||
const COST_FIELDS: CostFieldKey[] = ["bgtRfltAmt", "bscPlanAmt", "tstmDesignAmt"];
|
||||
const COST_STEP = 0.001;
|
||||
|
||||
function isSameBizCstCodes(
|
||||
current: BusinessCostFormData["bizCstList"],
|
||||
next: BusinessCostFormData["bizCstList"],
|
||||
@@ -71,7 +80,7 @@ function BusinessCostFormSection() {
|
||||
}, [costItems, getValues, setValue]);
|
||||
|
||||
function handleCostChange(index: number, fieldKey: CostFieldKey, value: string) {
|
||||
const numValue = Number(value) || 0;
|
||||
const numValue = Number(truncateDecimals(stripMinusSign(value), COST_DECIMAL_PLACES)) || 0;
|
||||
const draft = [...getValues("bizCstList")];
|
||||
draft[index] = { ...draft[index], [fieldKey]: numValue };
|
||||
const next = recalculateBizCstTotals(draft, costItems);
|
||||
@@ -94,6 +103,14 @@ function BusinessCostFormSection() {
|
||||
<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="20%" />
|
||||
<col width="20%" />
|
||||
<col width="20%" />
|
||||
<col width="20%" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30rem]" rowSpan={2}>
|
||||
@@ -114,7 +131,10 @@ function BusinessCostFormSection() {
|
||||
const data = bizCstList[idx];
|
||||
if (!data) return null;
|
||||
|
||||
const diff = (data.tstmDesignAmt || 0) - (data.bgtRfltAmt || 0);
|
||||
const diff = roundAmount(
|
||||
(data.tstmDesignAmt || 0) - (data.bgtRfltAmt || 0),
|
||||
COST_DECIMAL_PLACES,
|
||||
);
|
||||
const paddingLeft = `${item.level * 1.6}rem`;
|
||||
const showSubIcon = item.level >= 2;
|
||||
|
||||
@@ -128,39 +148,21 @@ function BusinessCostFormSection() {
|
||||
{item.label}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Field>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.bgtRfltAmt ?? ""}
|
||||
onChange={(e) => handleCostChange(idx, "bgtRfltAmt", e.target.value)}
|
||||
readOnly={item.readonly}
|
||||
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
|
||||
/>
|
||||
</Field>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Field>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.bscPlanAmt ?? ""}
|
||||
onChange={(e) => handleCostChange(idx, "bscPlanAmt", e.target.value)}
|
||||
readOnly={item.readonly}
|
||||
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
|
||||
/>
|
||||
</Field>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Field>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.tstmDesignAmt ?? ""}
|
||||
onChange={(e) => handleCostChange(idx, "tstmDesignAmt", e.target.value)}
|
||||
readOnly={item.readonly}
|
||||
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
|
||||
/>
|
||||
</Field>
|
||||
</TableCell>
|
||||
{COST_FIELDS.map((fieldKey) => (
|
||||
<TableCell key={fieldKey}>
|
||||
<Field>
|
||||
<Input
|
||||
{...NON_NEGATIVE_INPUT_PROPS}
|
||||
type="number"
|
||||
step={COST_STEP}
|
||||
value={data[fieldKey] ?? ""}
|
||||
onChange={(e) => handleCostChange(idx, fieldKey, e.target.value)}
|
||||
readOnly={item.readonly}
|
||||
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
|
||||
/>
|
||||
</Field>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { useCnstpSeMngTree } from "@/entities/cnstp";
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from "@common/components";
|
||||
|
||||
import { buildCnstpScaleItems, recalculateBzScaleTotals } from "../lib/business-scale-form-utils";
|
||||
import { truncateDecimals } from "../lib/format-utils";
|
||||
|
||||
interface BusinessScaleFormData {
|
||||
bzScaleList: Array<{
|
||||
@@ -31,6 +33,8 @@ interface BusinessScaleFormData {
|
||||
|
||||
type ScaleFieldKey = "bscPl" | "budgetReflct" | "thtiDsgn";
|
||||
|
||||
const SCALE_FIELDS: ScaleFieldKey[] = ["bscPl", "budgetReflct", "thtiDsgn"];
|
||||
|
||||
function isSameBzScaleCodes(
|
||||
current: BusinessScaleFormData["bzScaleList"],
|
||||
next: BusinessScaleFormData["bzScaleList"],
|
||||
@@ -73,7 +77,10 @@ function BusinessScaleFormSection() {
|
||||
}, [scaleItems, getValues, setValue]);
|
||||
|
||||
function handleScaleChange(index: number, fieldKey: ScaleFieldKey, value: string) {
|
||||
const numValue = Number(value) || 0;
|
||||
const item = scaleItems[index];
|
||||
const unsigned = stripMinusSign(value);
|
||||
const normalized = item?.numYn ? truncateDecimals(unsigned, item.dcptUnitCnt) : unsigned;
|
||||
const numValue = Number(normalized) || 0;
|
||||
const draft = [...getValues("bzScaleList")];
|
||||
draft[index] = { ...draft[index], [fieldKey]: numValue };
|
||||
const next = recalculateBzScaleTotals(draft, scaleItems);
|
||||
@@ -95,6 +102,13 @@ function BusinessScaleFormSection() {
|
||||
<SectionCollapsible title="사업규모">
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="20%" />
|
||||
<col width="20%" />
|
||||
<col width="20%" />
|
||||
<col width="20%" />
|
||||
<col width="20%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30rem]">구분</TableHead>
|
||||
@@ -123,39 +137,21 @@ function BusinessScaleFormSection() {
|
||||
{item.label}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Field>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.bscPl ?? ""}
|
||||
onChange={(e) => handleScaleChange(idx, "bscPl", e.target.value)}
|
||||
readOnly={item.readonly}
|
||||
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
|
||||
/>
|
||||
</Field>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Field>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.budgetReflct ?? ""}
|
||||
onChange={(e) => handleScaleChange(idx, "budgetReflct", e.target.value)}
|
||||
readOnly={item.readonly}
|
||||
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
|
||||
/>
|
||||
</Field>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Field>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.thtiDsgn ?? ""}
|
||||
onChange={(e) => handleScaleChange(idx, "thtiDsgn", e.target.value)}
|
||||
readOnly={item.readonly}
|
||||
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
|
||||
/>
|
||||
</Field>
|
||||
</TableCell>
|
||||
{SCALE_FIELDS.map((fieldKey) => (
|
||||
<TableCell key={fieldKey}>
|
||||
<Field>
|
||||
<Input
|
||||
{...(item.numYn ? NON_NEGATIVE_INPUT_PROPS : {})}
|
||||
type={item.numYn ? "number" : "text"}
|
||||
step={item.numYn ? item.step : undefined}
|
||||
value={data[fieldKey] ?? ""}
|
||||
onChange={(e) => handleScaleChange(idx, fieldKey, e.target.value)}
|
||||
readOnly={item.readonly}
|
||||
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
|
||||
/>
|
||||
</Field>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import {
|
||||
getDefaultFileKey,
|
||||
NON_NEGATIVE_INPUT_PROPS,
|
||||
stripMinusSign,
|
||||
useTus,
|
||||
} from "@/shared/lib";
|
||||
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
@@ -142,7 +147,7 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
|
||||
if (fieldKey === "rmrkCn") {
|
||||
draft[index] = { ...row, rmrkCn: value };
|
||||
} else {
|
||||
const numValue = Number(value) || 0;
|
||||
const numValue = Number(stripMinusSign(value)) || 0;
|
||||
draft[index] = { ...row, [fieldKey]: numValue };
|
||||
}
|
||||
|
||||
@@ -236,6 +241,7 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
|
||||
<TableCell>
|
||||
<Field>
|
||||
<Input
|
||||
{...NON_NEGATIVE_INPUT_PROPS}
|
||||
type="number"
|
||||
value={data.exstnAmt ?? ""}
|
||||
onChange={(e) => handleSharChange(idx, "exstnAmt", e.target.value)}
|
||||
@@ -247,6 +253,7 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
|
||||
<TableCell>
|
||||
<Field>
|
||||
<Input
|
||||
{...NON_NEGATIVE_INPUT_PROPS}
|
||||
type="number"
|
||||
value={data.chgRqtAmt ?? ""}
|
||||
onChange={(e) => handleSharChange(idx, "chgRqtAmt", e.target.value)}
|
||||
|
||||
@@ -4,11 +4,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import { Field, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import {
|
||||
type CmCdAdstDstrComboResponseDto,
|
||||
useFindUserGugunCombo,
|
||||
useFindUserSidoCombo,
|
||||
} from "@/entities/adstdstr";
|
||||
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
|
||||
import { useCodeList } from "@/entities/code";
|
||||
import {
|
||||
Button,
|
||||
@@ -46,8 +42,6 @@ interface PbadmsTaskApplyDialogProps {
|
||||
codeUpCd?: string;
|
||||
}
|
||||
|
||||
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
|
||||
|
||||
function PbadmsTaskApplyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -60,18 +54,15 @@ function PbadmsTaskApplyDialog({
|
||||
const [sggComCd, setSggComCd] = useState("");
|
||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||
|
||||
const sidoFilterUserInfo =
|
||||
userInfo?.dtlInstCd === "ROLE_USER04" || userInfo?.dtlInstCd === "ROLE_USER05"
|
||||
? userInfo
|
||||
: undefined;
|
||||
|
||||
const { data: sidoData, isPending: isSidoPending } = useFindUserSidoCombo(sidoFilterUserInfo);
|
||||
const { data: gugunData, isPending: isGugunPending } = useFindUserGugunCombo(sidoComCd);
|
||||
const {
|
||||
userSidoData,
|
||||
userGugunData,
|
||||
isUserSidoPending,
|
||||
isUserGugunPending,
|
||||
} = useFindUserSidoGugunCombo(sidoComCd);
|
||||
const { data: taskOptions, isPending: isTaskSePending } = useCodeList(codeUpCd);
|
||||
|
||||
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
|
||||
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
|
||||
const isPending = isSidoPending || isTaskSePending;
|
||||
const isPending = isUserSidoPending || isTaskSePending;
|
||||
const canSidoGugunSelectable = userInfo?.dtlInstCd !== "ROLE_USER05";
|
||||
const canConfirm = selectedCodes.length > 0 && (!canSidoGugunSelectable || !!sidoComCd);
|
||||
|
||||
@@ -93,8 +84,8 @@ function PbadmsTaskApplyDialog({
|
||||
function handleConfirm() {
|
||||
if (!canConfirm) return;
|
||||
|
||||
const sidoOption = sidoOptions.find((option) => option.adstDstrCd === sidoComCd);
|
||||
const gugunOption = gugunOptions.find((option) => option.adstDstrCd === sggComCd);
|
||||
const sidoOption = userSidoData.find((option) => option.adstDstrCd === sidoComCd);
|
||||
const gugunOption = userGugunData.find((option) => option.adstDstrCd === sggComCd);
|
||||
|
||||
onConfirm({
|
||||
admdstCd: sggComCd || sidoComCd,
|
||||
@@ -137,7 +128,7 @@ function PbadmsTaskApplyDialog({
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sidoOptions.map((option) => (
|
||||
{userSidoData.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
@@ -150,13 +141,13 @@ function PbadmsTaskApplyDialog({
|
||||
<Select
|
||||
value={sggComCd || undefined}
|
||||
onValueChange={setSggComCd}
|
||||
disabled={!sidoComCd || isGugunPending}
|
||||
disabled={!sidoComCd || isUserGugunPending}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{gugunOptions.map((option) => (
|
||||
{userGugunData.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { create } from "zustand";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PplLengthContent, SewerageService } from "../model/sn-interface";
|
||||
import type { BasinStofcbrCountContent, PplLengthContent, SewerageService } from "../model/sn-interface";
|
||||
import type { OldPplChartData, mapPplOldType, mapPplOldItem } from "../model/sn-interface";
|
||||
|
||||
export type SnState = {
|
||||
@@ -35,26 +35,7 @@ const initialUsePplChartData: OldPplChartData = {
|
||||
};
|
||||
const initialOldPplChartData: OldPplChartData = {
|
||||
labels: ["금강", "한강", "낙동강", "영산강"],
|
||||
datasets: [
|
||||
{
|
||||
label: "20-25년",
|
||||
data: [1140, 3181, 3100, 1662],
|
||||
backgroundColor: "#3B82F6",
|
||||
borderRadius: 4
|
||||
},
|
||||
{
|
||||
label: "25-30년",
|
||||
data: [1391, 3615, 1814, 1411],
|
||||
backgroundColor: "#EF4444",
|
||||
borderRadius: 4
|
||||
},
|
||||
{
|
||||
label: "30년이상",
|
||||
data: [2014, 10303, 9831, 2000],
|
||||
backgroundColor: "#F87171",
|
||||
borderRadius: 4
|
||||
}
|
||||
]
|
||||
datasets: []
|
||||
};
|
||||
type OldPplStoreState = {
|
||||
chartData: OldPplChartData;
|
||||
@@ -70,23 +51,122 @@ export const useUsePplStore = create<OldPplStoreState>((set) => ({
|
||||
}));
|
||||
|
||||
export const initailMapOldPplData: mapPplOldType = {
|
||||
colTitle: { totalLen: "총길이", pplLen2025: "20~25년", pplLen2530: "25~30년", pplLen30: "30년 이상" }, //헤더 영역
|
||||
colTitle: { stofcbrCount: "우수토실개소수", totalLen: "하수관로총연장" },
|
||||
data: [
|
||||
{ name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30: 2014 },
|
||||
{ name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30: 10303 },
|
||||
{ name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30: 9831 },
|
||||
{ name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30: 2000 },
|
||||
{ name: "금강", totalLen: 0, stofcbrCount: 0 },
|
||||
{ name: "한강", totalLen: 0, stofcbrCount: 0 },
|
||||
{ name: "낙동강", totalLen: 0, stofcbrCount: 0 },
|
||||
{ name: "영산강", totalLen: 0, stofcbrCount: 0 },
|
||||
]
|
||||
};
|
||||
type mapPplOldState = {
|
||||
mapData: mapPplOldItem[];
|
||||
setMapData: (mapData: mapPplOldItem[]) => void;
|
||||
setStofcbrCounts: (counts: BasinStofcbrCountContent[]) => void;
|
||||
};
|
||||
export const useMapOldPplStore = create<mapPplOldState>((set) => ({
|
||||
mapData: initailMapOldPplData.data,
|
||||
setMapData: (newMapData) => set({ mapData: newMapData }),
|
||||
setMapData: (newMapData) => set((state) => ({
|
||||
mapData: newMapData.map((item) => ({
|
||||
...item,
|
||||
stofcbrCount: state.mapData.find((current) => current.name === item.name)?.stofcbrCount ?? item.stofcbrCount,
|
||||
})),
|
||||
})),
|
||||
setStofcbrCounts: (counts) => set((state) => ({
|
||||
mapData: state.mapData.map((item) => ({
|
||||
...item,
|
||||
stofcbrCount: counts.find((count) => count.zoneNm === item.name)?.stofcbrCount ?? item.stofcbrCount,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
const DEFAULT_BASIN_ORDER = ["금강", "한강", "낙동강", "영산강"];
|
||||
const YEAR_GROUP_START_COLOR = "#FACC15";
|
||||
const YEAR_GROUP_END_COLOR = "#F87171";
|
||||
|
||||
/**
|
||||
* 시작색과 종료색 사이를 RGB 기준으로 보간해 구간 수만큼 색상을 생성합니다.
|
||||
* 구간이 하나면 시작색을, 둘 이상이면 첫 구간은 파란색·마지막 구간은 빨간색으로 고정합니다.
|
||||
*/
|
||||
function buildYearGroupColors(count: number): string[] {
|
||||
if (count <= 0) return [];
|
||||
if (count === 1) return [YEAR_GROUP_START_COLOR];
|
||||
|
||||
const parseHex = (color: string) => [
|
||||
Number.parseInt(color.slice(1, 3), 16),
|
||||
Number.parseInt(color.slice(3, 5), 16),
|
||||
Number.parseInt(color.slice(5, 7), 16),
|
||||
];
|
||||
const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0");
|
||||
const start = parseHex(YEAR_GROUP_START_COLOR);
|
||||
const end = parseHex(YEAR_GROUP_END_COLOR);
|
||||
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const ratio = index / (count - 1);
|
||||
return `#${start.map((channel, channelIndex) => {
|
||||
const endChannel = end[channelIndex] ?? channel;
|
||||
return toHex(channel + (endChannel - channel) * ratio);
|
||||
}).join("")}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 별도 정렬순서가 없는 MV 계약에서 구간명의 첫 숫자를 기준으로 표시순서를 결정합니다.
|
||||
* 숫자가 없는 비정형 구간은 숫자 구간 뒤에 한글 오름차순으로 배치합니다.
|
||||
*/
|
||||
function compareYearGroups(left: string, right: string): number {
|
||||
const leftNumber = left.match(/\d+/)?.[0];
|
||||
const rightNumber = right.match(/\d+/)?.[0];
|
||||
|
||||
if (leftNumber !== undefined && rightNumber !== undefined) {
|
||||
const numberDifference = Number(leftNumber) - Number(rightNumber);
|
||||
if (numberDifference !== 0) return numberDifference;
|
||||
} else if (leftNumber !== undefined) {
|
||||
return -1;
|
||||
} else if (rightNumber !== undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return left.localeCompare(right, "ko");
|
||||
}
|
||||
|
||||
/**
|
||||
* MV 응답의 yearGroup 목록으로 노후관로 차트 데이터를 동적으로 생성합니다.
|
||||
* 구간명이나 구간 개수가 바뀌어도 고정 매핑 없이 동일한 로직을 사용합니다.
|
||||
*/
|
||||
export function buildOldPplChartData(items: PplLengthContent[]): OldPplChartData {
|
||||
const responseZones = [...new Set(items.map((item) => item.zoneNm).filter(Boolean))];
|
||||
const labels = [
|
||||
...DEFAULT_BASIN_ORDER,
|
||||
...responseZones.filter((zone) => !DEFAULT_BASIN_ORDER.includes(zone)).sort((left, right) => left.localeCompare(right, "ko")),
|
||||
];
|
||||
const yearGroups = [
|
||||
...new Set(
|
||||
items
|
||||
.map((item) => item.yearGroup?.trim())
|
||||
.filter((yearGroup): yearGroup is string => Boolean(yearGroup)),
|
||||
),
|
||||
].sort(compareYearGroups);
|
||||
const colors = buildYearGroupColors(yearGroups.length);
|
||||
|
||||
const lengthByZoneAndGroup = new Map<string, number>();
|
||||
items.forEach((item) => {
|
||||
const yearGroup = item.yearGroup?.trim();
|
||||
if (!item.zoneNm || !yearGroup) return;
|
||||
lengthByZoneAndGroup.set(`${item.zoneNm}\u0000${yearGroup}`, Number(item.sumPrlgVl) || 0);
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: yearGroups.map((yearGroup, index) => ({
|
||||
label: yearGroup,
|
||||
data: labels.map((zone) => lengthByZoneAndGroup.get(`${zone}\u0000${yearGroup}`) ?? 0),
|
||||
backgroundColor: colors[index] ?? YEAR_GROUP_START_COLOR,
|
||||
borderRadius: 4,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OldPplChartData를 지도 팝업 데이터 형태인 mapPplOldItem[]으로 변환합니다.
|
||||
* @param chartData 차트 데이터
|
||||
@@ -96,22 +176,16 @@ export function transformChartDataToMapData(chartData: OldPplChartData): mapPplO
|
||||
const mapData: mapPplOldItem[] = [];
|
||||
const { labels, datasets } = chartData;
|
||||
|
||||
const pplLen2025Data = datasets.find(d => d.label === '20-25년')?.data || [];
|
||||
const pplLen2530Data = datasets.find(d => d.label === '25-30년')?.data || [];
|
||||
const pplLen30Data = datasets.find(d => d.label === '30년이상')?.data || [];
|
||||
|
||||
labels.forEach((label, index) => {
|
||||
const pplLen2025 = pplLen2025Data[index] || 0;
|
||||
const pplLen2530 = pplLen2530Data[index] || 0;
|
||||
const pplLen30 = pplLen30Data[index] || 0;
|
||||
const totalLen = pplLen2025 + pplLen2530 + pplLen30;
|
||||
const totalLen = datasets.reduce((sum, dataset) => {
|
||||
const value = dataset.data[index];
|
||||
return sum + (typeof value === "number" ? value : 0);
|
||||
}, 0);
|
||||
|
||||
mapData.push({
|
||||
name: label,
|
||||
totalLen,
|
||||
pplLen2025,
|
||||
pplLen2530,
|
||||
pplLen30,
|
||||
stofcbrCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,7 +245,7 @@ export class SewerageApiService implements SewerageService {
|
||||
}));
|
||||
return list;
|
||||
}
|
||||
// 유역별 노후도별 관로 길이(km) MV조회 : yearGroup - 20년~25년미만,25년~30년미만,30년이상
|
||||
// 유역별 노후도별 관로 길이(km) MV조회: yearGroup은 MV가 제공하는 동적 구간명
|
||||
public async getMvOldPplLength(): Promise<PplLengthContent[]> {
|
||||
const response = await client.get<{
|
||||
PplLengthList: PplLengthContent[];
|
||||
@@ -184,6 +258,18 @@ export class SewerageApiService implements SewerageService {
|
||||
return list;
|
||||
}
|
||||
|
||||
// CM 대시보드 유역별 우수토실 개소수 조회
|
||||
public async getBasinStofcbrCount(): Promise<BasinStofcbrCountContent[]> {
|
||||
const response = await client.get<{
|
||||
basinStofcbrCountList: BasinStofcbrCountContent[];
|
||||
}>(ENDPOINT.SEWERAGE_SERVICE.BASIN_STOFCBR_COUNT);
|
||||
|
||||
return (response.data.basinStofcbrCountList ?? []).map((item) => ({
|
||||
...item,
|
||||
stofcbrCount: Number(item.stofcbrCount),
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const SewerageApi = new SewerageApiService();
|
||||
|
||||
@@ -12,7 +12,7 @@ import { svcInfo } from "../model/map-data";
|
||||
import type { mapOperFcltType, mapReuseMapType} from "../model/fo-interface";
|
||||
import type { mapPplOldType } from "../model/sn-interface";
|
||||
import { useMapOperFcltStore, OperationApi, useMapReuseMapStore } from "../api/fo-service";
|
||||
import { useMapOldPplStore } from "../api/sn-service";
|
||||
import { SewerageApi, useMapOldPplStore } from "../api/sn-service";
|
||||
import {Button} from "@common/components";
|
||||
|
||||
const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요
|
||||
@@ -31,12 +31,12 @@ const mapOperFcltData: mapOperFcltType = {
|
||||
]
|
||||
};
|
||||
const mapPplOldData: mapPplOldType = {
|
||||
colTitle: {totalLen: "총길이",pplLen2025: "20~25년",pplLen2530: "25~30년",pplLen30 : "30년 이상" }, //헤더 영역
|
||||
colTitle: {stofcbrCount: "우수토실개소수", totalLen: "하수관로총연장" },
|
||||
data: [
|
||||
{name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30 : 2014},
|
||||
{name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30 : 10303},
|
||||
{name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30 : 9831},
|
||||
{name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30 : 2000},
|
||||
{name: "금강", totalLen: 0, stofcbrCount: 0},
|
||||
{name: "한강", totalLen: 0, stofcbrCount: 0},
|
||||
{name: "낙동강", totalLen: 0, stofcbrCount: 0},
|
||||
{name: "영산강", totalLen: 0, stofcbrCount: 0},
|
||||
]
|
||||
};
|
||||
const mapReuseMapData: mapReuseMapType = {
|
||||
@@ -62,17 +62,18 @@ export function KoreaBasinMap() {
|
||||
const { selectedBasin, setSelectedBasin } = useStore(basinStore);
|
||||
const selectedBasinKey = basinKeyMap[selectedBasin];
|
||||
const {mapOperFclt} = useMapOperFcltStore();
|
||||
const {mapData: mapPplOld} = useMapOldPplStore();
|
||||
const {mapData: mapPplOld, setStofcbrCounts} = useMapOldPplStore();
|
||||
const {setMapData: setMapReuseMapData} = useMapReuseMapStore();
|
||||
mapOperFcltData.data = mapOperFclt;
|
||||
mapPplOldData.data = mapPplOld;
|
||||
// mapReuseMapData.data = mapReuseMap;
|
||||
let standardYear = '2024';
|
||||
const [standardYear, setStandardYear] = useState('2024');
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
standardYear = await OperationApi.getStandardYear();
|
||||
const mapReuseMapRslt = await OperationApi.getReuseMap(standardYear);
|
||||
const fetchedStandardYear = await OperationApi.getStandardYear();
|
||||
setStandardYear(fetchedStandardYear);
|
||||
const mapReuseMapRslt = await OperationApi.getReuseMap(fetchedStandardYear);
|
||||
setMapReuseMapData(mapReuseMapRslt);
|
||||
mapReuseMapData.data = mapReuseMapRslt;
|
||||
} catch (e) {
|
||||
@@ -80,7 +81,13 @@ export function KoreaBasinMap() {
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
}, [setMapReuseMapData]);
|
||||
|
||||
useEffect(() => {
|
||||
SewerageApi.getBasinStofcbrCount()
|
||||
.then(setStofcbrCounts)
|
||||
.catch((e) => console.error("유역별 우수토실 개소수 조회 실패", e));
|
||||
}, [setStofcbrCounts]);
|
||||
// 유역정보
|
||||
const showFor = useCallback((g: SVGGElement) => {
|
||||
const activeService = services.find(s => s.active);
|
||||
@@ -104,24 +111,16 @@ export function KoreaBasinMap() {
|
||||
});
|
||||
} else if (activeService.svcId === 'sn') { // 하수관로
|
||||
const item = mapPplOldData.data.find(d => d.name === basinName);
|
||||
newPopupContent.title = `${standardYear}년도 ${basinName}유역 노후하수관로 현황`;
|
||||
newPopupContent.title = `${standardYear}년도 ${basinName}유역 하수관로 현황`;
|
||||
const colTitles = mapPplOldData.colTitle;
|
||||
newPopupContent.items.push({
|
||||
header: colTitles.stofcbrCount,
|
||||
data: item ? `${fmt(item.stofcbrCount)}개소` : '-',
|
||||
});
|
||||
newPopupContent.items.push({
|
||||
header: colTitles.totalLen,
|
||||
data: item ? `${fmt(item.totalLen)}km` : '-',
|
||||
});
|
||||
newPopupContent.items.push({
|
||||
header: colTitles.pplLen2025,
|
||||
data: item ? `${fmt(item.pplLen2025)}km` : '-',
|
||||
});
|
||||
newPopupContent.items.push({
|
||||
header: colTitles.pplLen2530,
|
||||
data: item ? `${fmt(item.pplLen2530)}km` : '-',
|
||||
});
|
||||
newPopupContent.items.push({
|
||||
header: colTitles.pplLen30,
|
||||
data: item ? `${fmt(item.pplLen30)}km` : '-',
|
||||
});
|
||||
} else if (activeService.svcId === 'wr') { // 물재이용
|
||||
const item = mapReuseMapData.data.find(d => d.name === basinName);
|
||||
newPopupContent.title = `${standardYear}년도 ${basinName}유역 물재이용 현황`;
|
||||
@@ -160,7 +159,7 @@ export function KoreaBasinMap() {
|
||||
);
|
||||
popupRef.current.style.left = x + "px";
|
||||
}
|
||||
}, [services, mapOperFclt]);
|
||||
}, [services, mapOperFclt, mapPplOld, standardYear]);
|
||||
|
||||
// selectedBasinKey prop이 변경될 때 지도를 업데이트하는 useEffect
|
||||
useEffect(() => {
|
||||
@@ -410,8 +409,14 @@ export function KoreaBasinMap() {
|
||||
};
|
||||
// 스타일은 필요에 따라 tailwind, css 등으로 커스터마이즈하세요.
|
||||
return (
|
||||
<div className="col-span-3 !p-0 relative z-10" style={{ height: "765px", background: "rgba(255, 255, 255, 0.6)" }}>
|
||||
<div className="card card-border" style={{ height: "100%" }}>
|
||||
<div
|
||||
className="absolute inset-0 z-10 col-span-3 h-full !p-0"
|
||||
style={{ background: "rgba(255, 255, 255, 0.6)" }}
|
||||
>
|
||||
<div
|
||||
className="card card-border"
|
||||
style={{ height: "calc(100% - 1.2rem)" }}
|
||||
>
|
||||
<div className="card-header text-foreground">
|
||||
<h3 className="text-2xl font-medium">유역별 정보</h3>
|
||||
|
||||
|
||||
@@ -6,11 +6,17 @@ export interface PplLengthContent {
|
||||
yearGroup?: string;
|
||||
}
|
||||
|
||||
export interface BasinStofcbrCountContent {
|
||||
zoneNm: string;
|
||||
stofcbrCount: number;
|
||||
}
|
||||
|
||||
export interface SewerageService {
|
||||
getPplLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
|
||||
getPplUseLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
|
||||
getMvUsgPplLength(): Promise<PplLengthContent[]>;
|
||||
getMvOldPplLength(): Promise<PplLengthContent[]>;
|
||||
getBasinStofcbrCount(): Promise<BasinStofcbrCountContent[]>;
|
||||
}
|
||||
|
||||
export interface ChartDataSet {
|
||||
@@ -37,17 +43,13 @@ type SnOldPplLength = {
|
||||
export type mapPplOldItem = {
|
||||
name: string;
|
||||
totalLen: number;
|
||||
pplLen2025: number;
|
||||
pplLen2530: number;
|
||||
pplLen30: number;
|
||||
stofcbrCount: number;
|
||||
};
|
||||
|
||||
export type mapPplOldType = {
|
||||
colTitle: {
|
||||
totalLen: string; // 총길이
|
||||
pplLen2025: string; // 20~25년
|
||||
pplLen2530: string; // 25~30년
|
||||
pplLen30: string; // 30년 이상
|
||||
stofcbrCount: string; // 우수토실개소수
|
||||
totalLen: string; // 총연장
|
||||
};
|
||||
data: mapPplOldItem[];
|
||||
};
|
||||
@@ -82,4 +84,4 @@ export function buildSnOldPplLength(
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ function DashboardPage() {
|
||||
<div className="p-2 sm:p-4 md:p-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* 첫번째 라인 - 좌측 6개 패널(2x3) + 우측 지도/사용자 정보 */}
|
||||
<div className="flex flex-col gap-1 lg:flex-row">
|
||||
<div className="flex flex-col gap-1 lg:flex-row lg:items-stretch">
|
||||
{/* 좌측: 드래그 가능한 6개 패널 (2x3 그리드, 화면의 50%) */}
|
||||
<div className="w-full lg:w-1/2">
|
||||
{isMounted ? (
|
||||
@@ -173,9 +173,9 @@ function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* 우측: 고정된 지도와 사용자 정보 (화면의 50%) */}
|
||||
<div className="flex w-full gap-1 lg:w-1/2">
|
||||
<div className="flex w-full flex-col items-stretch gap-1 lg:w-1/2 lg:flex-row">
|
||||
{/* 지도영역 - 고정 (드래그 불가) */}
|
||||
<div className="flex-1">
|
||||
<div className="relative min-h-[765px] w-full self-stretch lg:min-h-0 lg:flex-1">
|
||||
<KoreaBasinMap />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
} from "chart.js";
|
||||
import { Bar } from "react-chartjs-2";
|
||||
import { Button } from "@common/components";
|
||||
import type { PplLengthContent, ChartDataSet, OldPplChartData } from "../model/sn-interface";
|
||||
import { SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service";
|
||||
import type { PplLengthContent } from "../model/sn-interface";
|
||||
import { buildOldPplChartData, SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service";
|
||||
import { useDragHandle } from "./sortable-panel";
|
||||
|
||||
|
||||
@@ -41,45 +41,7 @@ export default function SnOldPpl() {
|
||||
useEffect(() => {
|
||||
if (oldPplLenData.length === 0) return;
|
||||
|
||||
// 유역 목록 추출 (중복 제거)
|
||||
const allZones = [...new Set(oldPplLenData.map(d => d.zoneNm))];
|
||||
|
||||
// yearGroup 매핑: API 응답값 -> 차트 label
|
||||
const yearGroupMap: { [key: string]: { label: string; color: string } } = {
|
||||
"20년~25년미만": { label: "20-25년", color: "#3B82F6" },
|
||||
"25년~30년미만": { label: "25-30년", color: "#EF4444" },
|
||||
"30년이상": { label: "30년이상", color: "#F87171" },
|
||||
};
|
||||
|
||||
// 유역별, 노후년도별 데이터 구조화
|
||||
const dataByZone: { [zone: string]: { [yearGroup: string]: number } } = {};
|
||||
allZones.forEach(zone => {
|
||||
dataByZone[zone] = {};
|
||||
});
|
||||
|
||||
oldPplLenData.forEach(item => {
|
||||
let zoneData = dataByZone[item.zoneNm];
|
||||
if (!zoneData) {
|
||||
zoneData = {};
|
||||
dataByZone[item.zoneNm] = zoneData;
|
||||
}
|
||||
if (item.yearGroup) {
|
||||
zoneData[item.yearGroup] = item.sumPrlgVl;
|
||||
}
|
||||
});
|
||||
|
||||
// 데이터셋 생성
|
||||
const newDatasets: ChartDataSet[] = Object.entries(yearGroupMap).map(([yearGroup, { label, color }]) => ({
|
||||
label,
|
||||
data: allZones.map(zone => dataByZone[zone]?.[yearGroup] ?? 0),
|
||||
backgroundColor: color,
|
||||
borderRadius: 4,
|
||||
}));
|
||||
|
||||
const newChartData: OldPplChartData = {
|
||||
labels: allZones,
|
||||
datasets: newDatasets,
|
||||
};
|
||||
const newChartData = buildOldPplChartData(oldPplLenData);
|
||||
|
||||
setChartData(newChartData);
|
||||
|
||||
@@ -110,9 +72,7 @@ export default function SnOldPpl() {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
min: 0,
|
||||
max: 10000,
|
||||
ticks: {
|
||||
stepSize: 2000,
|
||||
callback: function (value) {
|
||||
return Number(value).toLocaleString() + 'km';
|
||||
}
|
||||
@@ -192,4 +152,4 @@ export default function SnOldPpl() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export const ENDPOINT = {
|
||||
OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts",
|
||||
MV_USG_PPL_LENGTH: "/sewerage-service/api/v1/ms/mvUsgPplLength",
|
||||
MV_OLD_PPL_LENGTH: "/sewerage-service/api/v1/ms/mvOldPplLength",
|
||||
BASIN_STOFCBR_COUNT: "/sewerage-service/api/v1/ms/basinStofcbrCount",
|
||||
},
|
||||
OPERATION_SERVICE: {
|
||||
MAIN_YEAR: "/operation-service/api/v1/dashBoard/main/year",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
"use client";
|
||||
|
||||
export { PostPage as NoticePage } from "@/widgets/post";
|
||||
import { PostPage } from "@/widgets/post";
|
||||
|
||||
export function NoticePage({ bbsId }: { bbsId: string }) {
|
||||
return <PostPage bbsId={bbsId} tableCaption="공지사항 목록" />;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,13 @@ import { columns as tableColumns } from "../model/post-table-columns";
|
||||
import { createColumns as createGridViewColumns } from "../model/grid-view-table-columns";
|
||||
import { SortSelect } from "./sort-select";
|
||||
|
||||
function PostPage({ bbsId }: { bbsId: string }) {
|
||||
function PostPage({
|
||||
bbsId,
|
||||
tableCaption,
|
||||
}: {
|
||||
bbsId: string;
|
||||
tableCaption?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { confirm } = useConfirm();
|
||||
@@ -116,8 +122,6 @@ function PostPage({ bbsId }: { bbsId: string }) {
|
||||
meta: { bbsTypeCd: bbsInfo?.bbsTypeCd },
|
||||
});
|
||||
|
||||
const DataTableComponent = isGridViewPage ? GridViewDataTable : DataTable;
|
||||
|
||||
const onPostDelete = async () => {
|
||||
const selectedPosts = table
|
||||
.getFilteredSelectedRowModel()
|
||||
@@ -245,11 +249,20 @@ function PostPage({ bbsId }: { bbsId: string }) {
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[36rem] w-full" />
|
||||
) : (
|
||||
<DataTableComponent
|
||||
table={table}
|
||||
onRowClick={handleRowClick}
|
||||
viewPagination
|
||||
/>
|
||||
isGridViewPage ? (
|
||||
<GridViewDataTable
|
||||
table={table}
|
||||
onRowClick={handleRowClick}
|
||||
viewPagination
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
table={table}
|
||||
onRowClick={handleRowClick}
|
||||
viewPagination
|
||||
caption={tableCaption}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { PageableList, PageableParams } from "@/shared/model";
|
||||
|
||||
export interface AuthorizationsParams extends PageableParams {
|
||||
keywordType?: string;
|
||||
keyword?: string;
|
||||
authorizationName?: string;
|
||||
urlPatternValue?: string;
|
||||
httpMethodCode?: string;
|
||||
useYn?: string;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { columns } from "../model/authorizations-table-columns";
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
authorizationName: "",
|
||||
urlPatternValue: "",
|
||||
httpMethodCode: "",
|
||||
sort: "regDt,desc",
|
||||
page: 0,
|
||||
@@ -36,6 +37,9 @@ function ModulePage() {
|
||||
const [authorizationName, setAuthorizationName] = useState(
|
||||
params.authorizationName,
|
||||
);
|
||||
const [urlPatternValue, setUrlPatternValue] = useState(
|
||||
params.urlPatternValue,
|
||||
);
|
||||
const [httpMethodCode, setHttpMethodCode] = useState(params.httpMethodCode);
|
||||
|
||||
const query = useQuery({
|
||||
@@ -43,6 +47,7 @@ function ModulePage() {
|
||||
queryFn: () =>
|
||||
AuthorizationsApi.getAuthorizationsList({
|
||||
authorizationName: params.authorizationName,
|
||||
urlPatternValue: params.urlPatternValue,
|
||||
httpMethodCode: params.httpMethodCode,
|
||||
sort: params.sort,
|
||||
page: params.page,
|
||||
@@ -63,11 +68,12 @@ function ModulePage() {
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setParams({ authorizationName, httpMethodCode, page: 0 });
|
||||
setParams({ authorizationName, urlPatternValue, httpMethodCode, page: 0 });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setAuthorizationName("");
|
||||
setUrlPatternValue("");
|
||||
setHttpMethodCode("");
|
||||
resetParams();
|
||||
};
|
||||
@@ -113,12 +119,23 @@ function ModulePage() {
|
||||
API명
|
||||
</Label>
|
||||
<Input
|
||||
id="keyword"
|
||||
id="authorizationName"
|
||||
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
|
||||
value={authorizationName}
|
||||
onChange={(e) => setAuthorizationName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="urlPatternValue" className="text-label">
|
||||
API URL
|
||||
</Label>
|
||||
<Input
|
||||
id="urlPatternValue"
|
||||
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
|
||||
value={urlPatternValue}
|
||||
onChange={(e) => setUrlPatternValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="h-[3.2rem]" type="submit">
|
||||
조회
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useMenuStore, useMutationWrapper } from "@/shared/lib";
|
||||
import { AttachmentApi } from "@/entities/attachment";
|
||||
import { BoardApi } from "@/entities/board";
|
||||
import { PostApi } from "@/entities/post";
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
@@ -12,14 +19,11 @@ import {
|
||||
RadioGroupItem,
|
||||
Skeleton,
|
||||
} from "@common/components";
|
||||
import { AttachmentApi } from "@/entities/attachment";
|
||||
import { PostApi } from "@/entities/post";
|
||||
import { BoardApi } from "@/entities/board";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useMutationWrapper, useMenuStore } from "@/shared/lib";
|
||||
|
||||
import { PostEvaluationApi } from "../api/post-evaluation-service";
|
||||
|
||||
import { PostAction } from "./post-action";
|
||||
import { PostComment } from "./post-comment";
|
||||
import { PostEvaluationApi } from "../api/post-evaluation-service";
|
||||
|
||||
function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
|
||||
const router = useRouter();
|
||||
@@ -255,7 +259,9 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
|
||||
)}
|
||||
</div>
|
||||
</PostAction>
|
||||
<PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />
|
||||
{bbsInfo?.cmntUseYn === "Y" && (
|
||||
<PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-1
@@ -134,6 +134,8 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
|
||||
|
||||
## 작업 원칙
|
||||
|
||||
- 일반 목록·모든 팝업은 공통 AGENTS.md의 서버 페이징·지도·Excel 계약을 따른다. 지도 전체 표출에 현재 그리드 페이지 배열을 재사용하지 않는다. Excel은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다.
|
||||
|
||||
1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
|
||||
2. `apps/sn`과 `packages/common/business_components` 범위를 넘는 수정은 하지 않는다.
|
||||
3. 공통 패키지 수정은 `sn` 요구사항을 충족하기 위한 최소 범위로 제한한다.
|
||||
@@ -228,7 +230,7 @@ VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `ne
|
||||
|
||||
### 프론트엔드 실행 프로세스 관리
|
||||
|
||||
- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 임시 파일·결과 파일 정리 기준을 적용한다. 일회성 검증 스크립트·캡처·Network/서버 로그·원시 결과 파일은 최종 보고서에 요약 후 삭제한다. 정식 회귀 테스트·최종 산출물·사용자 원본/복구 백업은 유지한다. 사용자 dev server가 쓰는 .next·공유 캐시·node_modules는 임시 검증물이라는 이유로 포괄 삭제하지 않는다.
|
||||
- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 파일 정리 기준을 적용한다. 에이전트가 테스트를 위해 만든 파일은 정식 회귀 테스트·재사용 가능한 소스·fixture·mock·임시 라우트를 포함해 위치와 관계없이 결과를 요약한 뒤 작업 종료 전에 삭제한다(2026-09-23 사용자 지시). 전용 빈 폴더와 끊어진 실행 명령도 정리한다. 명시 보존 요청 파일·최종 산출물·사용자 원본/복구 백업은 유지한다. 기존 타 작업자 테스트 자산은 삭제 권한을 먼저 확인한다. 사용자 dev server의 .next·공유 캐시·node_modules는 포괄 삭제하지 않는다.
|
||||
|
||||
- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
|
||||
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { NoFooterLayout } from "@common/components";
|
||||
import type { MenuItem } from "@/entities/menu";
|
||||
import { useMenuStore } from "@/shared/lib";
|
||||
import useStore from '../store/useStore';
|
||||
import useStore from "@/entities/reference-data/model/reference-data-store";
|
||||
import { authService } from "@/entities/auth";
|
||||
|
||||
export default function MapLayout({
|
||||
|
||||
+2
-2
@@ -2,13 +2,13 @@
|
||||
"use client";
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { getRawJson } from "@/shared/api/sewerage-api";
|
||||
import {
|
||||
getAgedPipeStatusByCtpvValidationMessage,
|
||||
normalizeAgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvResponse,
|
||||
} from "./agedPipeStatusByCtpvUtils";
|
||||
} from "./aged-pipe-status-by-ctpv-utils";
|
||||
|
||||
/** 적용조건의 시도별 노후관로현황 전체를 조회해 연도 그룹 Excel로 생성한다. */
|
||||
export const downloadAgedPipeStatusByCtpvExcel = async (
|
||||
+8
-8
@@ -4,10 +4,10 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { getSnRowNumber } from "@/shared/lib/row-number";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Input } from "@common/components";
|
||||
import {
|
||||
getStandardRowNumber,
|
||||
StandardDataTable,
|
||||
StandardPopupLayout,
|
||||
} from "@common/business_components";
|
||||
@@ -18,10 +18,10 @@ import {
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { getRawJson } from "@/shared/api/sewerage-api";
|
||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
|
||||
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
|
||||
import {
|
||||
AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
buildAgedPipeStatusYearOptions,
|
||||
@@ -32,9 +32,9 @@ import {
|
||||
type AgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvResponse,
|
||||
type AgedPipeStatusByCtpvRow,
|
||||
} from "./agedPipeStatusByCtpvUtils";
|
||||
import { downloadAgedPipeStatusByCtpvExcel } from "./agedPipeStatusByCtpvExport";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
} from "./aged-pipe-status-by-ctpv-utils";
|
||||
import { downloadAgedPipeStatusByCtpvExcel } from "./aged-pipe-status-by-ctpv-export";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
|
||||
// CS 목록과 동일하게 한 페이지에는 최대 10개 시도만 표시한다.
|
||||
const AGED_PIPE_STATUS_PAGE_SIZE = 10;
|
||||
@@ -71,7 +71,7 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
id: "rowNumber",
|
||||
header: "순번",
|
||||
cell: ({ row }) =>
|
||||
getStandardRowNumber({
|
||||
getSnRowNumber({
|
||||
total: rows.length,
|
||||
rowIndex: row.index,
|
||||
}),
|
||||
+3
-7
@@ -1,13 +1,9 @@
|
||||
/** 노후관로 목록의 엑셀 다운로드 데이터와 서식을 생성한다. */
|
||||
"use client";
|
||||
|
||||
import { postBlobData } from "@/components/lib/api";
|
||||
import { fileDownload } from "@/components/lib/utils/fileDownload";
|
||||
import {
|
||||
AGED_PIPE_EXCEL_FIELDS,
|
||||
normalizeAgedPipeFilters,
|
||||
type AgedPipeFilters,
|
||||
} from "./agedPipeUtils";
|
||||
import { postBlobData } from "@/shared/api/file-api";
|
||||
import { fileDownload } from "@/components/lib/utils/file-download";
|
||||
import { AGED_PIPE_EXCEL_FIELDS, normalizeAgedPipeFilters, type AgedPipeFilters } from "@/entities/facility/lib/aged-pipe-utils";
|
||||
|
||||
type AgedPipeExportProgress = {
|
||||
fileName?: string;
|
||||
+2
-2
@@ -6,8 +6,8 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils";
|
||||
import LayerSettingCard from "../layerSetting/LayerSettingCard";
|
||||
import { validateElapsedYears, type AgedPipeFilters } from "@/entities/facility/lib/aged-pipe-utils";
|
||||
import LayerSettingCard from "../layer-setting/layer-setting-card";
|
||||
|
||||
type MaybePromise<T = void> = T | Promise<T>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user