diff --git a/.gitignore b/.gitignore index 871b209f0..6eedddd04 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ settings.json .npmrc .cursor .agent +CLAUDE.MD apps/**/tsconfig.tsbuildinfo packages/**/tsconfig.tsbuildinfo diff --git a/apps/as/app/(main)/(with-base-layout)/ntitlk-sndng/page.tsx b/apps/as/app/(main)/(with-base-layout)/ntitlk-sndng/page.tsx new file mode 100644 index 000000000..c3df98ba1 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/ntitlk-sndng/page.tsx @@ -0,0 +1,3 @@ +import { NtitlkSndngPage } from "@/pages/ntitlk-sndng"; + +export default NtitlkSndngPage; diff --git a/apps/as/src/entities/cnstp/model/cnstp-interface.ts b/apps/as/src/entities/cnstp/model/cnstp-interface.ts index eabd12f46..85ec716e5 100644 --- a/apps/as/src/entities/cnstp/model/cnstp-interface.ts +++ b/apps/as/src/entities/cnstp/model/cnstp-interface.ts @@ -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; diff --git a/apps/as/src/entities/dashboard/api/dashboard-service.ts b/apps/as/src/entities/dashboard/api/dashboard-service.ts index 2440c423c..45798ebea 100644 --- a/apps/as/src/entities/dashboard/api/dashboard-service.ts +++ b/apps/as/src/entities/dashboard/api/dashboard-service.ts @@ -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 { + const response = await client.get(`${BASE}/in-progress-docs`, { + params: { searchYear }, + }); + return response.data; +} + export const dashboardService = { getMainDashboard, + getInPrgrsDocPicList, } satisfies DashboardService; diff --git a/apps/as/src/entities/dashboard/api/queries.ts b/apps/as/src/entities/dashboard/api/queries.ts index 9274d520c..6450b442b 100644 --- a/apps/as/src/entities/dashboard/api/queries.ts +++ b/apps/as/src/entities/dashboard/api/queries.ts @@ -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( + searchYear?: number, + options?: { select?: (data: InPrgrsDocPicResponseDto[]) => TData }, +) { + return useQuery({ + queryKey: [...dashboardBaseKey, "getInPrgrsDocPicList", searchYear], + queryFn: () => dashboardService.getInPrgrsDocPicList(searchYear), + select: options?.select, + }); +} diff --git a/apps/as/src/entities/dashboard/index.ts b/apps/as/src/entities/dashboard/index.ts index 19d5b2c0b..0bfbf9afd 100644 --- a/apps/as/src/entities/dashboard/index.ts +++ b/apps/as/src/entities/dashboard/index.ts @@ -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"; diff --git a/apps/as/src/entities/dashboard/model/dashboard-interface.ts b/apps/as/src/entities/dashboard/model/dashboard-interface.ts index fb592680e..e5a18be45 100644 --- a/apps/as/src/entities/dashboard/model/dashboard-interface.ts +++ b/apps/as/src/entities/dashboard/model/dashboard-interface.ts @@ -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; + getInPrgrsDocPicList(searchYear?: number): Promise; } diff --git a/apps/as/src/entities/ntftalk/api/ntftalk-service.ts b/apps/as/src/entities/ntftalk/api/ntftalk-service.ts new file mode 100644 index 000000000..80c5cb19b --- /dev/null +++ b/apps/as/src/entities/ntftalk/api/ntftalk-service.ts @@ -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> { + const response = await client.get>(TEMP, { params }); + return response.data; +} + +async function findTemplateVariables(templateCode: string): Promise { + const response = await client.get( + `${ATA_SEND}/template-variables/${templateCode}`, + ); + return response.data; +} + +async function sendBusiness(body: BusinessSendRequest): Promise { + const response = await client.post(`${ATA_SEND}/business`, body); + return response.data; +} + +export const ntftalkService = { + findTemplatePage, + findTemplateVariables, + sendBusiness, +} satisfies NtftalkService; diff --git a/apps/as/src/entities/ntftalk/api/queries.ts b/apps/as/src/entities/ntftalk/api/queries.ts new file mode 100644 index 000000000..b704d3a51 --- /dev/null +++ b/apps/as/src/entities/ntftalk/api/queries.ts @@ -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), + }); +} diff --git a/apps/as/src/entities/ntftalk/index.ts b/apps/as/src/entities/ntftalk/index.ts new file mode 100644 index 000000000..4d1073f89 --- /dev/null +++ b/apps/as/src/entities/ntftalk/index.ts @@ -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"; diff --git a/apps/as/src/entities/ntftalk/model/ntftalk-interface.ts b/apps/as/src/entities/ntftalk/model/ntftalk-interface.ts new file mode 100644 index 000000000..990e0d2ea --- /dev/null +++ b/apps/as/src/entities/ntftalk/model/ntftalk-interface.ts @@ -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[]; +} + +export interface BusinessSendResponse { + success: boolean; + sentCount: number; + message: string; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface NtftalkService { + findTemplatePage(params: NtftalkTemplateSearchDto): Promise>; + findTemplateVariables(templateCode: string): Promise; + sendBusiness(body: BusinessSendRequest): Promise; +} diff --git a/apps/as/src/entities/rvw-mnpw/api/queries.ts b/apps/as/src/entities/rvw-mnpw/api/queries.ts index 7b38c6acf..22d6f05e9 100644 --- a/apps/as/src/entities/rvw-mnpw/api/queries.ts +++ b/apps/as/src/entities/rvw-mnpw/api/queries.ts @@ -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>( params: RvwMnpwMngPageSearchDto, - options?: { enabled?: boolean }, + options?: { + enabled?: boolean; + select?: (data: PageResponse) => TData; + }, ) { return useQuery({ queryKey: [...rvwMnpwMngBaseKey, "findPage", params], queryFn: () => rvwMnpwMngService.findPage(params), enabled: options?.enabled, + select: options?.select, }); } diff --git a/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts b/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts index 4fee8df2e..61f18b924 100644 --- a/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts +++ b/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts @@ -7,6 +7,7 @@ export interface RvwMnpwMngResponseDto { ognzSn: number; userUuid: string; userUuidNm?: string; + mblTelno?: string; ognzNm?: string; jbpsSeNm?: string; jbgdSeNm?: string; diff --git a/apps/as/src/entities/tus/index.ts b/apps/as/src/entities/tus/index.ts index 5cf2e3fbf..58687d0ef 100644 --- a/apps/as/src/entities/tus/index.ts +++ b/apps/as/src/entities/tus/index.ts @@ -1 +1 @@ -export { handleTusUpload, type TusUploadOptions } from "./lib/utils"; +export { handleTusUpload, type TusErrorResponse, type TusUploadOptions } from "./lib/utils"; diff --git a/apps/as/src/entities/tus/lib/utils.ts b/apps/as/src/entities/tus/lib/utils.ts index 6bb40bfd7..96fcd5195 100644 --- a/apps/as/src/entities/tus/lib/utils.ts +++ b/apps/as/src/entities/tus/lib/utils.ts @@ -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; 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 }; diff --git a/apps/as/src/pages/code-management/lib/configs/cnstp-config.ts b/apps/as/src/pages/code-management/lib/configs/cnstp-config.ts index 26bcc8000..5e11dc252 100644 --- a/apps/as/src/pages/code-management/lib/configs/cnstp-config.ts +++ b/apps/as/src/pages/code-management/lib/configs/cnstp-config.ts @@ -21,6 +21,8 @@ const buildSavePayload = createSavePayloadBuilder ({ mode: CNSTP_SE_SAVE_MODE.CREATE, @@ -31,6 +33,8 @@ const buildSavePayload = createSavePayloadBuilder ({ mode: CNSTP_SE_SAVE_MODE.UPDATE, @@ -41,6 +45,8 @@ const buildSavePayload = createSavePayloadBuilder ({ @@ -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, diff --git a/apps/as/src/pages/code-management/model/cnstp/cnstp-se-grid-types.ts b/apps/as/src/pages/code-management/model/cnstp/cnstp-se-grid-types.ts index 9c2d37c5d..ebf1f01a0 100644 --- a/apps/as/src/pages/code-management/model/cnstp/cnstp-se-grid-types.ts +++ b/apps/as/src/pages/code-management/model/cnstp/cnstp-se-grid-types.ts @@ -13,6 +13,8 @@ export interface CnstpSeDraft { cnstpSeCd: string; cnstpSeCdNm: string; expln: string; + numYn: string; + dcptUnitCnt: number; useYn: string; sortSeq?: number; } diff --git a/apps/as/src/pages/code-management/ui/cnstp-code-tab-panel.tsx b/apps/as/src/pages/code-management/ui/cnstp-code-tab-panel.tsx index 2db182456..b915b10fb 100644 --- a/apps/as/src/pages/code-management/ui/cnstp-code-tab-panel.tsx +++ b/apps/as/src/pages/code-management/ui/cnstp-code-tab-panel.tsx @@ -118,6 +118,8 @@ function CnstpCodeTabPanel() { 코드 코드명 설명 + 숫자 여부 + 소숫점 단위 사용 여부 정렬순서 구분 diff --git a/apps/as/src/pages/code-management/ui/cnstp-se-row.tsx b/apps/as/src/pages/code-management/ui/cnstp-se-row.tsx index 299894821..85c69a3d1 100644 --- a/apps/as/src/pages/code-management/ui/cnstp-se-row.tsx +++ b/apps/as/src/pages/code-management/ui/cnstp-se-row.tsx @@ -62,6 +62,33 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns maxLength={1000} /> + + onChange({ numYn: v })} + className="flex justify-center gap-4" + > +
+ + +
+
+ + +
+
+
+ + + onChange({ dcptUnitCnt: e.target.value ? Number(e.target.value) : undefined }) + } + className="w-20" + /> + - {levelLabel} + + {levelLabel} +
diff --git a/apps/as/src/pages/dashboard/ui/dashboard-page.tsx b/apps/as/src/pages/dashboard/ui/dashboard-page.tsx index dc904c38e..2093dfc48 100644 --- a/apps/as/src/pages/dashboard/ui/dashboard-page.tsx +++ b/apps/as/src/pages/dashboard/ui/dashboard-page.tsx @@ -12,8 +12,8 @@ export function DashboardPage() { return (
-
- +
+
diff --git a/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx b/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx index e355b0585..34180750a 100644 --- a/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx +++ b/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx @@ -33,7 +33,7 @@ export function DocPrgrsSttsCards({ items, isPending, isError }: DocPrgrsSttsCar const itemByCd = new Map(items?.map((item) => [item.docSeComCd, item])); return ( -
+
{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; diff --git a/apps/as/src/pages/dashboard/ui/national-as-map.tsx b/apps/as/src/pages/dashboard/ui/national-as-map.tsx index 096bbed89..82e93676c 100644 --- a/apps/as/src/pages/dashboard/ui/national-as-map.tsx +++ b/apps/as/src/pages/dashboard/ui/national-as-map.tsx @@ -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 ( diff --git a/apps/as/src/pages/design-cnstn/lib/design-cnstn-regist-schema.ts b/apps/as/src/pages/design-cnstn/lib/design-cnstn-regist-schema.ts index fbb6c9147..a5dda2d38 100644 --- a/apps/as/src/pages/design-cnstn/lib/design-cnstn-regist-schema.ts +++ b/apps/as/src/pages/design-cnstn/lib/design-cnstn-regist-schema.ts @@ -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, "설계자 이름은 필수입니다"), diff --git a/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx index 85242d34b..b55dd35a2 100644 --- a/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx +++ b/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx @@ -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; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx index 9e91e9bad..a4f96c006 100644 --- a/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx +++ b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx @@ -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 }) => ( - + ( - + 파일보완 -
{tableComponent}
+
{tableComponent}
{pjtcoAjmtPage?.content?.length ? ( diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-form-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-form-section.tsx index 759b1f0df..075d488d1 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-form-section.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-form-section.tsx @@ -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 }) => ( - + ( - + ( - + ( - + , +): string { + return templateContent.replace(/#?\{([^}]+)\}/g, (_, key: string) => variables[key.trim()] ?? ""); +} + +export function getMergedVariables( + recipient: SelectedNtitlkRcvr, + commonValues: Record, +): Record { + return { + ...commonValues, + ...recipient.variableOverrides, + TEL_NUM: recipient.telNo, + }; +} diff --git a/apps/as/src/pages/ntitlk-sndng/lib/to-ntitlk-rcvr.ts b/apps/as/src/pages/ntitlk-sndng/lib/to-ntitlk-rcvr.ts new file mode 100644 index 000000000..f8efdf409 --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/lib/to-ntitlk-rcvr.ts @@ -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): NtitlkRcvr[] { + const rcvrByUserUuid = new Map(); + + 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), + })); +} diff --git a/apps/as/src/pages/ntitlk-sndng/lib/use-rcvr-row-selection.ts b/apps/as/src/pages/ntitlk-sndng/lib/use-rcvr-row-selection.ts new file mode 100644 index 000000000..0865ae8ee --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/lib/use-rcvr-row-selection.ts @@ -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; + 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 = (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) => Boolean(row.original.telNo), + enableMultiRowSelection: true, + getRowId: (rcvr: NtitlkRcvr) => rcvr.rcvrKey, + onRowSelectionChange: handleRowSelectionChange, + }, + }; +} diff --git a/apps/as/src/pages/ntitlk-sndng/model/ntitlk-rcvr.ts b/apps/as/src/pages/ntitlk-sndng/model/ntitlk-rcvr.ts new file mode 100644 index 000000000..d19224814 --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/model/ntitlk-rcvr.ts @@ -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; +} + +/** 수신자 목록에서 선택 상태가 바뀐 행 */ +export interface RcvrSelectionChange { + added: NtitlkRcvr[]; + removedKeys: string[]; +} diff --git a/apps/as/src/pages/ntitlk-sndng/model/rcvr-table-columns.tsx b/apps/as/src/pages/ntitlk-sndng/model/rcvr-table-columns.tsx new file mode 100644 index 000000000..e1230a092 --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/model/rcvr-table-columns.tsx @@ -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(); + +function formatOptional(value: string | undefined) { + return value?.trim() ? value : "-"; +} + +const selectColumn = columnHelper.display({ + id: "select", + header: ({ table }) => ( + table.toggleAllRowsSelected(!!checked)} + aria-label="전체 선택" + /> + ), + meta: { align: "center", width: 48 }, + cell: ({ row }) => ( + 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()), + }), +]; diff --git a/apps/as/src/pages/ntitlk-sndng/model/vrbl-mapping-options.ts b/apps/as/src/pages/ntitlk-sndng/model/vrbl-mapping-options.ts new file mode 100644 index 000000000..4be02c077 --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/model/vrbl-mapping-options.ts @@ -0,0 +1,23 @@ +import type { NtitlkRcvr } from "./ntitlk-rcvr"; + +export const CUSTOM_VRBL_MAPPING = "custom"; + +export type VrblMappingKey = Exclude | 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: "직접입력" }, +]; diff --git a/apps/as/src/pages/ntitlk-sndng/ui/in-prgrs-doc-rcvr-list.tsx b/apps/as/src/pages/ntitlk-sndng/ui/in-prgrs-doc-rcvr-list.tsx new file mode 100644 index 000000000..80ce1eab0 --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/ui/in-prgrs-doc-rcvr-list.tsx @@ -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; + 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 ( + + {tableComponent} + + ); +} + +export { InPrgrsDocRcvrList }; diff --git a/apps/as/src/pages/ntitlk-sndng/ui/ntitlk-prvw-dialog.tsx b/apps/as/src/pages/ntitlk-sndng/ui/ntitlk-prvw-dialog.tsx new file mode 100644 index 000000000..d770d24d8 --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/ui/ntitlk-prvw-dialog.tsx @@ -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 ( + + + + 알림톡 미리보기 + + 수신자: {recipient?.userNm || "-"} / {recipient?.telNo || "-"} + + + + + + + + + + + ); +} + +export { NtitlkPrvwDialog }; diff --git a/apps/as/src/pages/ntitlk-sndng/ui/ntitlk-prvw.tsx b/apps/as/src/pages/ntitlk-sndng/ui/ntitlk-prvw.tsx new file mode 100644 index 000000000..1529b4479 --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/ui/ntitlk-prvw.tsx @@ -0,0 +1,25 @@ +const SNDR_NM = "디지털 행정지원 시스템"; + +interface NtitlkPrvwProps { + message: string; +} + +function NtitlkPrvw({ message }: NtitlkPrvwProps) { + return ( +
+
+
+ {SNDR_NM} +
+ 알림톡 도착 +
+
+ {message} +
+
+
+
+ ); +} + +export { NtitlkPrvw }; diff --git a/apps/as/src/pages/ntitlk-sndng/ui/ntitlk-sndng-page.tsx b/apps/as/src/pages/ntitlk-sndng/ui/ntitlk-sndng-page.tsx new file mode 100644 index 000000000..389f32857 --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/ui/ntitlk-sndng-page.tsx @@ -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([]); + const [commonVariableValues, setCommonVariableValues] = useState>({}); + const [commonVariableMapping, setCommonVariableMapping] = useState< + Record + >({}); + const [previewRecipient, setPreviewRecipient] = useState(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 = {}; + + 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 ( +
+
+ +
+ + +
+
+ +
+
+

발송 목록

+
+ + +
+
+ + {variableNames.length > 0 && ( + + setCommonVariableMapping((prev) => ({ ...prev, [variableName]: mappingKey })) + } + onCustomValueChange={(variableName, value) => + setCommonVariableValues((prev) => ({ ...prev, [variableName]: value })) + } + onApply={handleApplyCommonValues} + onReset={handleResetCommonValues} + /> + )} + + +
+ + +
+ ); +} + +export { NtitlkSndngPage }; diff --git a/apps/as/src/pages/ntitlk-sndng/ui/rcvr-list-card.tsx b/apps/as/src/pages/ntitlk-sndng/ui/rcvr-list-card.tsx new file mode 100644 index 000000000..323b522a3 --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/ui/rcvr-list-card.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; + +interface RcvrListCardProps { + title: string; + selectedCount: number; + children: ReactNode; +} + +function RcvrListCard({ title, selectedCount, children }: RcvrListCardProps) { + return ( +
+
+

{title}

+

+ {selectedCount}명 선택 +

+
+
{children}
+
+ ); +} + +export { RcvrListCard }; diff --git a/apps/as/src/pages/ntitlk-sndng/ui/rvw-mnpw-rcvr-list.tsx b/apps/as/src/pages/ntitlk-sndng/ui/rvw-mnpw-rcvr-list.tsx new file mode 100644 index 000000000..aac0b75d1 --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/ui/rvw-mnpw-rcvr-list.tsx @@ -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; + 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 ( + + {tableComponent} + + ); +} + +export { RvwMnpwRcvrList }; diff --git a/apps/as/src/pages/ntitlk-sndng/ui/sndng-list.tsx b/apps/as/src/pages/ntitlk-sndng/ui/sndng-list.tsx new file mode 100644 index 000000000..82f8b188b --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/ui/sndng-list.tsx @@ -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 ( +

+ 선택된 발송 대상이 없습니다. +

+ ); + + return ( +
+ + + + 이름 + 전화번호 + {variableNames.map((variableName) => ( + + #{variableName} + + ))} + 버튼 + + + + {recipients.map((recipient) => ( + + {recipient.userNm} + {recipient.telNo} + {variableNames.map((variableName) => ( + + + onVariableChange(recipient.rcvrKey, variableName, event.target.value) + } + className="w-full min-w-[150px]" + /> + + ))} + +
+ + +
+
+
+ ))} +
+
+
+ ); +} + +export { SndngList }; diff --git a/apps/as/src/pages/ntitlk-sndng/ui/tmplt-select-panel.tsx b/apps/as/src/pages/ntitlk-sndng/ui/tmplt-select-panel.tsx new file mode 100644 index 000000000..7cead2969 --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/ui/tmplt-select-panel.tsx @@ -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 ( +
+
+

알림톡 템플릿 선택

+ +
+ +
+

미리보기

+ +
+
+ ); +} + +export { TmpltSelectPanel }; diff --git a/apps/as/src/pages/ntitlk-sndng/ui/vrbl-bulk-apply.tsx b/apps/as/src/pages/ntitlk-sndng/ui/vrbl-bulk-apply.tsx new file mode 100644 index 000000000..571bd83dc --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/ui/vrbl-bulk-apply.tsx @@ -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; + customValues: Record; + 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 ( +
+

변수 일괄 적용

+
+ {variableNames.map((variableName) => ( +
+ + + {mapping[variableName] === CUSTOM_VRBL_MAPPING && ( + onCustomValueChange(variableName, event.target.value)} + /> + )} +
+ ))} +
+ + +
+
+
+ ); +} + +export { VrblBulkApply }; diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/biz-cst-matrix-form-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/biz-cst-matrix-form-section.tsx index efbdfc462..a5ebdae97 100644 --- a/apps/as/src/pages/sewer-bsc-plan/ui/sections/biz-cst-matrix-form-section.tsx +++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/biz-cst-matrix-form-section.tsx @@ -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() { handleChange(item.code, step.planStpNo, e.target.value)} readOnly={item.readonly} diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/fnrs-cst-form-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/fnrs-cst-form-section.tsx index 3779c4c22..f4be7a396 100644 --- a/apps/as/src/pages/sewer-bsc-plan/ui/sections/fnrs-cst-form-section.tsx +++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/fnrs-cst-form-section.tsx @@ -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() { item.planStpNo === planStpNo); if (index < 0) return; @@ -104,11 +109,14 @@ function OutlineFormSection() { handleChange(planStpNo, row.key, e.target.value)} + onChange={(e) => + handleChange(planStpNo, row.key, e.target.value, row.decimals) + } /> diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/tmplnt-form-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/tmplnt-form-section.tsx index 51f59d559..bee7a8720 100644 --- a/apps/as/src/pages/sewer-bsc-plan/ui/sections/tmplnt-form-section.tsx +++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/tmplnt-form-section.tsx @@ -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(); const fieldName = `sewerBscPlanTmplntList.${facilityIndex}.sewerBscPlanTmplntDtlList.${planStpNo}.${fieldKey}` as const; + const hasDecimals = decimals !== undefined; return ( ( { - 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); }} /> @@ -193,7 +206,12 @@ function TmplntFacilityMatrix({ ))} - + {changeStepHeaders.map((step) => ( @@ -201,6 +219,7 @@ function TmplntFacilityMatrix({ facilityIndex={facilityIndex} planStpNo={step.planStpNo} fieldKey={row.key} + decimals={row.decimals} /> ))} diff --git a/apps/as/src/shared/config/api.ts b/apps/as/src/shared/config/api.ts index a5bf296e2..4fcf3ca42 100644 --- a/apps/as/src/shared/config/api.ts +++ b/apps/as/src/shared/config/api.ts @@ -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", diff --git a/apps/as/src/shared/lib/index.ts b/apps/as/src/shared/lib/index.ts index 3c8813abb..4735424af 100644 --- a/apps/as/src/shared/lib/index.ts +++ b/apps/as/src/shared/lib/index.ts @@ -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"; diff --git a/apps/as/src/shared/lib/number-input.ts b/apps/as/src/shared/lib/number-input.ts new file mode 100644 index 000000000..ac65664b2 --- /dev/null +++ b/apps/as/src/shared/lib/number-input.ts @@ -0,0 +1,22 @@ +import type { KeyboardEvent } from "react"; + +const MAX_INTEGER_DIGITS = 10; + +function preventMinusKey(e: KeyboardEvent) { + 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); +} diff --git a/apps/as/src/shared/lib/use-data-table.tsx b/apps/as/src/shared/lib/use-data-table.tsx index df3b16f6e..c35b0ef10 100644 --- a/apps/as/src/shared/lib/use-data-table.tsx +++ b/apps/as/src/shared/lib/use-data-table.tsx @@ -19,16 +19,23 @@ interface DataTableOptions { } interface UseDataTableOptions { - query: UseQueryResult>; + /** 페이징 응답(PageResponse) 또는 페이징 없는 목록(T[]) */ + query: UseQueryResult | T[]>; tableOptions: Omit, "data" | "rowCount">; dataTableOptions?: DataTableOptions; } +const EMPTY_ROWS: never[] = []; + export function useDataTable({ query, tableOptions, dataTableOptions }: UseDataTableOptions) { + 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 { diff --git a/apps/as/src/shared/lib/use-tus.ts b/apps/as/src/shared/lib/use-tus.ts index 7a48cad6c..f07f009c0 100644 --- a/apps/as/src/shared/lib/use-tus.ts +++ b/apps/as/src/shared/lib/use-tus.ts @@ -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; } +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 => { - 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); }, }); diff --git a/apps/as/src/shared/ui/file-add-button.tsx b/apps/as/src/shared/ui/file-add-button.tsx index 36c41406e..922e660b8 100644 --- a/apps/as/src/shared/ui/file-add-button.tsx +++ b/apps/as/src/shared/ui/file-add-button.tsx @@ -40,7 +40,6 @@ function FileAddButton({ + {onDmndSplmnt && ( + + )}
); } diff --git a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx index db9db7c32..5a382d640 100644 --- a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx +++ b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx @@ -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} />