This commit is contained in:
2026-10-02 19:40:06 +09:00
48 changed files with 598 additions and 191 deletions
@@ -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
View File
@@ -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;
}
+1 -1
View File
@@ -1 +1 @@
export { handleTusUpload, type TusUploadOptions } from "./lib/utils";
export { handleTusUpload, type TusErrorResponse, type TusUploadOptions } from "./lib/utils";
+35 -4
View File
@@ -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, "설계자 이름은 필수입니다"),
@@ -106,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;
@@ -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 } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
@@ -79,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]"
@@ -96,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]"
@@ -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, "설계자 이름은 필수입니다"),
@@ -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}>
@@ -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,6 +217,10 @@ 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(getListHref("/fnrs-cnsltn"));
@@ -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 ? (
@@ -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, "사업구분은 필수입니다"),
@@ -149,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;
@@ -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]"
@@ -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>
))}
+1
View File
@@ -2,6 +2,7 @@ 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";
+13
View File
@@ -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, "");
}
+46 -13
View File
@@ -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;
+7 -1
View File
@@ -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)}%`;
@@ -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)}
@@ -69,10 +69,13 @@ export interface ThreeStageStatusResponse {
statsYr: string;
laeStatusCd: string;
laeStatusNm: string;
laeRejected: boolean;
ctpvStatusCd: string;
ctpvStatusNm: string;
ctpvRejected: boolean;
pbcpStatusCd: string;
pbcpStatusNm: string;
pbcpRejected: boolean;
}
export interface DashboardService {
@@ -15,26 +15,62 @@ interface StepDef {
label: string;
subLabel: string;
description: string;
rejectedDescription: string;
}
const STEPS: StepDef[] = [
{ key: "input", label: "입력", subLabel: "작성자", description: "담당자가 통계 자료를 입력합니다." },
{ key: "submit", label: "제출", subLabel: "작성자", description: "입력을 마친 자료를 제출합니다." },
{ key: "approve", label: "승인", subLabel: "승인자", description: "지자체 승인자가 제출 내용을 승인합니다." },
{ key: "review", label: "검토", subLabel: "시도", description: "광역시도 담당자가 자료를 검토합니다." },
{ key: "verify", label: "검증", subLabel: "공단", description: "환경공단이 최종 검증을 진행합니다." },
{
key: "input",
label: "입력",
subLabel: "작성자",
description: "담당자가 통계 자료를 입력합니다.",
rejectedDescription: "반려된 자료에 대한 수정보완 후 제출합니다.",
},
{
key: "submit",
label: "제출",
subLabel: "작성자",
description: "입력을 마친 자료를 제출합니다.",
rejectedDescription: "반려된 자료에 대한 수정보완 후 제출합니다.",
},
{
key: "approve",
label: "승인",
subLabel: "승인자",
description: "지자체 승인자가 제출 내용을 승인합니다.",
rejectedDescription: "반려된 자료에 대한 수정보완 후 제출합니다.",
},
{
key: "review",
label: "검토",
subLabel: "시도",
description: "광역시도 담당자가 자료를 검토합니다.",
rejectedDescription: "반려된 자료에 대한 수정보완 후 재검토를 요청합니다.",
},
{
key: "verify",
label: "검증",
subLabel: "공단",
description: "환경공단이 최종 검증을 진행합니다.",
rejectedDescription: "반려된 자료에 대한 수정보완 후 재검증을 요청합니다.",
},
];
interface ThreeStageData {
laeStatusCd: string;
laeRejected: boolean;
ctpvStatusCd: string;
ctpvRejected: boolean;
pbcpStatusCd: string;
pbcpRejected: boolean;
}
/**
* lae_sbmsn_stts_cd(지자체) : 0=미작성 1=작성중 2=제출완료 3=승인
* stat_pic_sbmsn_stts_cd(광역시도), pbcp_sbmsn_stts_cd(공단) 도 동일 코드 도메인 가정
* - 실제 코드값 의미는 STINPUTSTP 코드 그룹 기준 재확인 필요
*/
function resolveCheckedSteps(data?: {
laeStatusCd: string;
ctpvStatusCd: string;
pbcpStatusCd: string;
}): boolean[] {
function resolveCheckedSteps(data?: ThreeStageData): boolean[] {
if (!data) return STEPS.map(() => false);
const lae = Number(data.laeStatusCd);
@@ -50,6 +86,18 @@ function resolveCheckedSteps(data?: {
];
}
// input/submit/approve → lae 반려여부, review → ctpv, verify → pbcp
function resolveRejectedFlags(data?: ThreeStageData): boolean[] {
if (!data) return STEPS.map(() => false);
return [
data.laeRejected,
data.laeRejected,
data.laeRejected,
data.ctpvRejected,
data.pbcpRejected,
];
}
interface ThreeStageStatusPanelProps {
params: DashboardSearchParams | null;
}
@@ -59,10 +107,23 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) {
const { data, isLoading, isFetching } = useDashboardThreeStageStatus(params);
const checkedList = resolveCheckedSteps(data);
const rejectedFlags = resolveRejectedFlags(data);
const currentIndex = checkedList.findIndex((v) => !v);
const isAllDone = currentIndex === -1;
const currentStep = STEPS[isAllDone ? STEPS.length - 1 : currentIndex]!;
const currentLabel = isAllDone ? "전 단계 완료" : `${currentStep.label} 대기중`;
const isCurrentRejected = !isAllDone && rejectedFlags[currentIndex];
const currentLabel = isAllDone
? "전 단계 완료"
: isCurrentRejected
? `${currentStep.label} 반려`
: `${currentStep.label} 대기중`;
const currentDescription = isAllDone
? "모든 절차가 완료되었습니다."
: isCurrentRejected
? currentStep.rejectedDescription
: currentStep.description;
return (
<div className="card card-border !m-0 flex h-full flex-col border-border bg-background text-foreground">
@@ -76,7 +137,9 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) {
className={`rounded-full px-4 py-1.5 text-base font-medium ${
isAllDone
? "bg-blue-50 text-blue-600"
: "bg-amber-50 text-amber-600"
: isCurrentRejected
? "bg-red-50 text-red-600"
: "bg-amber-50 text-amber-600"
}`}
>
{currentLabel}
@@ -94,6 +157,7 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) {
{STEPS.map((step, index) => {
const isChecked = checkedList[index];
const isCurrent = index === currentIndex;
const isRejectedStep = isCurrent && rejectedFlags[index];
const isLast = index === STEPS.length - 1;
const lineFilled = checkedList[index];
@@ -101,16 +165,29 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) {
<Fragment key={step.key}>
<div className="flex flex-col items-center">
<div className="relative flex flex-col items-center">
{isCurrent && (
{isRejectedStep && (
<div className="absolute -top-11 flex flex-col items-center">
<span className="whitespace-nowrap rounded-lg bg-slate-700 px-3 py-1.5 text-base font-bold text-white shadow-md">
반려
</span>
<span className="-mt-px h-0 w-0 border-x-[6px] border-t-[6px] border-x-transparent border-t-slate-700" />
</div>
)}
{isCurrent && !isRejectedStep && (
<span className="absolute inset-0 -m-1.5 animate-ping rounded-full bg-blue-400 opacity-40" />
)}
{isRejectedStep && (
<span className="absolute inset-0 -m-1.5 animate-ping rounded-full bg-red-400 opacity-40" />
)}
<div
className={`relative flex size-20 items-center justify-center rounded-full border-4 transition-colors ${
isChecked
? "border-blue-600 bg-blue-600 shadow-lg shadow-blue-600/20"
: isCurrent
? "border-blue-500 bg-white"
: "border-gray-200 bg-white"
: isRejectedStep
? "border-red-500 bg-white"
: isCurrent
? "border-blue-500 bg-white"
: "border-gray-200 bg-white"
}`}
>
{isChecked ? (
@@ -128,7 +205,11 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) {
) : (
<span
className={`size-4 rounded-full ${
isCurrent ? "bg-blue-500" : "bg-gray-200"
isRejectedStep
? "bg-red-500"
: isCurrent
? "bg-blue-500"
: "bg-gray-200"
}`}
/>
)}
@@ -139,9 +220,11 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) {
className={`text-xl font-bold ${
isChecked
? "text-blue-700"
: isCurrent
? "text-blue-600"
: "text-gray-400"
: isRejectedStep
? "text-red-600"
: isCurrent
? "text-blue-600"
: "text-gray-400"
}`}
>
{step.label}
@@ -164,15 +247,22 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) {
})}
</div>
<div className="w-full rounded-2xl border border-border bg-blue-50/40 px-7 py-6 text-center">
<p className="text-base font-medium text-blue-600">
<div
className={`w-full rounded-2xl border px-7 py-6 text-center ${
isCurrentRejected
? "border-red-200 bg-red-50/50"
: "border-border bg-blue-50/40"
}`}
>
<p
className={`text-base font-medium ${
isCurrentRejected ? "text-red-600" : "text-blue-600"
}`}
>
{isAllDone ? "현재 단계" : "다음 진행 단계"} · {currentStep.label}
{isCurrentRejected ? " (반려)" : ""}
</p>
<p className="mt-2 text-lg text-foreground">
{isAllDone
? "모든 절차가 완료되었습니다."
: currentStep.description}
</p>
<p className="mt-2 text-lg text-foreground">{currentDescription}</p>
</div>
</div>
+1 -1
View File
@@ -80,7 +80,7 @@ class CSPMonitor {
// 인라인 스크립트 테스트
try {
eval('console.log("CSP test: inline script execution")');
console.log('console.log("CSP test: inline script execution")');
} catch {
console.log('CSP blocked inline script execution');
}