Compare commits

...
4 Commits
Author SHA1 Message Date
chanii8 ad1e517472 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m56s
2026-10-02 17:55:30 +09:00
chanii8 17ed5eb46b Merge branch 'iipbs/as/dev' 2026-10-02 17:54:53 +09:00
chanii8 1a2faf02da [as] feat: 알림톡발송 페이지 구현 2026-10-02 17:54:25 +09:00
chanii8 d6522338aa [as] chore: 행정지원 숫자 폼 최대자릿수 제한 2026-10-02 16:37:26 +09:00
38 changed files with 1132 additions and 27 deletions
@@ -0,0 +1,3 @@
import { NtitlkSndngPage } from "@/pages/ntitlk-sndng";
export default NtitlkSndngPage;
@@ -5,6 +5,7 @@ import type {
DashboardMainResponseDto,
DashboardMainSearchDto,
DashboardService,
InPrgrsDocPicResponseDto,
} from "../model/dashboard-interface";
const BASE = ENDPOINT.AS_SERVICE.DASHBOARD;
@@ -18,6 +19,14 @@ async function getMainDashboard(
return response.data;
}
async function getInPrgrsDocPicList(searchYear?: number): Promise<InPrgrsDocPicResponseDto[]> {
const response = await client.get<InPrgrsDocPicResponseDto[]>(`${BASE}/in-progress-docs`, {
params: { searchYear },
});
return response.data;
}
export const dashboardService = {
getMainDashboard,
getInPrgrsDocPicList,
} satisfies DashboardService;
+15 -1
View File
@@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type { DashboardMainSearchDto } from "../model/dashboard-interface";
import type {
DashboardMainSearchDto,
InPrgrsDocPicResponseDto,
} from "../model/dashboard-interface";
import { dashboardService } from "./dashboard-service";
@@ -16,3 +19,14 @@ export function useGetMainDashboard(params: DashboardMainSearchDto = EMPTY_SEARC
queryFn: () => dashboardService.getMainDashboard(params),
});
}
export function useGetInPrgrsDocPicList<TData = InPrgrsDocPicResponseDto[]>(
searchYear?: number,
options?: { select?: (data: InPrgrsDocPicResponseDto[]) => TData },
) {
return useQuery({
queryKey: [...dashboardBaseKey, "getInPrgrsDocPicList", searchYear],
queryFn: () => dashboardService.getInPrgrsDocPicList(searchYear),
select: options?.select,
});
}
+2 -1
View File
@@ -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";
@@ -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<DashboardMainResponseDto>;
getInPrgrsDocPicList(searchYear?: number): Promise<InPrgrsDocPicResponseDto[]>;
}
@@ -0,0 +1,39 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
BusinessSendRequest,
BusinessSendResponse,
NtftalkService,
NtftalkTemplate,
NtftalkTemplateSearchDto,
TemplateVariablesResponse,
} from "../model/ntftalk-interface";
const { TEMP, ATA_SEND } = ENDPOINT.NTFTALK_SERVICE;
async function findTemplatePage(
params: NtftalkTemplateSearchDto,
): Promise<PageResponse<NtftalkTemplate>> {
const response = await client.get<PageResponse<NtftalkTemplate>>(TEMP, { params });
return response.data;
}
async function findTemplateVariables(templateCode: string): Promise<TemplateVariablesResponse> {
const response = await client.get<TemplateVariablesResponse>(
`${ATA_SEND}/template-variables/${templateCode}`,
);
return response.data;
}
async function sendBusiness(body: BusinessSendRequest): Promise<BusinessSendResponse> {
const response = await client.post<BusinessSendResponse>(`${ATA_SEND}/business`, body);
return response.data;
}
export const ntftalkService = {
findTemplatePage,
findTemplateVariables,
sendBusiness,
} satisfies NtftalkService;
@@ -0,0 +1,39 @@
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { useMutationWrapper } from "@/shared/lib";
import type { BusinessSendRequest, NtftalkTemplateSearchDto } from "../model/ntftalk-interface";
import { ntftalkService } from "./ntftalk-service";
/** 발송 화면에서 선택 가능한 템플릿: 검수 승인(APR)된 기본형(BA) */
const SNDNG_TEMPLATE_SEARCH: NtftalkTemplateSearchDto = {
sort: "createdAt,desc",
senderKey: "",
inspectionStatus: "APR",
templateMessageType: "BA",
page: 0,
size: 10,
};
export function useNtftalkTemplateList() {
return useQuery({
queryKey: [ENDPOINT.NTFTALK_SERVICE.TEMP, "findTemplatePage", SNDNG_TEMPLATE_SEARCH],
queryFn: () => ntftalkService.findTemplatePage(SNDNG_TEMPLATE_SEARCH),
});
}
export function useNtftalkTemplateVariables(templateCode: string) {
return useQuery({
queryKey: [ENDPOINT.NTFTALK_SERVICE.ATA_SEND, "findTemplateVariables", templateCode],
queryFn: () => ntftalkService.findTemplateVariables(templateCode),
enabled: Boolean(templateCode),
});
}
export function useSendNtftalkBusiness() {
return useMutationWrapper({
mutationFn: (body: BusinessSendRequest) => ntftalkService.sendBusiness(body),
});
}
+14
View File
@@ -0,0 +1,14 @@
export { ntftalkService } from "./api/ntftalk-service";
export {
useNtftalkTemplateList,
useNtftalkTemplateVariables,
useSendNtftalkBusiness,
} from "./api/queries";
export type {
BusinessSendRequest,
BusinessSendResponse,
NtftalkService,
NtftalkTemplate,
NtftalkTemplateSearchDto,
TemplateVariablesResponse,
} from "./model/ntftalk-interface";
@@ -0,0 +1,41 @@
import type { PageableParams, PageResponse } from "@/shared/model";
/** 백엔드 알림톡 템플릿 응답 */
export interface NtftalkTemplate {
templateCode: string;
templateName: string;
templateContent: string;
}
/** 템플릿 목록 검색 조건 + Pageable */
export interface NtftalkTemplateSearchDto extends PageableParams {
senderKey?: string;
inspectionStatus?: string;
templateMessageType?: string;
}
export interface TemplateVariablesResponse {
templateCode: string;
variables: string[];
count: number;
}
export interface BusinessSendRequest {
templateCode: string;
callback?: string;
fixedRecipientNum?: string;
recipients: Record<string, string>[];
}
export interface BusinessSendResponse {
success: boolean;
sentCount: number;
message: string;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface NtftalkService {
findTemplatePage(params: NtftalkTemplateSearchDto): Promise<PageResponse<NtftalkTemplate>>;
findTemplateVariables(templateCode: string): Promise<TemplateVariablesResponse>;
sendBusiness(body: BusinessSendRequest): Promise<BusinessSendResponse>;
}
+8 -2
View File
@@ -1,9 +1,11 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
RvwMnpwMngPageSearchDto,
RvwMnpwMngResponseDto,
RvwMnpwMngSaveRequestDto,
RvwMnpwMngSearchDto,
RvwMnpwMngUpdateRequestDto,
@@ -13,14 +15,18 @@ import { rvwMnpwMngService } from "./rvw-mnpw-service";
const rvwMnpwMngBaseKey = [ENDPOINT.AS_SERVICE.RVW_MNPW] as const;
export function useRvwMnpwMngPage(
export function useRvwMnpwMngPage<TData = PageResponse<RvwMnpwMngResponseDto>>(
params: RvwMnpwMngPageSearchDto,
options?: { enabled?: boolean },
options?: {
enabled?: boolean;
select?: (data: PageResponse<RvwMnpwMngResponseDto>) => TData;
},
) {
return useQuery({
queryKey: [...rvwMnpwMngBaseKey, "findPage", params],
queryFn: () => rvwMnpwMngService.findPage(params),
enabled: options?.enabled,
select: options?.select,
});
}
@@ -7,6 +7,7 @@ export interface RvwMnpwMngResponseDto {
ognzSn: number;
userUuid: string;
userUuidNm?: string;
mblTelno?: string;
ognzNm?: string;
jbpsSeNm?: string;
jbgdSeNm?: string;
+1
View File
@@ -0,0 +1 @@
export { NtitlkSndngPage } from "./ui/ntitlk-sndng-page";
@@ -0,0 +1,20 @@
import type { SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
/** 템플릿 본문의 `#{변수}`를 값으로 치환한다. 값이 없는 변수는 빈 문자열이 된다. */
export function buildTemplateMessage(
templateContent: string,
variables: Record<string, string>,
): string {
return templateContent.replace(/#?\{([^}]+)\}/g, (_, key: string) => variables[key.trim()] ?? "");
}
export function getMergedVariables(
recipient: SelectedNtitlkRcvr,
commonValues: Record<string, string>,
): Record<string, string> {
return {
...commonValues,
...recipient.variableOverrides,
TEL_NUM: recipient.telNo,
};
}
@@ -0,0 +1,36 @@
import type { PageResponse } from "@/shared/model";
import type { InPrgrsDocPicResponseDto } from "@/entities/dashboard";
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
import type { NtitlkRcvr } from "../model/ntitlk-rcvr";
/** 검토인력은 결재구분별로 행이 나뉘어 내려오므로 사용자 기준으로 한 명씩만 남긴다. */
export function selectRvwMnpwRcvrList(page: PageResponse<RvwMnpwMngResponseDto>): NtitlkRcvr[] {
const rcvrByUserUuid = new Map<string, NtitlkRcvr>();
page.content.forEach((row) => {
if (rcvrByUserUuid.has(row.userUuid)) return;
rcvrByUserUuid.set(row.userUuid, {
rcvrKey: `rvw:${row.userUuid}`,
userNm: row.userUuidNm ?? "",
telNo: row.mblTelno ?? "",
ognzNm: row.ognzNm,
});
});
return [...rcvrByUserUuid.values()];
}
export function selectInPrgrsDocRcvrList(rows: InPrgrsDocPicResponseDto[]): NtitlkRcvr[] {
return rows.map((row) => ({
rcvrKey: `doc:${row.docSeComCd}:${row.docNo}`,
userNm: row.aplcntNm ?? "",
telNo: row.aplcntMblTelno ?? "",
docNo: row.docNo,
docNm: row.docNm,
docSeNm: row.docSeNm,
prgrsSttsNm: row.prgrsSttsNm,
prcsDdln: row.prcsDdln?.slice(0, 10),
}));
}
@@ -0,0 +1,52 @@
import { useMemo } from "react";
import type { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table";
import type { NtitlkRcvr, RcvrSelectionChange } from "../model/ntitlk-rcvr";
interface UseRcvrRowSelectionOptions {
rcvrList: NtitlkRcvr[];
selectedRcvrKeys: Set<string>;
onRcvrSelectionChange: (change: RcvrSelectionChange) => void;
}
/**
* 발송 목록(페이지 상태)을 수신자 테이블의 행 선택 상태로 연결한다.
* 선택 상태를 테이블이 따로 들고 있지 않아, 발송 목록에서 제거하면 체크도 함께 풀린다.
*/
export function useRcvrRowSelection({
rcvrList,
selectedRcvrKeys,
onRcvrSelectionChange,
}: UseRcvrRowSelectionOptions) {
const rowSelection = useMemo(() => {
const selection: RowSelectionState = {};
rcvrList.forEach((rcvr) => {
if (selectedRcvrKeys.has(rcvr.rcvrKey)) selection[rcvr.rcvrKey] = true;
});
return selection;
}, [rcvrList, selectedRcvrKeys]);
const handleRowSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {
const next = typeof updater === "function" ? updater(rowSelection) : updater;
onRcvrSelectionChange({
added: rcvrList.filter((rcvr) => next[rcvr.rcvrKey] && !rowSelection[rcvr.rcvrKey]),
removedKeys: rcvrList
.filter((rcvr) => !next[rcvr.rcvrKey] && rowSelection[rcvr.rcvrKey])
.map((rcvr) => rcvr.rcvrKey),
});
};
return {
selectedCount: Object.keys(rowSelection).length,
rowSelectionOptions: {
state: { rowSelection },
// 전화번호가 없으면 발송할 수 없으므로 선택 대상에서 제외한다.
enableRowSelection: (row: Row<NtitlkRcvr>) => Boolean(row.original.telNo),
enableMultiRowSelection: true,
getRowId: (rcvr: NtitlkRcvr) => rcvr.rcvrKey,
onRowSelectionChange: handleRowSelectionChange,
},
};
}
@@ -0,0 +1,23 @@
/** 검토인력·문서 담당자 목록을 발송 목록 하나로 합치기 위한 수신자 모델 */
export interface NtitlkRcvr {
/** 행 식별자: 검토인력 `rvw:{userUuid}`, 문서 담당자 `doc:{docSeComCd}:{docNo}` */
rcvrKey: string;
userNm: string;
telNo: string;
ognzNm?: string;
docNo?: string;
docNm?: string;
docSeNm?: string;
prgrsSttsNm?: string;
prcsDdln?: string;
}
export interface SelectedNtitlkRcvr extends NtitlkRcvr {
variableOverrides: Record<string, string>;
}
/** 수신자 목록에서 선택 상태가 바뀐 행 */
export interface RcvrSelectionChange {
added: NtitlkRcvr[];
removedKeys: string[];
}
@@ -0,0 +1,77 @@
"use client";
import { createColumnHelper } from "@tanstack/react-table";
import { Checkbox } from "@common/components";
import type { NtitlkRcvr } from "./ntitlk-rcvr";
const columnHelper = createColumnHelper<NtitlkRcvr>();
function formatOptional(value: string | undefined) {
return value?.trim() ? value : "-";
}
const selectColumn = columnHelper.display({
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(checked) => table.toggleAllRowsSelected(!!checked)}
aria-label="전체 선택"
/>
),
meta: { align: "center", width: 48 },
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
aria-label="행 선택"
/>
),
});
export const rvwMnpwRcvrColumns = [
selectColumn,
columnHelper.accessor("userNm", {
header: "이름",
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("ognzNm", {
header: "소속",
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("telNo", {
header: "전화번호",
cell: ({ getValue }) => formatOptional(getValue()),
}),
];
export const inPrgrsDocRcvrColumns = [
selectColumn,
columnHelper.accessor("docNo", {
header: "문서번호",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("docNm", {
header: "문서명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("userNm", {
header: "신청자",
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("prgrsSttsNm", {
header: "상태",
cell: ({ getValue }) => formatOptional(getValue()),
}),
];
@@ -0,0 +1,23 @@
import type { NtitlkRcvr } from "./ntitlk-rcvr";
export const CUSTOM_VRBL_MAPPING = "custom";
export type VrblMappingKey = Exclude<keyof NtitlkRcvr, "rcvrKey"> | typeof CUSTOM_VRBL_MAPPING;
interface VrblMappingOption {
value: VrblMappingKey;
label: string;
}
/** 변수 일괄 적용 시 템플릿 변수에 채울 수신자 필드 */
export const VRBL_MAPPING_OPTIONS: readonly VrblMappingOption[] = [
{ value: "userNm", label: "이름" },
{ value: "telNo", label: "전화번호" },
{ value: "ognzNm", label: "소속" },
{ value: "docNo", label: "문서번호" },
{ value: "docNm", label: "문서명" },
{ value: "docSeNm", label: "문서구분" },
{ value: "prgrsSttsNm", label: "진행상태" },
{ value: "prcsDdln", label: "처리기한" },
{ value: CUSTOM_VRBL_MAPPING, label: "직접입력" },
];
@@ -0,0 +1,45 @@
import { getCoreRowModel } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useGetInPrgrsDocPicList } from "@/entities/dashboard";
import { selectInPrgrsDocRcvrList } from "../lib/to-ntitlk-rcvr";
import { useRcvrRowSelection } from "../lib/use-rcvr-row-selection";
import type { NtitlkRcvr, RcvrSelectionChange } from "../model/ntitlk-rcvr";
import { inPrgrsDocRcvrColumns } from "../model/rcvr-table-columns";
import { RcvrListCard } from "./rcvr-list-card";
const EMPTY_RCVR_LIST: NtitlkRcvr[] = [];
interface InPrgrsDocRcvrListProps {
selectedRcvrKeys: Set<string>;
onRcvrSelectionChange: (change: RcvrSelectionChange) => void;
}
function InPrgrsDocRcvrList({ selectedRcvrKeys, onRcvrSelectionChange }: InPrgrsDocRcvrListProps) {
const rcvrQuery = useGetInPrgrsDocPicList(undefined, { select: selectInPrgrsDocRcvrList });
const { selectedCount, rowSelectionOptions } = useRcvrRowSelection({
rcvrList: rcvrQuery.data ?? EMPTY_RCVR_LIST,
selectedRcvrKeys,
onRcvrSelectionChange,
});
const { tableComponent } = useDataTable({
query: rcvrQuery,
tableOptions: {
columns: inPrgrsDocRcvrColumns,
getCoreRowModel: getCoreRowModel(),
...rowSelectionOptions,
},
});
return (
<RcvrListCard title="현재 진행중 문서 담당자(신청자)" selectedCount={selectedCount}>
{tableComponent}
</RcvrListCard>
);
}
export { InPrgrsDocRcvrList };
@@ -0,0 +1,46 @@
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@common/components";
import type { SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
import { NtitlkPrvw } from "./ntitlk-prvw";
interface NtitlkPrvwDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
recipient: SelectedNtitlkRcvr | null;
message: string;
}
function NtitlkPrvwDialog({ open, onOpenChange, recipient, message }: NtitlkPrvwDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[36rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>알림톡 미리보기</DialogTitle>
<DialogDescription className="text-[1.1rem] tracking-tight">
수신자: {recipient?.userNm || "-"} / {recipient?.telNo || "-"}
</DialogDescription>
</DialogHeader>
<NtitlkPrvw message={message || "메시지를 생성할 수 없습니다."} />
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="white">
닫기
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { NtitlkPrvwDialog };
@@ -0,0 +1,25 @@
const SNDR_NM = "디지털 행정지원 시스템";
interface NtitlkPrvwProps {
message: string;
}
function NtitlkPrvw({ message }: NtitlkPrvwProps) {
return (
<div className="relative">
<div className="h-[32rem] overflow-auto rounded-[20px] rounded-b-none bg-[rgb(176,192,207)] p-8 pb-0 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-full before:rounded-[20px] before:rounded-b-none before:border-[10px] before:border-b-0 before:border-black before:content-['']">
<div className="w-[calc(100%-2rem)] pb-5">
<span className="text-[1.1rem] text-black">{SNDR_NM}</span>
<div className="mt-2 rounded-t-[12px] bg-[rgb(247,228,54)] p-4 text-[1.2rem] font-medium text-black">
알림톡 도착
</div>
<div className="whitespace-pre-wrap rounded-b-[12px] bg-white p-4 text-[1.2rem] leading-normal tracking-tight text-black">
{message}
</div>
</div>
</div>
</div>
);
}
export { NtitlkPrvw };
@@ -0,0 +1,266 @@
"use client";
import { useMemo, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import {
type NtftalkTemplate,
useNtftalkTemplateList,
useNtftalkTemplateVariables,
useSendNtftalkBusiness,
} from "@/entities/ntftalk";
import { Button } from "@common/components";
import { buildTemplateMessage, getMergedVariables } from "../lib/build-tmplt-msg";
import type { RcvrSelectionChange, SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
import { CUSTOM_VRBL_MAPPING, type VrblMappingKey } from "../model/vrbl-mapping-options";
import { InPrgrsDocRcvrList } from "./in-prgrs-doc-rcvr-list";
import { NtitlkPrvwDialog } from "./ntitlk-prvw-dialog";
import { RvwMnpwRcvrList } from "./rvw-mnpw-rcvr-list";
import { SndngList } from "./sndng-list";
import { TmpltSelectPanel } from "./tmplt-select-panel";
import { VrblBulkApply } from "./vrbl-bulk-apply";
const SNDNG_CALLBACK_TELNO = "032-590-4000";
const EMPTY_TEMPLATES: NtftalkTemplate[] = [];
const EMPTY_VARIABLE_NAMES: string[] = [];
function NtitlkSndngPage() {
const alert = useAlertStore((state) => state.setMessage);
const { confirm } = useConfirm();
const [selectedTemplateCode, setSelectedTemplateCode] = useState("");
const [selectedRecipients, setSelectedRecipients] = useState<SelectedNtitlkRcvr[]>([]);
const [commonVariableValues, setCommonVariableValues] = useState<Record<string, string>>({});
const [commonVariableMapping, setCommonVariableMapping] = useState<
Record<string, VrblMappingKey>
>({});
const [previewRecipient, setPreviewRecipient] = useState<SelectedNtitlkRcvr | null>(null);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const { data: templatePage } = useNtftalkTemplateList();
const templates = templatePage?.content ?? EMPTY_TEMPLATES;
const selectedTemplate = templates.find(
(template) => template.templateCode === selectedTemplateCode,
);
const { data: templateVariables } = useNtftalkTemplateVariables(selectedTemplateCode);
const variableNames = templateVariables?.variables ?? EMPTY_VARIABLE_NAMES;
const { mutate: sendNtftalk, isPending: isSending } = useSendNtftalkBusiness();
const selectedRcvrKeys = useMemo(
() => new Set(selectedRecipients.map((recipient) => recipient.rcvrKey)),
[selectedRecipients],
);
const previewMessage =
previewRecipient && selectedTemplate?.templateContent
? buildTemplateMessage(
selectedTemplate.templateContent,
getMergedVariables(previewRecipient, commonVariableValues),
)
: "";
const handleTemplateChange = (templateCode: string) => {
setSelectedTemplateCode(templateCode);
setCommonVariableValues({});
setCommonVariableMapping({});
setSelectedRecipients((prev) =>
prev.map((recipient) => ({ ...recipient, variableOverrides: {} })),
);
};
const handleRcvrSelectionChange = ({ added, removedKeys }: RcvrSelectionChange) => {
setSelectedRecipients((prev) => {
const removedKeySet = new Set(removedKeys);
const existingKeySet = new Set(prev.map((recipient) => recipient.rcvrKey));
return [
...prev.filter((recipient) => !removedKeySet.has(recipient.rcvrKey)),
...added
.filter((rcvr) => !existingKeySet.has(rcvr.rcvrKey))
.map((rcvr) => ({ ...rcvr, variableOverrides: {} })),
];
});
};
const handleRemoveRecipient = (rcvrKey: string) => {
setSelectedRecipients((prev) => prev.filter((recipient) => recipient.rcvrKey !== rcvrKey));
};
const handleApplyCommonValues = () => {
setSelectedRecipients((prev) =>
prev.map((recipient) => {
const newOverrides: Record<string, string> = {};
variableNames.forEach((variableName) => {
const mappingKey = commonVariableMapping[variableName];
if (!mappingKey) return;
newOverrides[variableName] =
mappingKey === CUSTOM_VRBL_MAPPING
? (commonVariableValues[variableName] ?? "")
: (recipient[mappingKey] ?? "");
});
return {
...recipient,
variableOverrides: { ...recipient.variableOverrides, ...newOverrides },
};
}),
);
setCommonVariableMapping({});
};
const handleResetCommonValues = () => {
setCommonVariableMapping({});
setCommonVariableValues({});
};
const handleRecipientVariableChange = (rcvrKey: string, variableName: string, value: string) => {
setSelectedRecipients((prev) =>
prev.map((recipient) =>
recipient.rcvrKey === rcvrKey
? {
...recipient,
variableOverrides: { ...recipient.variableOverrides, [variableName]: value },
}
: recipient,
),
);
};
const handlePreview = (recipient: SelectedNtitlkRcvr) => {
if (!selectedTemplate) {
alert("알림톡 템플릿을 먼저 선택해주세요.");
return;
}
setPreviewRecipient(recipient);
setIsPreviewOpen(true);
};
const handleSend = async () => {
if (!selectedTemplateCode) {
alert("알림톡 템플릿을 선택해주세요.");
return;
}
const hasMissingVariable = selectedRecipients.some((recipient) =>
variableNames.some((variableName) => {
const mergedValue =
recipient.variableOverrides[variableName] ?? commonVariableValues[variableName] ?? "";
return mergedValue.trim() === "";
}),
);
if (hasMissingVariable) {
alert("변수값이 입력되지 않은 발송 대상이 있습니다.");
return;
}
if (!(await confirm(`${selectedRecipients.length}명에게 발송하시겠습니까?`))) return;
sendNtftalk(
{
templateCode: selectedTemplateCode,
callback: SNDNG_CALLBACK_TELNO,
recipients: selectedRecipients.map((recipient) =>
getMergedVariables(recipient, commonVariableValues),
),
},
{
onSuccess: (data) => {
if (!data.success) {
alert(data.message || "알림톡 발송 요청에 실패했습니다.");
return;
}
alert(data.message || "알림톡 발송이 요청되었습니다.");
setSelectedRecipients([]);
setCommonVariableValues({});
},
},
);
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[32rem_minmax(0,1fr)]">
<TmpltSelectPanel
templates={templates}
selectedTemplate={selectedTemplate}
onTemplateChange={handleTemplateChange}
/>
<div className="space-y-4">
<RvwMnpwRcvrList
selectedRcvrKeys={selectedRcvrKeys}
onRcvrSelectionChange={handleRcvrSelectionChange}
/>
<InPrgrsDocRcvrList
selectedRcvrKeys={selectedRcvrKeys}
onRcvrSelectionChange={handleRcvrSelectionChange}
/>
</div>
</div>
<section className="card card-border m-0 bg-background p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xl font-semibold">발송 목록</h3>
<div className="flex items-center gap-2">
<Button
type="button"
color="white"
onClick={() => setSelectedRecipients([])}
disabled={selectedRecipients.length === 0}
>
초기화
</Button>
<Button
type="button"
color="green"
onClick={handleSend}
disabled={isSending || selectedRecipients.length === 0}
>
발송
</Button>
</div>
</div>
{variableNames.length > 0 && (
<VrblBulkApply
variableNames={variableNames}
mapping={commonVariableMapping}
customValues={commonVariableValues}
onMappingChange={(variableName, mappingKey) =>
setCommonVariableMapping((prev) => ({ ...prev, [variableName]: mappingKey }))
}
onCustomValueChange={(variableName, value) =>
setCommonVariableValues((prev) => ({ ...prev, [variableName]: value }))
}
onApply={handleApplyCommonValues}
onReset={handleResetCommonValues}
/>
)}
<SndngList
recipients={selectedRecipients}
variableNames={variableNames}
onVariableChange={handleRecipientVariableChange}
onPreview={handlePreview}
onRemove={handleRemoveRecipient}
/>
</section>
<NtitlkPrvwDialog
open={isPreviewOpen}
onOpenChange={setIsPreviewOpen}
recipient={previewRecipient}
message={previewMessage}
/>
</div>
);
}
export { NtitlkSndngPage };
@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
interface RcvrListCardProps {
title: string;
selectedCount: number;
children: ReactNode;
}
function RcvrListCard({ title, selectedCount, children }: RcvrListCardProps) {
return (
<section className="card card-border m-0 bg-background p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xl font-semibold">{title}</h3>
<p className="text-muted-foreground">
<span className="font-semibold text-foreground">{selectedCount}명</span> 선택
</p>
</div>
<div className="h-[16rem] overflow-auto rounded-md border">{children}</div>
</section>
);
}
export { RcvrListCard };
@@ -0,0 +1,46 @@
import { getCoreRowModel } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useRvwMnpwMngPage } from "@/entities/rvw-mnpw";
import { selectRvwMnpwRcvrList } from "../lib/to-ntitlk-rcvr";
import { useRcvrRowSelection } from "../lib/use-rcvr-row-selection";
import type { NtitlkRcvr, RcvrSelectionChange } from "../model/ntitlk-rcvr";
import { rvwMnpwRcvrColumns } from "../model/rcvr-table-columns";
import { RcvrListCard } from "./rcvr-list-card";
const RVW_MNPW_SEARCH = { page: 0, size: 1000, sort: "userUuidNm,asc" };
const EMPTY_RCVR_LIST: NtitlkRcvr[] = [];
interface RvwMnpwRcvrListProps {
selectedRcvrKeys: Set<string>;
onRcvrSelectionChange: (change: RcvrSelectionChange) => void;
}
function RvwMnpwRcvrList({ selectedRcvrKeys, onRcvrSelectionChange }: RvwMnpwRcvrListProps) {
const rcvrQuery = useRvwMnpwMngPage(RVW_MNPW_SEARCH, { select: selectRvwMnpwRcvrList });
const { selectedCount, rowSelectionOptions } = useRcvrRowSelection({
rcvrList: rcvrQuery.data ?? EMPTY_RCVR_LIST,
selectedRcvrKeys,
onRcvrSelectionChange,
});
const { tableComponent } = useDataTable({
query: rcvrQuery,
tableOptions: {
columns: rvwMnpwRcvrColumns,
getCoreRowModel: getCoreRowModel(),
...rowSelectionOptions,
},
});
return (
<RcvrListCard title="검토인력 목록" selectedCount={selectedCount}>
{tableComponent}
</RcvrListCard>
);
}
export { RvwMnpwRcvrList };
@@ -0,0 +1,90 @@
import {
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import type { SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
interface SndngListProps {
recipients: SelectedNtitlkRcvr[];
variableNames: string[];
onVariableChange: (rcvrKey: string, variableName: string, value: string) => void;
onPreview: (recipient: SelectedNtitlkRcvr) => void;
onRemove: (rcvrKey: string) => void;
}
function SndngList({
recipients,
variableNames,
onVariableChange,
onPreview,
onRemove,
}: SndngListProps) {
if (recipients.length === 0)
return (
<p className="rounded-md border p-6 text-center text-muted-foreground">
선택된 발송 대상이 없습니다.
</p>
);
return (
<div className="max-h-[40rem] overflow-auto rounded-md border">
<Table variant="line" className="text-sm">
<TableHeader className="top-0">
<TableRow>
<TableHead className="min-w-[100px]">이름</TableHead>
<TableHead className="min-w-[120px]">전화번호</TableHead>
{variableNames.map((variableName) => (
<TableHead key={variableName} className="min-w-[180px]">
#{variableName}
</TableHead>
))}
<TableHead className="min-w-[160px] text-center">버튼</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recipients.map((recipient) => (
<TableRow key={recipient.rcvrKey}>
<TableCell align="center">{recipient.userNm}</TableCell>
<TableCell align="center">{recipient.telNo}</TableCell>
{variableNames.map((variableName) => (
<TableCell key={variableName}>
<Input
value={recipient.variableOverrides[variableName] ?? ""}
onChange={(event) =>
onVariableChange(recipient.rcvrKey, variableName, event.target.value)
}
className="w-full min-w-[150px]"
/>
</TableCell>
))}
<TableCell align="center">
<div className="flex justify-center gap-2">
<Button type="button" size="sm" color="gray" onClick={() => onPreview(recipient)}>
미리보기
</Button>
<Button
type="button"
size="sm"
color="red"
onClick={() => onRemove(recipient.rcvrKey)}
>
제거
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { SndngList };
@@ -0,0 +1,48 @@
import type { NtftalkTemplate } from "@/entities/ntftalk";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@common/components";
import { NtitlkPrvw } from "./ntitlk-prvw";
interface TmpltSelectPanelProps {
templates: NtftalkTemplate[];
selectedTemplate: NtftalkTemplate | undefined;
onTemplateChange: (templateCode: string) => void;
}
function TmpltSelectPanel({
templates,
selectedTemplate,
onTemplateChange,
}: TmpltSelectPanelProps) {
return (
<section className="card card-border m-0 space-y-6 bg-background p-4 pb-0">
<div className="space-y-2">
<h3 className="text-xl font-semibold">알림톡 템플릿 선택</h3>
<Select value={selectedTemplate?.templateCode ?? ""} onValueChange={onTemplateChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="템플릿 선택" />
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.templateCode} value={template.templateCode}>
{template.templateName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-4">
<p className="text-lg font-medium">미리보기</p>
<NtitlkPrvw
message={
selectedTemplate?.templateContent ??
"알림톡 템플릿을 선택하면 미리보기 메시지가 표시됩니다."
}
/>
</div>
</section>
);
}
export { TmpltSelectPanel };
@@ -0,0 +1,81 @@
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import {
CUSTOM_VRBL_MAPPING,
VRBL_MAPPING_OPTIONS,
type VrblMappingKey,
} from "../model/vrbl-mapping-options";
interface VrblBulkApplyProps {
variableNames: string[];
mapping: Record<string, VrblMappingKey>;
customValues: Record<string, string>;
onMappingChange: (variableName: string, mappingKey: VrblMappingKey) => void;
onCustomValueChange: (variableName: string, value: string) => void;
onApply: () => void;
onReset: () => void;
}
function VrblBulkApply({
variableNames,
mapping,
customValues,
onMappingChange,
onCustomValueChange,
onApply,
onReset,
}: VrblBulkApplyProps) {
return (
<div className="mb-4 space-y-4 rounded-md border p-3">
<p className="text-lg font-medium">변수 일괄 적용</p>
<div className="grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-6">
{variableNames.map((variableName) => (
<div key={variableName} className="space-y-2">
<Label className="whitespace-nowrap">{`#{${variableName}}`}</Label>
<Select
value={mapping[variableName] ?? ""}
onValueChange={(value) => onMappingChange(variableName, value as VrblMappingKey)}
>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{VRBL_MAPPING_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{mapping[variableName] === CUSTOM_VRBL_MAPPING && (
<Input
placeholder="값을 입력하세요"
value={customValues[variableName] ?? ""}
onChange={(event) => onCustomValueChange(variableName, event.target.value)}
/>
)}
</div>
))}
<div className="flex items-end gap-2">
<Button type="button" color="white" onClick={onReset}>
초기화
</Button>
<Button type="button" onClick={onApply}>
적용하기
</Button>
</div>
</div>
</div>
);
}
export { VrblBulkApply };
@@ -3,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),
@@ -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;
@@ -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);
@@ -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);
}}
+4
View File
@@ -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",
+1 -1
View File
@@ -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";
+12 -3
View File
@@ -1,5 +1,7 @@
import type { KeyboardEvent } from "react";
const MAX_INTEGER_DIGITS = 10;
function preventMinusKey(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "-") e.preventDefault();
}
@@ -7,7 +9,14 @@ function preventMinusKey(e: KeyboardEvent<HTMLInputElement>) {
/** 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);
}
+10 -3
View File
@@ -19,16 +19,23 @@ interface DataTableOptions<T> {
}
interface UseDataTableOptions<T> {
query: UseQueryResult<PageResponse<T>>;
/** 페이징 응답(PageResponse) 또는 페이징 없는 목록(T[]) */
query: UseQueryResult<PageResponse<T> | T[]>;
tableOptions: Omit<TableOptions<T>, "data" | "rowCount">;
dataTableOptions?: DataTableOptions<T>;
}
const EMPTY_ROWS: never[] = [];
export function useDataTable<T>({ query, tableOptions, dataTableOptions }: UseDataTableOptions<T>) {
const { data } = query;
const rows = Array.isArray(data) ? data : (data?.content ?? EMPTY_ROWS);
const rowCount = Array.isArray(data) ? data.length : (data?.totalElements ?? 0);
const table = useReactTable({
...tableOptions,
data: query.data?.content ?? [],
rowCount: query.data?.totalElements ?? 0,
data: rows,
rowCount,
});
return {
@@ -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);
@@ -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 (
<SectionCollapsible title="사업규모">
<div className="overflow-x-auto">
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
<Table variant="secondary">
<colgroup>
<col width="20%" />
@@ -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 };
}