diff --git a/apps/st/src/components/statistics/management/NoticeTalkSending.tsx b/apps/st/src/components/statistics/management/NoticeTalkSending.tsx new file mode 100644 index 000000000..cdb060e30 --- /dev/null +++ b/apps/st/src/components/statistics/management/NoticeTalkSending.tsx @@ -0,0 +1,973 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { useQuery } from "@tanstack/react-query"; +import { + type ColumnDef, + getCoreRowModel, + type RowSelectionState, + useReactTable, +} from "@tanstack/react-table"; + +import { client } from "@/shared/api/client"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore } from "@/shared/lib"; +import type { PageableList } from "@/shared/model"; +import { + type AdstZoneResponseDto, + type SewerStUserItem, + useSewerStUserList, + useStAnlsSidoCombo, + useStAnlsSigunguCombo, +} from "@/entities/statistics"; +import { + Button, + Checkbox, + DataTable, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + LoadingWithAnimation, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +interface NtftalkTemplate { + templateCode: string; + templateName: string; + templateContent: string; +} + +interface TemplateVariablesResponse { + templateCode: string; + variables: string[]; + count: number; +} + +interface BusinessSendRequest { + templateCode: string; + callback?: string; + fixedRecipientNum?: string; + recipients: Record[]; +} + +interface BusinessSendResponse { + success: boolean; + sentCount: number; + message: string; +} + +interface SelectedRecipient extends SewerStUserItem { + variableOverrides: Record; +} + +const EMPTY_ADST_LIST: AdstZoneResponseDto[] = []; +const GUBUN_OPTIONS = [ + { value: "PIC", label: "입력자" }, + { value: "IDFR", label: "승인자" }, + { value: "MCTPV", label: "검토자" }, +] as const; +const DEFAULT_TEMPLATE_PARAMS = { + sort: "createdAt,desc", + senderKey: "", + inspectionStatus: "APR", + templateMessageType: "BA", + page: 0, + size: 10, +}; + +function getDefaultStatsYear(): string { + return String(new Date().getFullYear() - 1); +} + +function buildTemplateMessage( + templateContent: string, + variables: Record, +): string { + return templateContent.replace(/#?\{([^}]+)\}/g, (_, key: string) => { + const value = variables[key.trim()]; + return value ?? ""; + }); +} + +function getMergedVariables( + recipient: SelectedRecipient, + commonValues: Record, +): Record { + return { + ...commonValues, + ...recipient.variableOverrides, + TEL_NUM: recipient.telNo, + }; +} + +function NtitlkSndng() { + const alert = useAlertStore((state) => state.setMessage); + + const [searchStatsYr, setSearchStatsYr] = useState(getDefaultStatsYear()); + const [searchSido, setSearchSido] = useState(""); + const [searchSigungu, setSearchSigungu] = useState(""); + const [searchGubun, setSearchGubun] = useState(""); + + const [appliedParams, setAppliedParams] = useState({ + statsYr: getDefaultStatsYear(), + sewerBzmnCd: "", + gubun: "", + }); + + const [selectedTemplateCode, setSelectedTemplateCode] = useState(""); + const [selectedRecipients, setSelectedRecipients] = useState< + SelectedRecipient[] + >([]); + const [commonVariableValues, setCommonVariableValues] = useState< + Record + >({}); + const [commonVariableMapping, setCommonVariableMapping] = useState< + Record< + string, + | "userNm" + | "sido" + | "gugun" + | "gubunNm" + | "deptNm" + | "jbgdNm" + | "telNo" + | "custom" + | "" + > + >({}); + + const [previewRecipient, setPreviewRecipient] = + useState(null); + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [isSending, setIsSending] = useState(false); + const [userListRowSelection, setUserListRowSelection] = + useState({}); + + const { data: templatePageData } = useQuery({ + queryKey: [ + ENDPOINT.NTFTALK_SERVICE.TEMP, + "ntitlk-sndng", + DEFAULT_TEMPLATE_PARAMS, + ], + queryFn: async () => { + const response = await client.get>( + ENDPOINT.NTFTALK_SERVICE.TEMP, + { params: DEFAULT_TEMPLATE_PARAMS }, + ); + return response.data; + }, + }); + + const templates = templatePageData?.content ?? []; + + const { data: templateVariablesData } = useQuery({ + queryKey: [ + ENDPOINT.NTFTALK_SERVICE.ATA_SEND, + "template-variables", + selectedTemplateCode, + ], + queryFn: async () => { + const response = await client.get( + `${ENDPOINT.NTFTALK_SERVICE.ATA_SEND}/template-variables/${selectedTemplateCode}`, + ); + return response.data; + }, + enabled: Boolean(selectedTemplateCode), + }); + + const variableNames = templateVariablesData?.variables ?? []; + + const { data: sidoData } = useStAnlsSidoCombo(); + const { data: sigunguData } = useStAnlsSigunguCombo(searchSido); + const sidoOptions = sidoData ?? EMPTY_ADST_LIST; + const sigunguOptions = sigunguData ?? EMPTY_ADST_LIST; + + const { data: userListResponse, isLoading: isUserListLoading } = + useSewerStUserList(appliedParams); + const userList = (userListResponse as unknown as SewerStUserItem[]) ?? []; + const userListColumns = useMemo[]>( + () => [ + { + id: "select", + header: ({ table }) => ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + + table.toggleAllPageRowsSelected(!!value) + } + aria-label="전체 선택" + /> +
+ ), + cell: ({ row }) => ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + + row.toggleSelected(!!value) + } + aria-label="행 선택" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "userNm", + header: "이름", + }, + { + accessorKey: "sido", + header: "시도", + }, + { + accessorKey: "gugun", + header: "시군구", + }, + { + accessorKey: "gubunNm", + header: "구분", + }, + { + accessorKey: "deptNm", + header: "소속", + }, + { + accessorKey: "jbgdNm", + header: "직급", + }, + { + accessorKey: "telNo", + header: "전화번호", + }, + ], + [], + ); + const userListTable = useReactTable({ + data: userList, + columns: userListColumns, + getCoreRowModel: getCoreRowModel(), + enableRowSelection: true, + enableMultiRowSelection: true, + state: { + rowSelection: userListRowSelection, + }, + onRowSelectionChange: setUserListRowSelection, + getRowId: (row) => `${row.userNm}-${row.telNo}`, + }); + + const selectedTemplate = useMemo( + () => + templates.find( + (template) => template.templateCode === selectedTemplateCode, + ), + [templates, selectedTemplateCode], + ); + + const previewMessage = useMemo(() => { + if (!previewRecipient || !selectedTemplate?.templateContent) return ""; + return buildTemplateMessage( + selectedTemplate.templateContent, + getMergedVariables(previewRecipient, commonVariableValues), + ); + }, [previewRecipient, selectedTemplate, commonVariableValues]); + + const handleTemplateChange = (templateCode: string) => { + setSelectedTemplateCode(templateCode); + setCommonVariableValues({}); + setCommonVariableMapping({}); + setSelectedRecipients((prev) => + prev.map((recipient) => ({ ...recipient, variableOverrides: {} })), + ); + }; + + const handleSearch = () => { + const sewerBzmnCd = + searchSido !== "" + ? searchSigungu !== "" + ? searchSigungu + : searchSido + : ""; + + setAppliedParams({ + statsYr: searchStatsYr, + sewerBzmnCd, + gubun: searchGubun, + }); + setUserListRowSelection({}); + }; + + const handleReset = () => { + const defaultYear = getDefaultStatsYear(); + setSearchStatsYr(defaultYear); + setSearchSido(""); + setSearchSigungu(""); + setSearchGubun(""); + setAppliedParams({ + statsYr: defaultYear, + sewerBzmnCd: "", + gubun: "", + }); + setUserListRowSelection({}); + }; + + const handleRemoveRecipient = (telNo: string) => { + setSelectedRecipients((prev) => + prev.filter((recipient) => recipient.telNo !== telNo), + ); + }; + + const handleAddSelectedToRecipients = () => { + const rows = userListTable.getSelectedRowModel().rows; + if (rows.length === 0) { + alert("추가할 담당자를 선택해주세요."); + return; + } + + setSelectedRecipients((prev) => { + const existingTelNos = new Set(prev.map((r) => r.telNo)); + const newRecipients = rows + .map((row) => row.original) + .filter((recipient) => !existingTelNos.has(recipient.telNo)) + .map((recipient) => ({ + ...recipient, + variableOverrides: {}, + })); + + return [...prev, ...newRecipients]; + }); + + setUserListRowSelection({}); + }; + + const handleApplyCommonValues = () => { + setSelectedRecipients((prev) => + prev.map((recipient) => { + const newOverrides: Record = {}; + + variableNames.forEach((variableName) => { + const mappingType = commonVariableMapping[variableName]; + + if (!mappingType) return; + + if (mappingType === "custom") { + newOverrides[variableName] = + commonVariableValues[variableName] ?? ""; + } else { + newOverrides[variableName] = + (recipient[ + mappingType as keyof SewerStUserItem + ] as string) ?? ""; + } + }); + + return { + ...recipient, + variableOverrides: { + ...recipient.variableOverrides, + ...newOverrides, + }, + }; + }), + ); + setCommonVariableMapping({}); + }; + + const handleResetCommonValues = () => { + setCommonVariableMapping({}); + setCommonVariableValues({}); + }; + + const handleRecipientVariableChange = ( + telNo: string, + variableName: string, + value: string, + ) => { + setSelectedRecipients((prev) => + prev.map((recipient) => + recipient.telNo === telNo + ? { + ...recipient, + variableOverrides: { + ...recipient.variableOverrides, + [variableName]: value, + }, + } + : recipient, + ), + ); + }; + + const handlePreview = (recipient: SelectedRecipient) => { + if (!selectedTemplate) { + alert("알림톡 템플릿을 먼저 선택해주세요."); + return; + } + setPreviewRecipient(recipient); + setIsPreviewOpen(true); + }; + + const handleSend = async () => { + if (!selectedTemplateCode) { + alert("알림톡 템플릿을 선택해주세요."); + return; + } + if (selectedRecipients.length === 0) { + alert("발송할 담당자를 1명 이상 선택해주세요."); + return; + } + + const missingVariable = selectedRecipients.some((recipient) => + variableNames.some((name) => { + const mergedValue = + recipient.variableOverrides[name] ?? + commonVariableValues[name] ?? + ""; + return mergedValue.trim() === ""; + }), + ); + if (missingVariable) { + alert("필수 변수값이 입력되지 않은 발송 대상이 있습니다."); + return; + } + + const payload: BusinessSendRequest = { + templateCode: selectedTemplateCode, + callback: "032-590-4000", + recipients: selectedRecipients.map((recipient) => + getMergedVariables(recipient, commonVariableValues), + ), + }; + + try { + setIsSending(true); + const response = await client.post( + `${ENDPOINT.NTFTALK_SERVICE.ATA_SEND}/business`, + payload, + ); + const data = response.data; + if (!data.success) { + alert(data.message || "알림톡 발송 요청에 실패했습니다."); + return; + } + + alert( + data.message || + `${data.sentCount}건의 알림톡 발송이 요청되었습니다.`, + ); + setSelectedRecipients([]); + setCommonVariableValues({}); + } catch { + alert("알림톡 발송 중 오류가 발생했습니다."); + } finally { + setIsSending(false); + } + }; + + return ( +
+
+
+
+

+ 알림톡 템플릿 선택 +

+
+ +
+
+ +
+

미리보기

+
+
+
+ + 하수도통계시스템 + +
+ 알림톡 도착 +
+
+ {selectedTemplate?.templateContent ?? + "알림톡 템플릿을 선택하면 미리보기 메시지가 표시됩니다."} +
+
+
+
+
+
+ +
+

통계 담당자 목록

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ {isUserListLoading ? ( +
+
+ +
+
+ ) : userList.length === 0 ? ( +
+ 조회된 담당자가 없습니다. +
+ ) : ( + + )} +
+ {!isUserListLoading && userList.length > 0 && ( +
+

+ 선택{" "} + + { + userListTable.getSelectedRowModel().rows + .length + } + 명 + {" "} + / 전체{" "} + + {userList.length}명 + +

+ +
+ )} +
+
+ +
+
+

발송 목록

+
+ + {selectedRecipients.length}명 + + +
+
+ + {variableNames.length > 0 && ( +
+
+

+ 변수 일괄 적용 +

+
+ + +
+
+
+ {variableNames.map((variableName) => ( +
+ + + {commonVariableMapping[variableName] === + "custom" && ( + + setCommonVariableValues( + (prev) => ({ + ...prev, + [variableName]: + event.target.value, + }), + ) + } + /> + )} +
+ ))} +
+
+ )} + +
+ {selectedRecipients.length === 0 ? ( +

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

+ ) : ( +
+ + + + + 이름 + + + 전화번호 + + {/* + 시도 + + + 시군구 + + + 소속 + */} + {variableNames.map((variableName) => ( + + #{variableName} + + ))} + + 액션 + + + + + {selectedRecipients.map((recipient) => ( + + + {recipient.userNm} + + + {recipient.telNo} + + {/* + {recipient.sido} + + + {recipient.gugun} + + + {recipient.deptNm} + */} + {variableNames.map( + (variableName) => ( + + + handleRecipientVariableChange( + recipient.telNo, + variableName, + event.target + .value, + ) + } + className="w-full min-w-[150px]" + /> + + ), + )} + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+
+ + + + + 알림톡 미리보기 + + 수신자: {previewRecipient?.userNm ?? "-"} /{" "} + {previewRecipient?.telNo ?? "-"} + + +
+
+
+ + 하수도통계시스템 + +
+ 알림톡 도착 +
+
+ {previewMessage || + "메시지를 생성할 수 없습니다."} +
+
+
+
+ + + + + +
+
+
+ ); +} + +export default NtitlkSndng; diff --git a/apps/st/src/entities/statistics/api/queries.ts b/apps/st/src/entities/statistics/api/queries.ts index 1ca1c6635..30a6437ca 100644 --- a/apps/st/src/entities/statistics/api/queries.ts +++ b/apps/st/src/entities/statistics/api/queries.ts @@ -9,6 +9,9 @@ import type { StatisticsVerificationSubFilterState, } from "@/entities/statistics/model/statistics-verification-report/statistics-verification-report-types"; +import type { SewerStUserSearchParams } from "../model/sewer-st-user-interface"; + +import { sewerStUserApi } from "./sewer-st-user-service"; import { stAnlsMstApi } from "./statistics-analysis-master-api"; import { stAnlsRsltApi } from "./statistics-analysis-result-api"; @@ -154,3 +157,15 @@ export const useStatsVrfcRsltData = ( refetchOnWindowFocus: false, }); }; + +export const useSewerStUserList = (params: SewerStUserSearchParams) => { + return useQuery({ + queryKey: [ + ENDPOINT.STATVERIFY_SERVICE.SEWERST_USER, + "user-list", + params, + ], + queryFn: () => sewerStUserApi.getUserList(params), + enabled: Boolean(params.statsYr), + }); +}; diff --git a/apps/st/src/entities/statistics/api/sewer-st-user-service.ts b/apps/st/src/entities/statistics/api/sewer-st-user-service.ts new file mode 100644 index 000000000..fb0184820 --- /dev/null +++ b/apps/st/src/entities/statistics/api/sewer-st-user-service.ts @@ -0,0 +1,29 @@ +import { stClient } from "@/shared/api/client"; +import { ENDPOINT } from "@/shared/config"; + +import type { + SewerStUserListResponse, + SewerStUserSearchParams, +} from "../model/sewer-st-user-interface"; + +/** + * 하수도 통계 사용자 목록 API 서비스 + */ +export class SewerStUserApiService { + /** + * 하수도 통계 제출현황 사용자 목록 조회 + * @param params 검색 조건 (statsYr, sewerBzmnCd, gubun) + * @returns 사용자 목록 (입력자·승인자·검토자) + */ + public async getUserList( + params: SewerStUserSearchParams, + ): Promise { + const response = await stClient.get( + `${ENDPOINT.STATVERIFY_SERVICE.SEWERST_USER}/user-list`, + { params }, + ); + return response.data; + } +} + +export const sewerStUserApi = new SewerStUserApiService(); diff --git a/apps/st/src/entities/statistics/index.ts b/apps/st/src/entities/statistics/index.ts index ff37b0535..28a485e76 100644 --- a/apps/st/src/entities/statistics/index.ts +++ b/apps/st/src/entities/statistics/index.ts @@ -9,11 +9,13 @@ export { dashboardApi } from "./api/dashboard/dashboard-service"; export { useScheduleInfo } from "./api/dashboard/schedule-info-queries"; export { scheduleInfoApi } from "./api/dashboard/schedule-info-service"; export { + useSewerStUserList, useStAnlsSidoCombo, useStAnlsSigunguCombo, useStatsVrfcRptData, useStatsVrfcRsltData, } from "./api/queries"; +export { sewerStUserApi } from "./api/sewer-st-user-service"; export { statisticsApprovalApi } from "./api/statistics-approval-service"; export { type AnalysisHistoryListApiResponse, @@ -62,6 +64,11 @@ export { } from "./model/imr/imr-interface"; export { imrVerificationHistoryColumns } from "./model/imr/imr-verification-history-columns"; export { type StatsInputStatusList } from "./model/input/statistics-input-status-interface"; +export { + type SewerStUserItem, + type SewerStUserListResponse, + type SewerStUserSearchParams, +} from "./model/sewer-st-user-interface"; export { type StAnlsDtlListApiResponse, type StAnlsDtlResponse, diff --git a/apps/st/src/entities/statistics/model/sewer-st-user-interface.ts b/apps/st/src/entities/statistics/model/sewer-st-user-interface.ts new file mode 100644 index 000000000..b9af09c1c --- /dev/null +++ b/apps/st/src/entities/statistics/model/sewer-st-user-interface.ts @@ -0,0 +1,46 @@ +/** + * 하수도 통계 제출현황 사용자 목록 조회 인터페이스 + */ + +/** + * 하수도 통계 사용자 목록 검색 조건 + */ +export interface SewerStUserSearchParams { + /** 통계연도 (예: "2024") */ + statsYr?: string; + /** 하수도 사업자코드 (예: "4145000000") */ + sewerBzmnCd?: string; + /** 구분 코드 (PIC: 입력자, IDFR: 승인자, MCTPV: 검토자, null: 전체) */ + gubun?: string; +} + +/** + * 하수도 통계 사용자 목록 한 행 + */ +export interface SewerStUserItem { + /** 시도 */ + sido: string; + /** 시군구 */ + gugun: string; + /** 이름 */ + userNm: string; + /** 전화번호 */ + telNo: string; + /** 소속 */ + deptNm: string; + /** 직급 */ + jbgdNm: string; + /** 구분 코드 (PIC: 입력자, IDFR: 승인자, MCTPV: 검토자) */ + gubun: string; + /** 구분명 (입력자 / 승인자 / 검토자) */ + gubunNm: string; +} + +/** + * 하수도 통계 사용자 목록 조회 API 응답 + */ +export interface SewerStUserListResponse { + status: string; + message?: string; + data?: SewerStUserItem[]; +} diff --git a/apps/st/src/pages/statistics/ui/panels/unsubmit-panel.tsx b/apps/st/src/pages/statistics/ui/panels/unsubmit-panel.tsx index c4981be02..a7eeb6551 100644 --- a/apps/st/src/pages/statistics/ui/panels/unsubmit-panel.tsx +++ b/apps/st/src/pages/statistics/ui/panels/unsubmit-panel.tsx @@ -49,14 +49,20 @@ export function UnSubmitPanel({ params }: UnSubmitPanelProps) { ) : (
- {data.statusList.map((item) => ( + {data.statusList.map((item, i) => (

{item.statsNm}

+ {params?.dtlInstCd !== + "ROLE_USER05" && ( + + {item.sidoSggNm} + + )}
))}
diff --git a/apps/st/src/shared/config/api.ts b/apps/st/src/shared/config/api.ts index 318bc6656..0cf732396 100644 --- a/apps/st/src/shared/config/api.ts +++ b/apps/st/src/shared/config/api.ts @@ -76,6 +76,10 @@ export const ENDPOINT = { APPROVAL: "/st-management-service/api/stats", DASHBOARD: "/st-management-service/api/v1/dashboard", }, + NTFTALK_SERVICE: { + TEMP: "/ntftalk-service/api/v1/ntftalk-temp", + ATA_SEND: "/ntftalk-service/api/v1/ata/send", + }, STATVERIFY_SERVICE: { VRFCTGT_MNG: "/statverify-service/api/v1/vrfc-trgt-mng", ANALYSIS: "/statverify-service/api/v1/stat-verify/analysis", @@ -83,6 +87,7 @@ export const ENDPOINT = { ST_ANLS_DTL: "/statverify-service/api/v1/st-anls-dtl", ST_ANLS_RSLT: "/statverify-service/api/v1/st-anls-rslt", IMR: "/statverify-service/api/v1/imr/outlier/detect", + SEWERST_USER: "/statverify-service/api/v1/sewerst", }, };