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/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 46bd4250f..0bfbf9afd 100644 --- a/apps/as/src/entities/dashboard/index.ts +++ b/apps/as/src/entities/dashboard/index.ts @@ -1,11 +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 abad209c5..e5a18be45 100644 --- a/apps/as/src/entities/dashboard/model/dashboard-interface.ts +++ b/apps/as/src/entities/dashboard/model/dashboard-interface.ts @@ -65,7 +65,22 @@ export interface DashboardMainResponseDto { 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/pages/ntitlk-sndng/index.ts b/apps/as/src/pages/ntitlk-sndng/index.ts new file mode 100644 index 000000000..86f96be4f --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/index.ts @@ -0,0 +1 @@ +export { NtitlkSndngPage } from "./ui/ntitlk-sndng-page"; diff --git a/apps/as/src/pages/ntitlk-sndng/lib/build-tmplt-msg.ts b/apps/as/src/pages/ntitlk-sndng/lib/build-tmplt-msg.ts new file mode 100644 index 000000000..5e0819e4d --- /dev/null +++ b/apps/as/src/pages/ntitlk-sndng/lib/build-tmplt-msg.ts @@ -0,0 +1,20 @@ +import type { SelectedNtitlkRcvr } from "../model/ntitlk-rcvr"; + +/** 템플릿 본문의 `#{변수}`를 값으로 치환한다. 값이 없는 변수는 빈 문자열이 된다. */ +export function buildTemplateMessage( + templateContent: string, + variables: Record, +): 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 710a81fcf..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,7 +3,7 @@ import { useEffect, useMemo } from "react"; import { useFormContext, useWatch } from "react-hook-form"; -import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib"; +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"; @@ -51,7 +51,7 @@ function BizCstMatrixFormSection() { ]; function handleChange(acntgSeCd: string, planStpNo: number, value: string) { - const normalized = truncateDecimals(stripMinusSign(value), 3); + const normalized = truncateDecimals(sanitizeNumberInput(value), 3); const numValue = Number(normalized.replace(/,/g, "")) || 0; const next = recalculateBizCstMatrix( setAmt(getValues("bizCstList"), acntgSeCd, planStpNo, numValue), 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 7ef5947f5..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,7 +3,7 @@ import { useMemo } from "react"; import { useFormContext, useWatch } from "react-hook-form"; -import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib"; +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"; @@ -59,7 +59,7 @@ function FnrsCstFormSection() { ); function handleChange(planStpNo: number, fieldKey: EditableFnrsKey, value: string) { - const numValue = Number(stripMinusSign(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; diff --git a/apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-form-section.tsx b/apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-form-section.tsx index 6375139a0..50bb4d629 100644 --- a/apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-form-section.tsx +++ b/apps/as/src/pages/sewer-bsc-plan/ui/sections/outline-form-section.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useFormContext, useWatch } from "react-hook-form"; import dynamic from "next/dynamic"; -import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib"; +import { 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"; @@ -62,8 +62,8 @@ function OutlineFormSection() { value: string, decimals?: number, ) { - const unsigned = stripMinusSign(value); - const normalized = decimals === undefined ? unsigned : truncateDecimals(unsigned, decimals); + 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); 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 8ee14fab2..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 @@ -8,7 +8,7 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { getDefaultFileKey, NON_NEGATIVE_INPUT_PROPS, - stripMinusSign, + sanitizeNumberInput, useTus, } from "@/shared/lib"; import { TextShimmer } from "@/shared/ui"; @@ -107,7 +107,7 @@ function TmplntDtlInput({ facilityIndex, planStpNo, fieldKey, decimals }: Tmplnt className="text-right" value={field.value ?? ""} onChange={(e) => { - const value = stripMinusSign(e.target.value.replace(/,/g, "")); + const value = sanitizeNumberInput(e.target.value.replace(/,/g, "")); const normalized = hasDecimals ? truncateDecimals(value, decimals) : value; field.onChange(Number(normalized) || 0); }} 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 b344b56bd..4735424af 100644 --- a/apps/as/src/shared/lib/index.ts +++ b/apps/as/src/shared/lib/index.ts @@ -2,7 +2,7 @@ export { getCookie, removeCookie, setCookie } from "./cookie"; export { dayjs } from "./dayjs"; export { getListHref, withListQs } from "./list-return-href"; export { matchQueryStatus } from "./match-query-status"; -export { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "./number-input"; +export { 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 index 228f6948b..ac65664b2 100644 --- a/apps/as/src/shared/lib/number-input.ts +++ b/apps/as/src/shared/lib/number-input.ts @@ -1,5 +1,7 @@ import type { KeyboardEvent } from "react"; +const MAX_INTEGER_DIGITS = 10; + function preventMinusKey(e: KeyboardEvent) { if (e.key === "-") e.preventDefault(); } @@ -7,7 +9,14 @@ function preventMinusKey(e: KeyboardEvent) { /** type="number" Input에 스프레드해 음수 입력(키·스피너)을 막는다 */ export const NON_NEGATIVE_INPUT_PROPS = { min: 0, onKeyDown: preventMinusKey } as const; -/** 붙여넣기 등으로 들어온 음수 부호를 제거한다 */ -export function stripMinusSign(value: string): string { - return value.replace(/-/g, ""); +/** 정수부를 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/widgets/pbadms-sprt/ui/business-cost-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/business-cost-form-section.tsx index fb27309aa..8f32111c4 100644 --- a/apps/as/src/widgets/pbadms-sprt/ui/business-cost-form-section.tsx +++ b/apps/as/src/widgets/pbadms-sprt/ui/business-cost-form-section.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo } from "react"; import { useFormContext, useWatch } from "react-hook-form"; -import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib"; +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"; @@ -80,7 +80,7 @@ function BusinessCostFormSection() { }, [costItems, getValues, setValue]); function handleCostChange(index: number, fieldKey: CostFieldKey, value: string) { - const numValue = Number(truncateDecimals(stripMinusSign(value), COST_DECIMAL_PLACES)) || 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); diff --git a/apps/as/src/widgets/pbadms-sprt/ui/business-scale-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/business-scale-form-section.tsx index c7342b670..88e4dba0b 100644 --- a/apps/as/src/widgets/pbadms-sprt/ui/business-scale-form-section.tsx +++ b/apps/as/src/widgets/pbadms-sprt/ui/business-scale-form-section.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo } from "react"; import { useFormContext, useWatch } from "react-hook-form"; -import { NON_NEGATIVE_INPUT_PROPS, stripMinusSign } from "@/shared/lib"; +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"; @@ -78,8 +78,8 @@ function BusinessScaleFormSection() { function handleScaleChange(index: number, fieldKey: ScaleFieldKey, value: string) { const item = scaleItems[index]; - const unsigned = stripMinusSign(value); - const normalized = item?.numYn ? truncateDecimals(unsigned, item.dcptUnitCnt) : unsigned; + 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 }; @@ -101,6 +101,7 @@ function BusinessScaleFormSection() { return (
+

(단위: 백만원)

diff --git a/apps/as/src/widgets/pbadms-sprt/ui/fnrs-shar-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/fnrs-shar-form-section.tsx index 9604ac7fc..68a5019fa 100644 --- a/apps/as/src/widgets/pbadms-sprt/ui/fnrs-shar-form-section.tsx +++ b/apps/as/src/widgets/pbadms-sprt/ui/fnrs-shar-form-section.tsx @@ -6,7 +6,7 @@ import { useFormContext, useWatch } from "react-hook-form"; import { getDefaultFileKey, NON_NEGATIVE_INPUT_PROPS, - stripMinusSign, + sanitizeNumberInput, useTus, } from "@/shared/lib"; import { AttachmentFileField, SectionCollapsible } from "@/shared/ui"; @@ -147,7 +147,7 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar if (fieldKey === "rmrkCn") { draft[index] = { ...row, rmrkCn: value }; } else { - const numValue = Number(stripMinusSign(value)) || 0; + const numValue = Number(sanitizeNumberInput(value)) || 0; draft[index] = { ...row, [fieldKey]: numValue }; }