This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { DashboardMainSearchDto } from "../model/dashboard-interface";
|
||||
import type {
|
||||
DashboardMainSearchDto,
|
||||
InPrgrsDocPicResponseDto,
|
||||
} from "../model/dashboard-interface";
|
||||
|
||||
import { dashboardService } from "./dashboard-service";
|
||||
|
||||
@@ -16,3 +19,14 @@ export function useGetMainDashboard(params: DashboardMainSearchDto = EMPTY_SEARC
|
||||
queryFn: () => dashboardService.getMainDashboard(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetInPrgrsDocPicList<TData = InPrgrsDocPicResponseDto[]>(
|
||||
searchYear?: number,
|
||||
options?: { select?: (data: InPrgrsDocPicResponseDto[]) => TData },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...dashboardBaseKey, "getInPrgrsDocPicList", searchYear],
|
||||
queryFn: () => dashboardService.getInPrgrsDocPicList(searchYear),
|
||||
select: options?.select,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,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),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export { ntftalkService } from "./api/ntftalk-service";
|
||||
export {
|
||||
useNtftalkTemplateList,
|
||||
useNtftalkTemplateVariables,
|
||||
useSendNtftalkBusiness,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
BusinessSendRequest,
|
||||
BusinessSendResponse,
|
||||
NtftalkService,
|
||||
NtftalkTemplate,
|
||||
NtftalkTemplateSearchDto,
|
||||
TemplateVariablesResponse,
|
||||
} from "./model/ntftalk-interface";
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
/** 백엔드 알림톡 템플릿 응답 */
|
||||
export interface NtftalkTemplate {
|
||||
templateCode: string;
|
||||
templateName: string;
|
||||
templateContent: string;
|
||||
}
|
||||
|
||||
/** 템플릿 목록 검색 조건 + Pageable */
|
||||
export interface NtftalkTemplateSearchDto extends PageableParams {
|
||||
senderKey?: string;
|
||||
inspectionStatus?: string;
|
||||
templateMessageType?: string;
|
||||
}
|
||||
|
||||
export interface TemplateVariablesResponse {
|
||||
templateCode: string;
|
||||
variables: string[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BusinessSendRequest {
|
||||
templateCode: string;
|
||||
callback?: string;
|
||||
fixedRecipientNum?: string;
|
||||
recipients: Record<string, string>[];
|
||||
}
|
||||
|
||||
export interface BusinessSendResponse {
|
||||
success: boolean;
|
||||
sentCount: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface NtftalkService {
|
||||
findTemplatePage(params: NtftalkTemplateSearchDto): Promise<PageResponse<NtftalkTemplate>>;
|
||||
findTemplateVariables(templateCode: string): Promise<TemplateVariablesResponse>;
|
||||
sendBusiness(body: BusinessSendRequest): Promise<BusinessSendResponse>;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
RvwMnpwMngPageSearchDto,
|
||||
RvwMnpwMngResponseDto,
|
||||
RvwMnpwMngSaveRequestDto,
|
||||
RvwMnpwMngSearchDto,
|
||||
RvwMnpwMngUpdateRequestDto,
|
||||
@@ -13,14 +15,18 @@ import { rvwMnpwMngService } from "./rvw-mnpw-service";
|
||||
|
||||
const rvwMnpwMngBaseKey = [ENDPOINT.AS_SERVICE.RVW_MNPW] as const;
|
||||
|
||||
export function useRvwMnpwMngPage(
|
||||
export function useRvwMnpwMngPage<TData = PageResponse<RvwMnpwMngResponseDto>>(
|
||||
params: RvwMnpwMngPageSearchDto,
|
||||
options?: { enabled?: boolean },
|
||||
options?: {
|
||||
enabled?: boolean;
|
||||
select?: (data: PageResponse<RvwMnpwMngResponseDto>) => TData;
|
||||
},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...rvwMnpwMngBaseKey, "findPage", params],
|
||||
queryFn: () => rvwMnpwMngService.findPage(params),
|
||||
enabled: options?.enabled,
|
||||
select: options?.select,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface RvwMnpwMngResponseDto {
|
||||
ognzSn: number;
|
||||
userUuid: string;
|
||||
userUuidNm?: string;
|
||||
mblTelno?: string;
|
||||
ognzNm?: string;
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -53,6 +53,10 @@ export const ENDPOINT = {
|
||||
STATVERIFY_SERVICE: {
|
||||
ST_ANLS_MST: "/statverify-service/api/v1/st-anls-mst",
|
||||
},
|
||||
NTFTALK_SERVICE: {
|
||||
TEMP: "/ntftalk-service/api/v1/ntftalk-temp",
|
||||
ATA_SEND: "/ntftalk-service/api/v1/ata/send",
|
||||
},
|
||||
DC_SERVICE: "/dc-service/api/v1",
|
||||
BOARD_SERVICE: {
|
||||
POSTS: "/board-service/api/v1/posts",
|
||||
|
||||
@@ -2,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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user