Merge branch 'deploy' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into deploy
This commit is contained in:
@@ -27,6 +27,7 @@ settings.json
|
||||
.npmrc
|
||||
.cursor
|
||||
.agent
|
||||
CLAUDE.MD
|
||||
|
||||
apps/**/tsconfig.tsbuildinfo
|
||||
packages/**/tsconfig.tsbuildinfo
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { NtitlkSndngPage } from "@/pages/ntitlk-sndng";
|
||||
|
||||
export default NtitlkSndngPage;
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DashboardMainResponseDto,
|
||||
DashboardMainSearchDto,
|
||||
DashboardService,
|
||||
InPrgrsDocPicResponseDto,
|
||||
} from "../model/dashboard-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.DASHBOARD;
|
||||
@@ -18,6 +19,14 @@ async function getMainDashboard(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getInPrgrsDocPicList(searchYear?: number): Promise<InPrgrsDocPicResponseDto[]> {
|
||||
const response = await client.get<InPrgrsDocPicResponseDto[]>(`${BASE}/in-progress-docs`, {
|
||||
params: { searchYear },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const dashboardService = {
|
||||
getMainDashboard,
|
||||
getInPrgrsDocPicList,
|
||||
} satisfies DashboardService;
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { DashboardMainSearchDto } from "../model/dashboard-interface";
|
||||
import type {
|
||||
DashboardMainSearchDto,
|
||||
InPrgrsDocPicResponseDto,
|
||||
} from "../model/dashboard-interface";
|
||||
|
||||
import { dashboardService } from "./dashboard-service";
|
||||
|
||||
@@ -16,3 +19,14 @@ export function useGetMainDashboard(params: DashboardMainSearchDto = EMPTY_SEARC
|
||||
queryFn: () => dashboardService.getMainDashboard(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetInPrgrsDocPicList<TData = InPrgrsDocPicResponseDto[]>(
|
||||
searchYear?: number,
|
||||
options?: { select?: (data: InPrgrsDocPicResponseDto[]) => TData },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...dashboardBaseKey, "getInPrgrsDocPicList", searchYear],
|
||||
queryFn: () => dashboardService.getInPrgrsDocPicList(searchYear),
|
||||
select: options?.select,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export { dashboardService } from "./api/dashboard-service";
|
||||
export { dashboardBaseKey, useGetMainDashboard } from "./api/queries";
|
||||
export { dashboardBaseKey, useGetInPrgrsDocPicList, useGetMainDashboard } from "./api/queries";
|
||||
export type {
|
||||
BasinDocPrgrsResponseDto,
|
||||
DashboardMainResponseDto,
|
||||
DashboardMainSearchDto,
|
||||
DashboardService,
|
||||
DocPrgrsSttsResponseDto,
|
||||
InPrgrsDocPicResponseDto,
|
||||
NotiAsStatResponseDto,
|
||||
NotiAsUserResponseDto,
|
||||
} from "./model/dashboard-interface";
|
||||
|
||||
@@ -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,11 +60,27 @@ export interface NotiAsStatResponseDto {
|
||||
/** 백엔드 DashboardMainResponseDto */
|
||||
export interface DashboardMainResponseDto {
|
||||
docPrgrsSttsList: DocPrgrsSttsResponseDto[];
|
||||
basinDocPrgrsList: BasinDocPrgrsResponseDto[];
|
||||
notiList: NotiAsUserResponseDto[];
|
||||
notiStats: NotiAsStatResponseDto;
|
||||
}
|
||||
|
||||
/** 백엔드 InPrgrsDocPicResponseDto */
|
||||
export interface InPrgrsDocPicResponseDto {
|
||||
docNo: string;
|
||||
docNm?: string;
|
||||
docSeComCd: string;
|
||||
docSeNm?: string;
|
||||
aplcntNm?: string;
|
||||
aplcntMblTelno?: string;
|
||||
prgrsSttsComCd?: string;
|
||||
prgrsSttsNm?: string;
|
||||
regDt?: string;
|
||||
prcsDdln?: string;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface DashboardService {
|
||||
getMainDashboard(searchDto: DashboardMainSearchDto): Promise<DashboardMainResponseDto>;
|
||||
getInPrgrsDocPicList(searchYear?: number): Promise<InPrgrsDocPicResponseDto[]>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
BusinessSendRequest,
|
||||
BusinessSendResponse,
|
||||
NtftalkService,
|
||||
NtftalkTemplate,
|
||||
NtftalkTemplateSearchDto,
|
||||
TemplateVariablesResponse,
|
||||
} from "../model/ntftalk-interface";
|
||||
|
||||
const { TEMP, ATA_SEND } = ENDPOINT.NTFTALK_SERVICE;
|
||||
|
||||
async function findTemplatePage(
|
||||
params: NtftalkTemplateSearchDto,
|
||||
): Promise<PageResponse<NtftalkTemplate>> {
|
||||
const response = await client.get<PageResponse<NtftalkTemplate>>(TEMP, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findTemplateVariables(templateCode: string): Promise<TemplateVariablesResponse> {
|
||||
const response = await client.get<TemplateVariablesResponse>(
|
||||
`${ATA_SEND}/template-variables/${templateCode}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function sendBusiness(body: BusinessSendRequest): Promise<BusinessSendResponse> {
|
||||
const response = await client.post<BusinessSendResponse>(`${ATA_SEND}/business`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const ntftalkService = {
|
||||
findTemplatePage,
|
||||
findTemplateVariables,
|
||||
sendBusiness,
|
||||
} satisfies NtftalkService;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useMutationWrapper } from "@/shared/lib";
|
||||
|
||||
import type { BusinessSendRequest, NtftalkTemplateSearchDto } from "../model/ntftalk-interface";
|
||||
|
||||
import { ntftalkService } from "./ntftalk-service";
|
||||
|
||||
/** 발송 화면에서 선택 가능한 템플릿: 검수 승인(APR)된 기본형(BA) */
|
||||
const SNDNG_TEMPLATE_SEARCH: NtftalkTemplateSearchDto = {
|
||||
sort: "createdAt,desc",
|
||||
senderKey: "",
|
||||
inspectionStatus: "APR",
|
||||
templateMessageType: "BA",
|
||||
page: 0,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
export function useNtftalkTemplateList() {
|
||||
return useQuery({
|
||||
queryKey: [ENDPOINT.NTFTALK_SERVICE.TEMP, "findTemplatePage", SNDNG_TEMPLATE_SEARCH],
|
||||
queryFn: () => ntftalkService.findTemplatePage(SNDNG_TEMPLATE_SEARCH),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNtftalkTemplateVariables(templateCode: string) {
|
||||
return useQuery({
|
||||
queryKey: [ENDPOINT.NTFTALK_SERVICE.ATA_SEND, "findTemplateVariables", templateCode],
|
||||
queryFn: () => ntftalkService.findTemplateVariables(templateCode),
|
||||
enabled: Boolean(templateCode),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendNtftalkBusiness() {
|
||||
return useMutationWrapper({
|
||||
mutationFn: (body: BusinessSendRequest) => ntftalkService.sendBusiness(body),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export { ntftalkService } from "./api/ntftalk-service";
|
||||
export {
|
||||
useNtftalkTemplateList,
|
||||
useNtftalkTemplateVariables,
|
||||
useSendNtftalkBusiness,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
BusinessSendRequest,
|
||||
BusinessSendResponse,
|
||||
NtftalkService,
|
||||
NtftalkTemplate,
|
||||
NtftalkTemplateSearchDto,
|
||||
TemplateVariablesResponse,
|
||||
} from "./model/ntftalk-interface";
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
/** 백엔드 알림톡 템플릿 응답 */
|
||||
export interface NtftalkTemplate {
|
||||
templateCode: string;
|
||||
templateName: string;
|
||||
templateContent: string;
|
||||
}
|
||||
|
||||
/** 템플릿 목록 검색 조건 + Pageable */
|
||||
export interface NtftalkTemplateSearchDto extends PageableParams {
|
||||
senderKey?: string;
|
||||
inspectionStatus?: string;
|
||||
templateMessageType?: string;
|
||||
}
|
||||
|
||||
export interface TemplateVariablesResponse {
|
||||
templateCode: string;
|
||||
variables: string[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BusinessSendRequest {
|
||||
templateCode: string;
|
||||
callback?: string;
|
||||
fixedRecipientNum?: string;
|
||||
recipients: Record<string, string>[];
|
||||
}
|
||||
|
||||
export interface BusinessSendResponse {
|
||||
success: boolean;
|
||||
sentCount: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface NtftalkService {
|
||||
findTemplatePage(params: NtftalkTemplateSearchDto): Promise<PageResponse<NtftalkTemplate>>;
|
||||
findTemplateVariables(templateCode: string): Promise<TemplateVariablesResponse>;
|
||||
sendBusiness(body: BusinessSendRequest): Promise<BusinessSendResponse>;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
RvwMnpwMngPageSearchDto,
|
||||
RvwMnpwMngResponseDto,
|
||||
RvwMnpwMngSaveRequestDto,
|
||||
RvwMnpwMngSearchDto,
|
||||
RvwMnpwMngUpdateRequestDto,
|
||||
@@ -13,14 +15,18 @@ import { rvwMnpwMngService } from "./rvw-mnpw-service";
|
||||
|
||||
const rvwMnpwMngBaseKey = [ENDPOINT.AS_SERVICE.RVW_MNPW] as const;
|
||||
|
||||
export function useRvwMnpwMngPage(
|
||||
export function useRvwMnpwMngPage<TData = PageResponse<RvwMnpwMngResponseDto>>(
|
||||
params: RvwMnpwMngPageSearchDto,
|
||||
options?: { enabled?: boolean },
|
||||
options?: {
|
||||
enabled?: boolean;
|
||||
select?: (data: PageResponse<RvwMnpwMngResponseDto>) => TData;
|
||||
},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...rvwMnpwMngBaseKey, "findPage", params],
|
||||
queryFn: () => rvwMnpwMngService.findPage(params),
|
||||
enabled: options?.enabled,
|
||||
select: options?.select,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface RvwMnpwMngResponseDto {
|
||||
ognzSn: number;
|
||||
userUuid: string;
|
||||
userUuidNm?: string;
|
||||
mblTelno?: string;
|
||||
ognzNm?: string;
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
|
||||
@@ -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, "설계자 이름은 필수입니다"),
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { NtitlkSndngPage } from "./ui/ntitlk-sndng-page";
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
|
||||
|
||||
/** 템플릿 본문의 `#{변수}`를 값으로 치환한다. 값이 없는 변수는 빈 문자열이 된다. */
|
||||
export function buildTemplateMessage(
|
||||
templateContent: string,
|
||||
variables: Record<string, string>,
|
||||
): string {
|
||||
return templateContent.replace(/#?\{([^}]+)\}/g, (_, key: string) => variables[key.trim()] ?? "");
|
||||
}
|
||||
|
||||
export function getMergedVariables(
|
||||
recipient: SelectedNtitlkRcvr,
|
||||
commonValues: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...commonValues,
|
||||
...recipient.variableOverrides,
|
||||
TEL_NUM: recipient.telNo,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
import type { InPrgrsDocPicResponseDto } from "@/entities/dashboard";
|
||||
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
|
||||
|
||||
import type { NtitlkRcvr } from "../model/ntitlk-rcvr";
|
||||
|
||||
/** 검토인력은 결재구분별로 행이 나뉘어 내려오므로 사용자 기준으로 한 명씩만 남긴다. */
|
||||
export function selectRvwMnpwRcvrList(page: PageResponse<RvwMnpwMngResponseDto>): NtitlkRcvr[] {
|
||||
const rcvrByUserUuid = new Map<string, NtitlkRcvr>();
|
||||
|
||||
page.content.forEach((row) => {
|
||||
if (rcvrByUserUuid.has(row.userUuid)) return;
|
||||
|
||||
rcvrByUserUuid.set(row.userUuid, {
|
||||
rcvrKey: `rvw:${row.userUuid}`,
|
||||
userNm: row.userUuidNm ?? "",
|
||||
telNo: row.mblTelno ?? "",
|
||||
ognzNm: row.ognzNm,
|
||||
});
|
||||
});
|
||||
|
||||
return [...rcvrByUserUuid.values()];
|
||||
}
|
||||
|
||||
export function selectInPrgrsDocRcvrList(rows: InPrgrsDocPicResponseDto[]): NtitlkRcvr[] {
|
||||
return rows.map((row) => ({
|
||||
rcvrKey: `doc:${row.docSeComCd}:${row.docNo}`,
|
||||
userNm: row.aplcntNm ?? "",
|
||||
telNo: row.aplcntMblTelno ?? "",
|
||||
docNo: row.docNo,
|
||||
docNm: row.docNm,
|
||||
docSeNm: row.docSeNm,
|
||||
prgrsSttsNm: row.prgrsSttsNm,
|
||||
prcsDdln: row.prcsDdln?.slice(0, 10),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table";
|
||||
|
||||
import type { NtitlkRcvr, RcvrSelectionChange } from "../model/ntitlk-rcvr";
|
||||
|
||||
interface UseRcvrRowSelectionOptions {
|
||||
rcvrList: NtitlkRcvr[];
|
||||
selectedRcvrKeys: Set<string>;
|
||||
onRcvrSelectionChange: (change: RcvrSelectionChange) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 목록(페이지 상태)을 수신자 테이블의 행 선택 상태로 연결한다.
|
||||
* 선택 상태를 테이블이 따로 들고 있지 않아, 발송 목록에서 제거하면 체크도 함께 풀린다.
|
||||
*/
|
||||
export function useRcvrRowSelection({
|
||||
rcvrList,
|
||||
selectedRcvrKeys,
|
||||
onRcvrSelectionChange,
|
||||
}: UseRcvrRowSelectionOptions) {
|
||||
const rowSelection = useMemo(() => {
|
||||
const selection: RowSelectionState = {};
|
||||
rcvrList.forEach((rcvr) => {
|
||||
if (selectedRcvrKeys.has(rcvr.rcvrKey)) selection[rcvr.rcvrKey] = true;
|
||||
});
|
||||
return selection;
|
||||
}, [rcvrList, selectedRcvrKeys]);
|
||||
|
||||
const handleRowSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {
|
||||
const next = typeof updater === "function" ? updater(rowSelection) : updater;
|
||||
|
||||
onRcvrSelectionChange({
|
||||
added: rcvrList.filter((rcvr) => next[rcvr.rcvrKey] && !rowSelection[rcvr.rcvrKey]),
|
||||
removedKeys: rcvrList
|
||||
.filter((rcvr) => !next[rcvr.rcvrKey] && rowSelection[rcvr.rcvrKey])
|
||||
.map((rcvr) => rcvr.rcvrKey),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
selectedCount: Object.keys(rowSelection).length,
|
||||
rowSelectionOptions: {
|
||||
state: { rowSelection },
|
||||
// 전화번호가 없으면 발송할 수 없으므로 선택 대상에서 제외한다.
|
||||
enableRowSelection: (row: Row<NtitlkRcvr>) => Boolean(row.original.telNo),
|
||||
enableMultiRowSelection: true,
|
||||
getRowId: (rcvr: NtitlkRcvr) => rcvr.rcvrKey,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 검토인력·문서 담당자 목록을 발송 목록 하나로 합치기 위한 수신자 모델 */
|
||||
export interface NtitlkRcvr {
|
||||
/** 행 식별자: 검토인력 `rvw:{userUuid}`, 문서 담당자 `doc:{docSeComCd}:{docNo}` */
|
||||
rcvrKey: string;
|
||||
userNm: string;
|
||||
telNo: string;
|
||||
ognzNm?: string;
|
||||
docNo?: string;
|
||||
docNm?: string;
|
||||
docSeNm?: string;
|
||||
prgrsSttsNm?: string;
|
||||
prcsDdln?: string;
|
||||
}
|
||||
|
||||
export interface SelectedNtitlkRcvr extends NtitlkRcvr {
|
||||
variableOverrides: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 수신자 목록에서 선택 상태가 바뀐 행 */
|
||||
export interface RcvrSelectionChange {
|
||||
added: NtitlkRcvr[];
|
||||
removedKeys: string[];
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { Checkbox } from "@common/components";
|
||||
|
||||
import type { NtitlkRcvr } from "./ntitlk-rcvr";
|
||||
|
||||
const columnHelper = createColumnHelper<NtitlkRcvr>();
|
||||
|
||||
function formatOptional(value: string | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
const selectColumn = columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllRowsSelected()
|
||||
? true
|
||||
: table.getIsSomeRowsSelected()
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) => table.toggleAllRowsSelected(!!checked)}
|
||||
aria-label="전체 선택"
|
||||
/>
|
||||
),
|
||||
meta: { align: "center", width: 48 },
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
|
||||
aria-label="행 선택"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const rvwMnpwRcvrColumns = [
|
||||
selectColumn,
|
||||
columnHelper.accessor("userNm", {
|
||||
header: "이름",
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("ognzNm", {
|
||||
header: "소속",
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("telNo", {
|
||||
header: "전화번호",
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
];
|
||||
|
||||
export const inPrgrsDocRcvrColumns = [
|
||||
selectColumn,
|
||||
columnHelper.accessor("docNo", {
|
||||
header: "문서번호",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("docNm", {
|
||||
header: "문서명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("userNm", {
|
||||
header: "신청자",
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("prgrsSttsNm", {
|
||||
header: "상태",
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NtitlkRcvr } from "./ntitlk-rcvr";
|
||||
|
||||
export const CUSTOM_VRBL_MAPPING = "custom";
|
||||
|
||||
export type VrblMappingKey = Exclude<keyof NtitlkRcvr, "rcvrKey"> | typeof CUSTOM_VRBL_MAPPING;
|
||||
|
||||
interface VrblMappingOption {
|
||||
value: VrblMappingKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 변수 일괄 적용 시 템플릿 변수에 채울 수신자 필드 */
|
||||
export const VRBL_MAPPING_OPTIONS: readonly VrblMappingOption[] = [
|
||||
{ value: "userNm", label: "이름" },
|
||||
{ value: "telNo", label: "전화번호" },
|
||||
{ value: "ognzNm", label: "소속" },
|
||||
{ value: "docNo", label: "문서번호" },
|
||||
{ value: "docNm", label: "문서명" },
|
||||
{ value: "docSeNm", label: "문서구분" },
|
||||
{ value: "prgrsSttsNm", label: "진행상태" },
|
||||
{ value: "prcsDdln", label: "처리기한" },
|
||||
{ value: CUSTOM_VRBL_MAPPING, label: "직접입력" },
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getCoreRowModel } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useGetInPrgrsDocPicList } from "@/entities/dashboard";
|
||||
|
||||
import { selectInPrgrsDocRcvrList } from "../lib/to-ntitlk-rcvr";
|
||||
import { useRcvrRowSelection } from "../lib/use-rcvr-row-selection";
|
||||
import type { NtitlkRcvr, RcvrSelectionChange } from "../model/ntitlk-rcvr";
|
||||
import { inPrgrsDocRcvrColumns } from "../model/rcvr-table-columns";
|
||||
|
||||
import { RcvrListCard } from "./rcvr-list-card";
|
||||
|
||||
const EMPTY_RCVR_LIST: NtitlkRcvr[] = [];
|
||||
|
||||
interface InPrgrsDocRcvrListProps {
|
||||
selectedRcvrKeys: Set<string>;
|
||||
onRcvrSelectionChange: (change: RcvrSelectionChange) => void;
|
||||
}
|
||||
|
||||
function InPrgrsDocRcvrList({ selectedRcvrKeys, onRcvrSelectionChange }: InPrgrsDocRcvrListProps) {
|
||||
const rcvrQuery = useGetInPrgrsDocPicList(undefined, { select: selectInPrgrsDocRcvrList });
|
||||
|
||||
const { selectedCount, rowSelectionOptions } = useRcvrRowSelection({
|
||||
rcvrList: rcvrQuery.data ?? EMPTY_RCVR_LIST,
|
||||
selectedRcvrKeys,
|
||||
onRcvrSelectionChange,
|
||||
});
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: rcvrQuery,
|
||||
tableOptions: {
|
||||
columns: inPrgrsDocRcvrColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
...rowSelectionOptions,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<RcvrListCard title="현재 진행중 문서 담당자(신청자)" selectedCount={selectedCount}>
|
||||
{tableComponent}
|
||||
</RcvrListCard>
|
||||
);
|
||||
}
|
||||
|
||||
export { InPrgrsDocRcvrList };
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@common/components";
|
||||
|
||||
import type { SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
|
||||
|
||||
import { NtitlkPrvw } from "./ntitlk-prvw";
|
||||
|
||||
interface NtitlkPrvwDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
recipient: SelectedNtitlkRcvr | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function NtitlkPrvwDialog({ open, onOpenChange, recipient, message }: NtitlkPrvwDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[36rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>알림톡 미리보기</DialogTitle>
|
||||
<DialogDescription className="text-[1.1rem] tracking-tight">
|
||||
수신자: {recipient?.userNm || "-"} / {recipient?.telNo || "-"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<NtitlkPrvw message={message || "메시지를 생성할 수 없습니다."} />
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" color="white">
|
||||
닫기
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { NtitlkPrvwDialog };
|
||||
@@ -0,0 +1,25 @@
|
||||
const SNDR_NM = "디지털 행정지원 시스템";
|
||||
|
||||
interface NtitlkPrvwProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
function NtitlkPrvw({ message }: NtitlkPrvwProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="h-[32rem] overflow-auto rounded-[20px] rounded-b-none bg-[rgb(176,192,207)] p-8 pb-0 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-full before:rounded-[20px] before:rounded-b-none before:border-[10px] before:border-b-0 before:border-black before:content-['']">
|
||||
<div className="w-[calc(100%-2rem)] pb-5">
|
||||
<span className="text-[1.1rem] text-black">{SNDR_NM}</span>
|
||||
<div className="mt-2 rounded-t-[12px] bg-[rgb(247,228,54)] p-4 text-[1.2rem] font-medium text-black">
|
||||
알림톡 도착
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap rounded-b-[12px] bg-white p-4 text-[1.2rem] leading-normal tracking-tight text-black">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { NtitlkPrvw };
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
type NtftalkTemplate,
|
||||
useNtftalkTemplateList,
|
||||
useNtftalkTemplateVariables,
|
||||
useSendNtftalkBusiness,
|
||||
} from "@/entities/ntftalk";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { buildTemplateMessage, getMergedVariables } from "../lib/build-tmplt-msg";
|
||||
import type { RcvrSelectionChange, SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
|
||||
import { CUSTOM_VRBL_MAPPING, type VrblMappingKey } from "../model/vrbl-mapping-options";
|
||||
|
||||
import { InPrgrsDocRcvrList } from "./in-prgrs-doc-rcvr-list";
|
||||
import { NtitlkPrvwDialog } from "./ntitlk-prvw-dialog";
|
||||
import { RvwMnpwRcvrList } from "./rvw-mnpw-rcvr-list";
|
||||
import { SndngList } from "./sndng-list";
|
||||
import { TmpltSelectPanel } from "./tmplt-select-panel";
|
||||
import { VrblBulkApply } from "./vrbl-bulk-apply";
|
||||
|
||||
const SNDNG_CALLBACK_TELNO = "032-590-4000";
|
||||
const EMPTY_TEMPLATES: NtftalkTemplate[] = [];
|
||||
const EMPTY_VARIABLE_NAMES: string[] = [];
|
||||
|
||||
function NtitlkSndngPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const [selectedTemplateCode, setSelectedTemplateCode] = useState("");
|
||||
const [selectedRecipients, setSelectedRecipients] = useState<SelectedNtitlkRcvr[]>([]);
|
||||
const [commonVariableValues, setCommonVariableValues] = useState<Record<string, string>>({});
|
||||
const [commonVariableMapping, setCommonVariableMapping] = useState<
|
||||
Record<string, VrblMappingKey>
|
||||
>({});
|
||||
const [previewRecipient, setPreviewRecipient] = useState<SelectedNtitlkRcvr | null>(null);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
|
||||
const { data: templatePage } = useNtftalkTemplateList();
|
||||
const templates = templatePage?.content ?? EMPTY_TEMPLATES;
|
||||
const selectedTemplate = templates.find(
|
||||
(template) => template.templateCode === selectedTemplateCode,
|
||||
);
|
||||
|
||||
const { data: templateVariables } = useNtftalkTemplateVariables(selectedTemplateCode);
|
||||
const variableNames = templateVariables?.variables ?? EMPTY_VARIABLE_NAMES;
|
||||
|
||||
const { mutate: sendNtftalk, isPending: isSending } = useSendNtftalkBusiness();
|
||||
|
||||
const selectedRcvrKeys = useMemo(
|
||||
() => new Set(selectedRecipients.map((recipient) => recipient.rcvrKey)),
|
||||
[selectedRecipients],
|
||||
);
|
||||
|
||||
const previewMessage =
|
||||
previewRecipient && selectedTemplate?.templateContent
|
||||
? buildTemplateMessage(
|
||||
selectedTemplate.templateContent,
|
||||
getMergedVariables(previewRecipient, commonVariableValues),
|
||||
)
|
||||
: "";
|
||||
|
||||
const handleTemplateChange = (templateCode: string) => {
|
||||
setSelectedTemplateCode(templateCode);
|
||||
setCommonVariableValues({});
|
||||
setCommonVariableMapping({});
|
||||
setSelectedRecipients((prev) =>
|
||||
prev.map((recipient) => ({ ...recipient, variableOverrides: {} })),
|
||||
);
|
||||
};
|
||||
|
||||
const handleRcvrSelectionChange = ({ added, removedKeys }: RcvrSelectionChange) => {
|
||||
setSelectedRecipients((prev) => {
|
||||
const removedKeySet = new Set(removedKeys);
|
||||
const existingKeySet = new Set(prev.map((recipient) => recipient.rcvrKey));
|
||||
|
||||
return [
|
||||
...prev.filter((recipient) => !removedKeySet.has(recipient.rcvrKey)),
|
||||
...added
|
||||
.filter((rcvr) => !existingKeySet.has(rcvr.rcvrKey))
|
||||
.map((rcvr) => ({ ...rcvr, variableOverrides: {} })),
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveRecipient = (rcvrKey: string) => {
|
||||
setSelectedRecipients((prev) => prev.filter((recipient) => recipient.rcvrKey !== rcvrKey));
|
||||
};
|
||||
|
||||
const handleApplyCommonValues = () => {
|
||||
setSelectedRecipients((prev) =>
|
||||
prev.map((recipient) => {
|
||||
const newOverrides: Record<string, string> = {};
|
||||
|
||||
variableNames.forEach((variableName) => {
|
||||
const mappingKey = commonVariableMapping[variableName];
|
||||
if (!mappingKey) return;
|
||||
|
||||
newOverrides[variableName] =
|
||||
mappingKey === CUSTOM_VRBL_MAPPING
|
||||
? (commonVariableValues[variableName] ?? "")
|
||||
: (recipient[mappingKey] ?? "");
|
||||
});
|
||||
|
||||
return {
|
||||
...recipient,
|
||||
variableOverrides: { ...recipient.variableOverrides, ...newOverrides },
|
||||
};
|
||||
}),
|
||||
);
|
||||
setCommonVariableMapping({});
|
||||
};
|
||||
|
||||
const handleResetCommonValues = () => {
|
||||
setCommonVariableMapping({});
|
||||
setCommonVariableValues({});
|
||||
};
|
||||
|
||||
const handleRecipientVariableChange = (rcvrKey: string, variableName: string, value: string) => {
|
||||
setSelectedRecipients((prev) =>
|
||||
prev.map((recipient) =>
|
||||
recipient.rcvrKey === rcvrKey
|
||||
? {
|
||||
...recipient,
|
||||
variableOverrides: { ...recipient.variableOverrides, [variableName]: value },
|
||||
}
|
||||
: recipient,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handlePreview = (recipient: SelectedNtitlkRcvr) => {
|
||||
if (!selectedTemplate) {
|
||||
alert("알림톡 템플릿을 먼저 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
setPreviewRecipient(recipient);
|
||||
setIsPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!selectedTemplateCode) {
|
||||
alert("알림톡 템플릿을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMissingVariable = selectedRecipients.some((recipient) =>
|
||||
variableNames.some((variableName) => {
|
||||
const mergedValue =
|
||||
recipient.variableOverrides[variableName] ?? commonVariableValues[variableName] ?? "";
|
||||
return mergedValue.trim() === "";
|
||||
}),
|
||||
);
|
||||
if (hasMissingVariable) {
|
||||
alert("변수값이 입력되지 않은 발송 대상이 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await confirm(`${selectedRecipients.length}명에게 발송하시겠습니까?`))) return;
|
||||
|
||||
sendNtftalk(
|
||||
{
|
||||
templateCode: selectedTemplateCode,
|
||||
callback: SNDNG_CALLBACK_TELNO,
|
||||
recipients: selectedRecipients.map((recipient) =>
|
||||
getMergedVariables(recipient, commonVariableValues),
|
||||
),
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
alert(data.message || "알림톡 발송 요청에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
alert(data.message || "알림톡 발송이 요청되었습니다.");
|
||||
setSelectedRecipients([]);
|
||||
setCommonVariableValues({});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-[1.8rem] pb-6">
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[32rem_minmax(0,1fr)]">
|
||||
<TmpltSelectPanel
|
||||
templates={templates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
onTemplateChange={handleTemplateChange}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<RvwMnpwRcvrList
|
||||
selectedRcvrKeys={selectedRcvrKeys}
|
||||
onRcvrSelectionChange={handleRcvrSelectionChange}
|
||||
/>
|
||||
<InPrgrsDocRcvrList
|
||||
selectedRcvrKeys={selectedRcvrKeys}
|
||||
onRcvrSelectionChange={handleRcvrSelectionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="card card-border m-0 bg-background p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold">발송 목록</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
onClick={() => setSelectedRecipients([])}
|
||||
disabled={selectedRecipients.length === 0}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="green"
|
||||
onClick={handleSend}
|
||||
disabled={isSending || selectedRecipients.length === 0}
|
||||
>
|
||||
발송
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{variableNames.length > 0 && (
|
||||
<VrblBulkApply
|
||||
variableNames={variableNames}
|
||||
mapping={commonVariableMapping}
|
||||
customValues={commonVariableValues}
|
||||
onMappingChange={(variableName, mappingKey) =>
|
||||
setCommonVariableMapping((prev) => ({ ...prev, [variableName]: mappingKey }))
|
||||
}
|
||||
onCustomValueChange={(variableName, value) =>
|
||||
setCommonVariableValues((prev) => ({ ...prev, [variableName]: value }))
|
||||
}
|
||||
onApply={handleApplyCommonValues}
|
||||
onReset={handleResetCommonValues}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SndngList
|
||||
recipients={selectedRecipients}
|
||||
variableNames={variableNames}
|
||||
onVariableChange={handleRecipientVariableChange}
|
||||
onPreview={handlePreview}
|
||||
onRemove={handleRemoveRecipient}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<NtitlkPrvwDialog
|
||||
open={isPreviewOpen}
|
||||
onOpenChange={setIsPreviewOpen}
|
||||
recipient={previewRecipient}
|
||||
message={previewMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { NtitlkSndngPage };
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface RcvrListCardProps {
|
||||
title: string;
|
||||
selectedCount: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function RcvrListCard({ title, selectedCount, children }: RcvrListCardProps) {
|
||||
return (
|
||||
<section className="card card-border m-0 bg-background p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold">{title}</h3>
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{selectedCount}명</span> 선택
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-[16rem] overflow-auto rounded-md border">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { RcvrListCard };
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getCoreRowModel } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useRvwMnpwMngPage } from "@/entities/rvw-mnpw";
|
||||
|
||||
import { selectRvwMnpwRcvrList } from "../lib/to-ntitlk-rcvr";
|
||||
import { useRcvrRowSelection } from "../lib/use-rcvr-row-selection";
|
||||
import type { NtitlkRcvr, RcvrSelectionChange } from "../model/ntitlk-rcvr";
|
||||
import { rvwMnpwRcvrColumns } from "../model/rcvr-table-columns";
|
||||
|
||||
import { RcvrListCard } from "./rcvr-list-card";
|
||||
|
||||
const RVW_MNPW_SEARCH = { page: 0, size: 1000, sort: "userUuidNm,asc" };
|
||||
const EMPTY_RCVR_LIST: NtitlkRcvr[] = [];
|
||||
|
||||
interface RvwMnpwRcvrListProps {
|
||||
selectedRcvrKeys: Set<string>;
|
||||
onRcvrSelectionChange: (change: RcvrSelectionChange) => void;
|
||||
}
|
||||
|
||||
function RvwMnpwRcvrList({ selectedRcvrKeys, onRcvrSelectionChange }: RvwMnpwRcvrListProps) {
|
||||
const rcvrQuery = useRvwMnpwMngPage(RVW_MNPW_SEARCH, { select: selectRvwMnpwRcvrList });
|
||||
|
||||
const { selectedCount, rowSelectionOptions } = useRcvrRowSelection({
|
||||
rcvrList: rcvrQuery.data ?? EMPTY_RCVR_LIST,
|
||||
selectedRcvrKeys,
|
||||
onRcvrSelectionChange,
|
||||
});
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: rcvrQuery,
|
||||
tableOptions: {
|
||||
columns: rvwMnpwRcvrColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
...rowSelectionOptions,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<RcvrListCard title="검토인력 목록" selectedCount={selectedCount}>
|
||||
{tableComponent}
|
||||
</RcvrListCard>
|
||||
);
|
||||
}
|
||||
|
||||
export { RvwMnpwRcvrList };
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
|
||||
|
||||
interface SndngListProps {
|
||||
recipients: SelectedNtitlkRcvr[];
|
||||
variableNames: string[];
|
||||
onVariableChange: (rcvrKey: string, variableName: string, value: string) => void;
|
||||
onPreview: (recipient: SelectedNtitlkRcvr) => void;
|
||||
onRemove: (rcvrKey: string) => void;
|
||||
}
|
||||
|
||||
function SndngList({
|
||||
recipients,
|
||||
variableNames,
|
||||
onVariableChange,
|
||||
onPreview,
|
||||
onRemove,
|
||||
}: SndngListProps) {
|
||||
if (recipients.length === 0)
|
||||
return (
|
||||
<p className="rounded-md border p-6 text-center text-muted-foreground">
|
||||
선택된 발송 대상이 없습니다.
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-h-[40rem] overflow-auto rounded-md border">
|
||||
<Table variant="line" className="text-sm">
|
||||
<TableHeader className="top-0">
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[100px]">이름</TableHead>
|
||||
<TableHead className="min-w-[120px]">전화번호</TableHead>
|
||||
{variableNames.map((variableName) => (
|
||||
<TableHead key={variableName} className="min-w-[180px]">
|
||||
#{variableName}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="min-w-[160px] text-center">버튼</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recipients.map((recipient) => (
|
||||
<TableRow key={recipient.rcvrKey}>
|
||||
<TableCell align="center">{recipient.userNm}</TableCell>
|
||||
<TableCell align="center">{recipient.telNo}</TableCell>
|
||||
{variableNames.map((variableName) => (
|
||||
<TableCell key={variableName}>
|
||||
<Input
|
||||
value={recipient.variableOverrides[variableName] ?? ""}
|
||||
onChange={(event) =>
|
||||
onVariableChange(recipient.rcvrKey, variableName, event.target.value)
|
||||
}
|
||||
className="w-full min-w-[150px]"
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button type="button" size="sm" color="gray" onClick={() => onPreview(recipient)}>
|
||||
미리보기
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="red"
|
||||
onClick={() => onRemove(recipient.rcvrKey)}
|
||||
>
|
||||
제거
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SndngList };
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { NtftalkTemplate } from "@/entities/ntftalk";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@common/components";
|
||||
|
||||
import { NtitlkPrvw } from "./ntitlk-prvw";
|
||||
|
||||
interface TmpltSelectPanelProps {
|
||||
templates: NtftalkTemplate[];
|
||||
selectedTemplate: NtftalkTemplate | undefined;
|
||||
onTemplateChange: (templateCode: string) => void;
|
||||
}
|
||||
|
||||
function TmpltSelectPanel({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
onTemplateChange,
|
||||
}: TmpltSelectPanelProps) {
|
||||
return (
|
||||
<section className="card card-border m-0 space-y-6 bg-background p-4 pb-0">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold">알림톡 템플릿 선택</h3>
|
||||
<Select value={selectedTemplate?.templateCode ?? ""} onValueChange={onTemplateChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="템플릿 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((template) => (
|
||||
<SelectItem key={template.templateCode} value={template.templateCode}>
|
||||
{template.templateName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-lg font-medium">미리보기</p>
|
||||
<NtitlkPrvw
|
||||
message={
|
||||
selectedTemplate?.templateContent ??
|
||||
"알림톡 템플릿을 선택하면 미리보기 메시지가 표시됩니다."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { TmpltSelectPanel };
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
|
||||
import {
|
||||
CUSTOM_VRBL_MAPPING,
|
||||
VRBL_MAPPING_OPTIONS,
|
||||
type VrblMappingKey,
|
||||
} from "../model/vrbl-mapping-options";
|
||||
|
||||
interface VrblBulkApplyProps {
|
||||
variableNames: string[];
|
||||
mapping: Record<string, VrblMappingKey>;
|
||||
customValues: Record<string, string>;
|
||||
onMappingChange: (variableName: string, mappingKey: VrblMappingKey) => void;
|
||||
onCustomValueChange: (variableName: string, value: string) => void;
|
||||
onApply: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
function VrblBulkApply({
|
||||
variableNames,
|
||||
mapping,
|
||||
customValues,
|
||||
onMappingChange,
|
||||
onCustomValueChange,
|
||||
onApply,
|
||||
onReset,
|
||||
}: VrblBulkApplyProps) {
|
||||
return (
|
||||
<div className="mb-4 space-y-4 rounded-md border p-3">
|
||||
<p className="text-lg font-medium">변수 일괄 적용</p>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-6">
|
||||
{variableNames.map((variableName) => (
|
||||
<div key={variableName} className="space-y-2">
|
||||
<Label className="whitespace-nowrap">{`#{${variableName}}`}</Label>
|
||||
<Select
|
||||
value={mapping[variableName] ?? ""}
|
||||
onValueChange={(value) => onMappingChange(variableName, value as VrblMappingKey)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VRBL_MAPPING_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{mapping[variableName] === CUSTOM_VRBL_MAPPING && (
|
||||
<Input
|
||||
placeholder="값을 입력하세요"
|
||||
value={customValues[variableName] ?? ""}
|
||||
onChange={(event) => onCustomValueChange(variableName, event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-end gap-2">
|
||||
<Button type="button" color="white" onClick={onReset}>
|
||||
초기화
|
||||
</Button>
|
||||
<Button type="button" onClick={onApply}>
|
||||
적용하기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { VrblBulkApply };
|
||||
@@ -3,10 +3,11 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } 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(sanitizeNumberInput(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, sanitizeNumberInput } 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(sanitizeNumberInput(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, sanitizeNumberInput } 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 sanitized = sanitizeNumberInput(value);
|
||||
const normalized = decimals === undefined ? sanitized : truncateDecimals(sanitized, 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,
|
||||
sanitizeNumberInput,
|
||||
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 = sanitizeNumberInput(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>
|
||||
))}
|
||||
|
||||
@@ -53,6 +53,10 @@ export const ENDPOINT = {
|
||||
STATVERIFY_SERVICE: {
|
||||
ST_ANLS_MST: "/statverify-service/api/v1/st-anls-mst",
|
||||
},
|
||||
NTFTALK_SERVICE: {
|
||||
TEMP: "/ntftalk-service/api/v1/ntftalk-temp",
|
||||
ATA_SEND: "/ntftalk-service/api/v1/ata/send",
|
||||
},
|
||||
DC_SERVICE: "/dc-service/api/v1",
|
||||
BOARD_SERVICE: {
|
||||
POSTS: "/board-service/api/v1/posts",
|
||||
|
||||
@@ -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, sanitizeNumberInput } from "./number-input";
|
||||
export { queryClient } from "./query-client";
|
||||
export { useAlertStore } from "./use-alert-store";
|
||||
export { useDataTable } from "./use-data-table";
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { KeyboardEvent } from "react";
|
||||
|
||||
const MAX_INTEGER_DIGITS = 10;
|
||||
|
||||
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;
|
||||
|
||||
/** 정수부를 maxDigits 자리까지 잘라낸다 (소수부는 유지) */
|
||||
function truncateIntegerDigits(value: string, maxDigits: number): string {
|
||||
const dotIndex = value.indexOf(".");
|
||||
if (dotIndex < 0) return value.slice(0, maxDigits);
|
||||
return value.slice(0, Math.min(dotIndex, maxDigits)) + value.slice(dotIndex);
|
||||
}
|
||||
|
||||
/** 숫자 입력 문자열에서 음수 부호를 제거하고 정수부를 최대 10자리로 제한한다 */
|
||||
export function sanitizeNumberInput(value: string): string {
|
||||
return truncateIntegerDigits(value.replace(/-/g, ""), MAX_INTEGER_DIGITS);
|
||||
}
|
||||
@@ -19,16 +19,23 @@ interface DataTableOptions<T> {
|
||||
}
|
||||
|
||||
interface UseDataTableOptions<T> {
|
||||
query: UseQueryResult<PageResponse<T>>;
|
||||
/** 페이징 응답(PageResponse) 또는 페이징 없는 목록(T[]) */
|
||||
query: UseQueryResult<PageResponse<T> | T[]>;
|
||||
tableOptions: Omit<TableOptions<T>, "data" | "rowCount">;
|
||||
dataTableOptions?: DataTableOptions<T>;
|
||||
}
|
||||
|
||||
const EMPTY_ROWS: never[] = [];
|
||||
|
||||
export function useDataTable<T>({ query, tableOptions, dataTableOptions }: UseDataTableOptions<T>) {
|
||||
const { data } = query;
|
||||
const rows = Array.isArray(data) ? data : (data?.content ?? EMPTY_ROWS);
|
||||
const rowCount = Array.isArray(data) ? data.length : (data?.totalElements ?? 0);
|
||||
|
||||
const table = useReactTable({
|
||||
...tableOptions,
|
||||
data: query.data?.content ?? [],
|
||||
rowCount: query.data?.totalElements ?? 0,
|
||||
data: rows,
|
||||
rowCount,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -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)}%`;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } 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(sanitizeNumberInput(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, sanitizeNumberInput } 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 sanitized = sanitizeNumberInput(value);
|
||||
const normalized = item?.numYn ? truncateDecimals(sanitized, item.dcptUnitCnt) : sanitized;
|
||||
const numValue = Number(normalized) || 0;
|
||||
const draft = [...getValues("bzScaleList")];
|
||||
draft[index] = { ...draft[index], [fieldKey]: numValue };
|
||||
const next = recalculateBzScaleTotals(draft, scaleItems);
|
||||
@@ -94,7 +101,15 @@ function BusinessScaleFormSection() {
|
||||
return (
|
||||
<SectionCollapsible title="사업규모">
|
||||
<div className="overflow-x-auto">
|
||||
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
|
||||
<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 +138,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,
|
||||
sanitizeNumberInput,
|
||||
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(sanitizeNumberInput(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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export function LNB({
|
||||
const [currentMenuCode, setCurrentMenuCode] = useState<number | null>(null);
|
||||
const prevPathRef = useRef(currentPath);
|
||||
const { browser } = useDetectDevice();
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
const appSiteId = getCookie("site-id-key") ?? "";
|
||||
const siteId = propSiteId ?? appSiteId;
|
||||
@@ -90,6 +91,38 @@ export function LNB({
|
||||
return currentPath?.startsWith(path);
|
||||
}
|
||||
|
||||
function handleSidebarKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key !== "Tab") return;
|
||||
|
||||
const menuControls = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLElement>(
|
||||
'[data-sidebar="menu-button"], [data-sidebar="menu-sub-button"]',
|
||||
),
|
||||
).filter(
|
||||
(control) =>
|
||||
control.tabIndex >= 0 &&
|
||||
!control.hasAttribute("disabled") &&
|
||||
control.getAttribute("aria-disabled") !== "true" &&
|
||||
!control.closest('[hidden], [aria-hidden="true"]'),
|
||||
);
|
||||
const currentIndex = menuControls.findIndex(
|
||||
(control) =>
|
||||
control === event.target ||
|
||||
control.contains(event.target as Node),
|
||||
);
|
||||
|
||||
if (currentIndex < 0) return;
|
||||
|
||||
const nextIndex = currentIndex + (event.shiftKey ? -1 : 1);
|
||||
const nextControl = menuControls[nextIndex];
|
||||
|
||||
if (nextControl) {
|
||||
event.preventDefault();
|
||||
nextControl.focus();
|
||||
nextControl.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuList || !currentPath) return;
|
||||
|
||||
@@ -127,6 +160,7 @@ export function LNB({
|
||||
if (prevPathRef.current !== currentPath) {
|
||||
setDefaultOpenMenuIds(matchedMenuIds);
|
||||
prevPathRef.current = currentPath;
|
||||
if (isMobile) setOpenMobile(false);
|
||||
}
|
||||
|
||||
if (matchedMenuCode) {
|
||||
@@ -142,7 +176,7 @@ export function LNB({
|
||||
siteId,
|
||||
urlPath: currentPath,
|
||||
});
|
||||
}, [menuList, currentPath]);
|
||||
}, [menuList, currentPath, isMobile, setOpenMobile]);
|
||||
|
||||
function MenuItem({
|
||||
item,
|
||||
@@ -151,65 +185,104 @@ export function LNB({
|
||||
item: MenuItem;
|
||||
isSubMenu?: boolean;
|
||||
}) {
|
||||
const { open: isSidebarOpen, setOpen } = useSidebar();
|
||||
const {
|
||||
open: isSidebarOpen,
|
||||
setOpen,
|
||||
isMobile: isMobileView,
|
||||
setOpenMobile: closeMobileSidebar,
|
||||
} = useSidebar();
|
||||
const isMatched =
|
||||
isPathMatched(item.urlPath) && currentMenuCode === item.menuCd;
|
||||
const hideLabel = !isSidebarOpen && !isMobileView;
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isSidebarOpen) setOpen(true);
|
||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
setCookie("menu-code-key", item.menuCd.toString());
|
||||
|
||||
// newWndwYn이 Y인 경우 새창으로 열기
|
||||
if (item.newWndwYn === "Y") {
|
||||
window.open(item.urlPath, "_blank");
|
||||
if (item.newWndwYn !== "Y" && handleMenuClick) {
|
||||
event.preventDefault();
|
||||
handleMenuClick(item);
|
||||
if (isMobileView && currentPath === item.urlPath) {
|
||||
closeMobileSidebar(false);
|
||||
}
|
||||
} else if (isMobileView) {
|
||||
closeMobileSidebar(false);
|
||||
}
|
||||
|
||||
handleMenuClick?.(item);
|
||||
};
|
||||
|
||||
if (item.children) {
|
||||
if (item.children?.length) {
|
||||
return <MenuGroup item={item} />;
|
||||
}
|
||||
|
||||
const MenuItem = isSubMenu ? SidebarMenuSubItem : SidebarMenuItem;
|
||||
const MenuButton = isSubMenu ? SidebarMenuSubButton : SidebarMenuButton;
|
||||
const menuContent = (
|
||||
<>
|
||||
<i
|
||||
className={cn(
|
||||
"diveicon",
|
||||
`di-${item.iconNm}`,
|
||||
!hideLabel && "ml-2",
|
||||
)}
|
||||
/>
|
||||
<span className={cn("whitespace-nowrap", hideLabel && "hidden")}>
|
||||
{item.menuKornNm}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const linkProps = {
|
||||
href: item.urlPath,
|
||||
target: item.newWndwYn === "Y" ? "_blank" : undefined,
|
||||
rel: item.newWndwYn === "Y" ? "noopener noreferrer" : undefined,
|
||||
"aria-current": isMatched ? ("page" as const) : undefined,
|
||||
onClick: handleClick,
|
||||
};
|
||||
const menuClassName = cn(
|
||||
"h-auto cursor-pointer py-4 text-[1.4rem] text-label transition-colors hover:bg-accent",
|
||||
isMatched && "bg-accent !text-primary",
|
||||
!isSidebarOpen && "justify-center",
|
||||
);
|
||||
|
||||
if (isSubMenu) {
|
||||
return (
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
{...linkProps}
|
||||
className={menuClassName}
|
||||
data-matched={isMatched}
|
||||
isActive={isMatched}
|
||||
>
|
||||
{menuContent}
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem>
|
||||
<MenuButton
|
||||
className={cn(
|
||||
"h-auto cursor-pointer py-4 text-[1.4rem] text-label transition-colors hover:bg-accent",
|
||||
isMatched && "bg-accent !text-primary",
|
||||
!isSidebarOpen && "justify-center",
|
||||
)}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className={menuClassName}
|
||||
data-matched={isMatched}
|
||||
onClick={handleClick}
|
||||
tooltip={item.menuKornNm}
|
||||
>
|
||||
<i
|
||||
className={cn(
|
||||
"diveicon",
|
||||
`di-${item.iconNm}`,
|
||||
isSidebarOpen && "ml-2",
|
||||
)}
|
||||
/>
|
||||
<span className={cn("whitespace-nowrap", !isSidebarOpen && "hidden")}>
|
||||
{item.menuKornNm}
|
||||
</span>
|
||||
</MenuButton>
|
||||
</MenuItem>
|
||||
<a {...linkProps}>{menuContent}</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuGroup({ item }: { item: MenuItem }) {
|
||||
const { open: isSidebarOpen, setOpen: setSidebarOpen } = useSidebar();
|
||||
const {
|
||||
open: isSidebarOpen,
|
||||
setOpen: setSidebarOpen,
|
||||
isMobile,
|
||||
} = useSidebar();
|
||||
const hideLabel = !isSidebarOpen && !isMobile;
|
||||
const [isOpen, setIsOpen] = useState(
|
||||
defaultOpenMenuIds.includes(item.menuCd),
|
||||
);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (!isSidebarOpen) setSidebarOpen(true);
|
||||
if (!isMobile && !isSidebarOpen) setSidebarOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -233,13 +306,13 @@ export function LNB({
|
||||
className={cn(
|
||||
"diveicon text-label",
|
||||
`di-${item.iconNm}`,
|
||||
isSidebarOpen && "ml-2",
|
||||
!hideLabel && "ml-2",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-nowrap text-label group-hover/menu-button:text-foreground",
|
||||
!isSidebarOpen && "hidden",
|
||||
hideLabel && "hidden",
|
||||
)}
|
||||
>
|
||||
{item.menuKornNm}
|
||||
@@ -249,7 +322,7 @@ export function LNB({
|
||||
className={cn(
|
||||
"ml-auto !size-6 text-label transition-transform",
|
||||
isOpen && "rotate-90",
|
||||
!isSidebarOpen && "hidden",
|
||||
hideLabel && "hidden",
|
||||
)}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
@@ -270,11 +343,14 @@ export function LNB({
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
className={cn(
|
||||
"border-border bg-background px-4 pt-[6rem]",
|
||||
"border-border bg-background px-4 pt-14 md:pt-[6rem]",
|
||||
classNames?.wrapper,
|
||||
)}
|
||||
>
|
||||
<SidebarContent className="bg-background">
|
||||
<SidebarContent
|
||||
className="bg-background"
|
||||
onKeyDown={handleSidebarKeyDown}
|
||||
>
|
||||
<SidebarMenu className="gap-0">
|
||||
{menuList?.map((item) => (
|
||||
<MenuItem key={item.menuCd} item={item} />
|
||||
|
||||
@@ -67,7 +67,7 @@ const SheetContent = React.forwardRef<
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">닫기</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
|
||||
@@ -211,7 +211,7 @@ const Sidebar = React.forwardRef<
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
className="h-[100dvh] max-h-[100dvh] w-[--sidebar-width] overflow-hidden bg-sidebar p-0 text-sidebar-foreground [&>button]:z-50 [&>button]:rounded-sm [&>button]:p-2 [&>button]:text-sidebar-foreground [&>button]:opacity-100 [&>button]:hover:bg-sidebar-accent"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
@@ -223,7 +223,14 @@ const Sidebar = React.forwardRef<
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-h-0 w-full flex-col",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
@@ -281,12 +288,22 @@ const SidebarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { toggleSidebar, isMobile, openMobile, state } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
data-sidebar="trigger"
|
||||
aria-label={
|
||||
isMobile
|
||||
? openMobile
|
||||
? "좌측 메뉴 닫기"
|
||||
: "좌측 메뉴 열기"
|
||||
: state === "expanded"
|
||||
? "좌측 메뉴 접기"
|
||||
: "좌측 메뉴 펼치기"
|
||||
}
|
||||
aria-expanded={isMobile ? openMobile : state === "expanded"}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
@@ -297,7 +314,7 @@ const SidebarTrigger = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
<span className="sr-only">좌측 메뉴 열기/닫기</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
@@ -422,7 +439,7 @@ const SidebarContent = React.forwardRef<
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto overscroll-y-contain touch-pan-y group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user