Files
frontend/apps/st/src/components/statistics/management/NoticeTalkSending.tsx
T

813 lines
36 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
getCoreRowModel,
type RowSelectionState,
useReactTable,
} from "@tanstack/react-table";
import type { AxiosError } from "axios";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import type { ApiErrorResponse } from "@/shared/model";
import {
type AdstZoneResponseDto,
type BusinessSendRequest,
type BusinessSendResponse,
noticeTalkSendingApi,
type SelectedRecipient,
type SewerStUserItem,
useNtftalkTemplateList,
useNtftalkTemplateVariables,
userListColumns,
useSewerStUserList,
useStAnlsSidoCombo,
useStAnlsSigunguCombo,
} from "@/entities/statistics";
import {
Button,
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";
type CommonVariableKey =
| "userNm"
| "sido"
| "gugun"
| "gubunNm"
| "deptNm"
| "jbgdNm"
| "telNo"
| "custom";
const EMPTY_ADST_LIST: AdstZoneResponseDto[] = [];
const GUBUN_OPTIONS = [
{ value: "PIC", label: "입력자" },
{ value: "IDFR", label: "승인자" },
{ value: "MCTPV", label: "검토자" },
] as const;
function getDefaultStatsYear(): string {
return String(new Date().getFullYear() - 1);
}
function buildTemplateMessage(
templateContent: string,
variables: Record<string, string>,
): string {
return templateContent.replace(/#?\{([^}]+)\}/g, (_, key: string) => {
const value = variables[key.trim()];
return value ?? "";
});
}
function getMergedVariables(
recipient: SelectedRecipient,
commonValues: Record<string, string>,
): Record<string, string> {
return {
...commonValues,
...recipient.variableOverrides,
TEL_NUM: recipient.telNo,
};
}
function NtitlkSndng() {
const alert = useAlertStore((state) => state.setMessage);
const { confirm } = useConfirm();
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<string, string>
>({});
const [commonVariableMapping, setCommonVariableMapping] = useState<
Record<string, CommonVariableKey>
>({});
const [previewRecipient, setPreviewRecipient] =
useState<SelectedRecipient | null>(null);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [userListRowSelection, setUserListRowSelection] =
useState<RowSelectionState>({});
const sendMutation = useMutationWrapper<
BusinessSendResponse,
AxiosError<ApiErrorResponse>,
BusinessSendRequest
>({
mutationFn: (payload) => noticeTalkSendingApi.sendBusiness(payload),
onSuccess: (data) => {
if (!data.success) {
alert(data.message || "알림톡 발송 요청에 실패했습니다.");
return;
}
alert(data.message || "알림톡 발송이 요청되었습니다.");
setSelectedRecipients([]);
setCommonVariableValues({});
},
});
const { data: templatePageData } = useNtftalkTemplateList();
const templates = templatePageData?.content ?? [];
const { data: templateVariablesData } =
useNtftalkTemplateVariables(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 userListTable = useReactTable({
data: userList,
columns: userListColumns,
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
enableMultiRowSelection: true,
state: {
rowSelection: userListRowSelection,
},
onRowSelectionChange: setUserListRowSelection,
getRowId: (row, i) => `${i}-${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<string, string> = {};
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;
}
const missingVariable = selectedRecipients.some((recipient) =>
variableNames.some((name) => {
const mergedValue =
recipient.variableOverrides[name] ??
commonVariableValues[name] ??
"";
return mergedValue.trim() === "";
}),
);
if (missingVariable) {
alert("변수값이 입력되지 않은 발송 대상이 있습니다.");
return;
}
if (
!(await confirm(
`${selectedRecipients.length}명에게 발송하시겠습니까?`,
))
)
return;
sendMutation.mutate({
templateCode: selectedTemplateCode,
callback: "032-590-4000",
recipients: selectedRecipients.map((recipient) =>
getMergedVariables(recipient, commonVariableValues),
),
});
};
return (
<div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-[32rem_minmax(0,1fr)]">
<section className="card card-border space-y-6 p-4 pb-0">
<div className="space-y-2">
<h3 className="text-xl font-semibold">
알림톡 템플릿 선택
</h3>
<div className="flex items-center gap-3">
<Select
value={selectedTemplateCode}
onValueChange={handleTemplateChange}
>
<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>
<div className="space-y-4">
<p className="text-lg font-medium">미리보기</p>
<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">
하수도통계시스템
</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">
{selectedTemplate?.templateContent ??
"알림톡 템플릿을 선택하면 미리보기 메시지가 표시됩니다."}
</div>
</div>
</div>
</div>
</div>
</section>
<section className="card card-border p-4">
<h3 className="text-xl font-semibold">통계 담당자 목록</h3>
<div className="card card-border mx-0 flex items-center p-4">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">
시도
</Label>
<Select
value={searchSido}
onValueChange={(value) => {
setSearchSido(value);
setSearchSigungu("");
}}
>
<SelectTrigger className="w-[16rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{sidoOptions.map((option) => (
<SelectItem
key={option.adstCd}
value={option.adstCd}
>
{option.adstNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">
시군구
</Label>
<Select
value={searchSigungu}
onValueChange={setSearchSigungu}
disabled={!searchSido}
>
<SelectTrigger className="w-[16rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{sigunguOptions.map((option) => (
<SelectItem
key={option.adstCd}
value={option.adstCd}
>
{option.adstNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">
구분
</Label>
<Select
value={searchGubun}
onValueChange={setSearchGubun}
>
<SelectTrigger className="w-[16rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{GUBUN_OPTIONS.map((option) => (
<SelectItem
key={option.value || "ALL"}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex w-full justify-end gap-2">
<Button
color="white"
type="button"
onClick={handleReset}
>
초기화
</Button>
<Button type="button" onClick={handleSearch}>
검색
</Button>
</div>
</div>
<div className="h-[28rem] overflow-x-auto rounded-md border">
{isUserListLoading ? (
<div className="flex items-center justify-center p-6">
<div className="relative flex size-[6rem] justify-center">
<LoadingWithAnimation color="primary" />
</div>
</div>
) : userList.length === 0 ? (
<div className="p-6 text-center text-muted-foreground">
조회된 담당자가 없습니다.
</div>
) : (
<DataTable
table={userListTable}
className="min-w-[72rem] text-sm"
/>
)}
</div>
{!isUserListLoading && userList.length > 0 && (
<div className="mt-4 flex items-center justify-end gap-4">
<p className="text-xl text-muted-foreground">
선택{" "}
<span className="font-semibold text-foreground">
{
userListTable.getSelectedRowModel().rows
.length
}
명
</span>{" "}
/ 전체{" "}
<span className="font-semibold text-foreground">
{userList.length}명
</span>
</p>
<Button
type="button"
color="gray"
onClick={handleAddSelectedToRecipients}
>
+ 발송 목록 추가
</Button>
</div>
)}
</section>
</div>
<section className="card card-border 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={
sendMutation.isPending ||
selectedRecipients.length === 0
}
>
발송
</Button>
</div>
</div>
{variableNames.length > 0 && (
<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={
commonVariableMapping[
variableName
] ?? ""
}
onValueChange={(value) =>
setCommonVariableMapping(
(prev) => ({
...prev,
[variableName]:
value as CommonVariableKey,
}),
)
}
>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="userNm">
이름
</SelectItem>
<SelectItem value="sido">
시도
</SelectItem>
<SelectItem value="gugun">
시군구
</SelectItem>
<SelectItem value="gubunNm">
구분
</SelectItem>
<SelectItem value="deptNm">
소속
</SelectItem>
<SelectItem value="jbgdNm">
직급
</SelectItem>
<SelectItem value="telNo">
전화번호
</SelectItem>
<SelectItem value="custom">
직접입력
</SelectItem>
</SelectContent>
</Select>
{commonVariableMapping[variableName] ===
"custom" && (
<Input
placeholder="값을 입력하세요"
value={
commonVariableValues[
variableName
] ?? ""
}
onChange={(event) =>
setCommonVariableValues(
(prev) => ({
...prev,
[variableName]:
event.target.value,
}),
)
}
/>
)}
</div>
))}
<div className="flex items-end gap-2">
<Button
type="button"
color="white"
onClick={handleResetCommonValues}
>
초기화
</Button>
<Button
type="button"
onClick={handleApplyCommonValues}
>
적용하기
</Button>
</div>
</div>
</div>
)}
<div className="space-y-4">
{selectedRecipients.length === 0 ? (
<p className="rounded-md border p-6 text-center text-muted-foreground">
선택된 발송 대상이 없습니다.
</p>
) : (
<div className="max-h-[40rem] overflow-x-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>
{selectedRecipients.map((recipient, i) => (
<TableRow
key={`${i}-${recipient.telNo}`}
>
<TableCell align="center">
{recipient.userNm}
</TableCell>
<TableCell align="center">
{recipient.telNo}
</TableCell>
{variableNames.map(
(variableName) => (
<TableCell
key={`${recipient.telNo}-${variableName}`}
>
<Input
value={
recipient
.variableOverrides[
variableName
] ?? ""
}
onChange={(event) =>
handleRecipientVariableChange(
recipient.telNo,
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={() =>
handlePreview(
recipient,
)
}
>
미리보기
</Button>
<Button
type="button"
size="sm"
color="red"
onClick={() =>
handleRemoveRecipient(
recipient.telNo,
)
}
>
제거
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</section>
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
<DialogContent
className="max-w-[36rem]"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>알림톡 미리보기</DialogTitle>
<DialogDescription className="text-[1.1rem] tracking-tight">
수신자: {previewRecipient?.userNm ?? "-"} /{" "}
{previewRecipient?.telNo ?? "-"}
</DialogDescription>
</DialogHeader>
<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">
하수도통계시스템
</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">
{previewMessage ||
"메시지를 생성할 수 없습니다."}
</div>
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="white">
닫기
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default NtitlkSndng;