Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev
This commit is contained in:
@@ -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<string, string>[];
|
||||
}
|
||||
|
||||
interface BusinessSendResponse {
|
||||
success: boolean;
|
||||
sentCount: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface SelectedRecipient extends SewerStUserItem {
|
||||
variableOverrides: Record<string, string>;
|
||||
}
|
||||
|
||||
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, 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 [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,
|
||||
| "userNm"
|
||||
| "sido"
|
||||
| "gugun"
|
||||
| "gubunNm"
|
||||
| "deptNm"
|
||||
| "jbgdNm"
|
||||
| "telNo"
|
||||
| "custom"
|
||||
| ""
|
||||
>
|
||||
>({});
|
||||
|
||||
const [previewRecipient, setPreviewRecipient] =
|
||||
useState<SelectedRecipient | null>(null);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [userListRowSelection, setUserListRowSelection] =
|
||||
useState<RowSelectionState>({});
|
||||
|
||||
const { data: templatePageData } = useQuery({
|
||||
queryKey: [
|
||||
ENDPOINT.NTFTALK_SERVICE.TEMP,
|
||||
"ntitlk-sndng",
|
||||
DEFAULT_TEMPLATE_PARAMS,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<PageableList<NtftalkTemplate>>(
|
||||
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<TemplateVariablesResponse>(
|
||||
`${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<ColumnDef<SewerStUserItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div
|
||||
className="flex justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() &&
|
||||
"indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label="전체 선택"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
className="flex justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) =>
|
||||
row.toggleSelected(!!value)
|
||||
}
|
||||
aria-label="행 선택"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
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<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;
|
||||
}
|
||||
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<BusinessSendResponse>(
|
||||
`${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 (
|
||||
<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-3">
|
||||
<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-[32rem] 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-3 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"
|
||||
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-4">
|
||||
<span className="text-xl font-semibold text-foreground">
|
||||
{selectedRecipients.length}명
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
color="green"
|
||||
onClick={handleSend}
|
||||
disabled={isSending}
|
||||
>
|
||||
발송
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{variableNames.length > 0 && (
|
||||
<div className="mb-4 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-lg font-medium">
|
||||
변수 일괄 적용
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
onClick={handleResetCommonValues}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleApplyCommonValues}
|
||||
>
|
||||
적용하기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
| "userNm"
|
||||
| "sido"
|
||||
| "gugun"
|
||||
| "gubunNm"
|
||||
| "deptNm"
|
||||
| "jbgdNm"
|
||||
| "telNo"
|
||||
| "custom"
|
||||
| "",
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{selectedRecipients.length === 0 ? (
|
||||
<p className="rounded-md border p-6 text-center text-muted-foreground">
|
||||
선택된 발송 대상이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table variant="line" className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[100px]">
|
||||
이름
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px]">
|
||||
전화번호
|
||||
</TableHead>
|
||||
{/* <TableHead className="min-w-[80px]">
|
||||
시도
|
||||
</TableHead>
|
||||
<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) => (
|
||||
<TableRow key={recipient.telNo}>
|
||||
<TableCell align="center">
|
||||
{recipient.userNm}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{recipient.telNo}
|
||||
</TableCell>
|
||||
{/* <TableCell align="center">
|
||||
{recipient.sido}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{recipient.gugun}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{recipient.deptNm}
|
||||
</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-1">
|
||||
<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;
|
||||
@@ -41,11 +41,14 @@ export function useDashboardUnSubmitCount(
|
||||
params: DashboardSearchParams | null,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: createDashboardQueryKey("un-submit-count", params),
|
||||
queryKey: createDashboardQueryKey("submission-status", params),
|
||||
queryFn: async () => {
|
||||
if (!params)
|
||||
throw new Error("Dashboard search params are required.");
|
||||
const response = await dashboardApi.getUnSubmitCount(params);
|
||||
const response = await dashboardApi.getUnSubmitCount({
|
||||
...params,
|
||||
unSubmitOnly: true,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: hasRequiredDashboardParams(params),
|
||||
|
||||
@@ -35,7 +35,7 @@ async function getUnSubmitCount(
|
||||
): Promise<DashboardApiResponse<UnSubmitCountResponse>> {
|
||||
const response = await stClient.get<
|
||||
DashboardApiResponse<UnSubmitCountResponse>
|
||||
>(`${ENDPOINT.ST_SERVICE.DASHBOARD}/un-submit-count`, { params });
|
||||
>(`${ENDPOINT.ST_SERVICE.DASHBOARD}/submission-status`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<SewerStUserListResponse> {
|
||||
const response = await stClient.get<SewerStUserListResponse>(
|
||||
`${ENDPOINT.STATVERIFY_SERVICE.SEWERST_USER}/user-list`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const sewerStUserApi = new SewerStUserApiService();
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,10 @@ export interface DashboardSearchParams {
|
||||
dtlInstCd: string;
|
||||
enoCd?: string;
|
||||
laeSeCd?: string;
|
||||
sewerBzmnCd?: string;
|
||||
sido?: string;
|
||||
sggNm?: string;
|
||||
unSubmitOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardApiResponse<T> {
|
||||
@@ -34,13 +38,19 @@ export interface VrfcErrCountResponse {
|
||||
|
||||
export interface UnSubmitCountResponse {
|
||||
statsYr: string;
|
||||
unSubmitCount: number;
|
||||
unSubmitList: UnSubmitStatsResponse[];
|
||||
statusList: UnSubmitStatsResponse[];
|
||||
submittedCount: number;
|
||||
totalCount: number;
|
||||
unSubmittedCount: number;
|
||||
}
|
||||
|
||||
export interface UnSubmitStatsResponse {
|
||||
sbmsnYn: string;
|
||||
sidoSggNm: string;
|
||||
statsCd: string;
|
||||
statsNm: string;
|
||||
statusCd: string;
|
||||
statusNm: string;
|
||||
}
|
||||
|
||||
export interface StatsSubmissionResponse {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function UnSubmitPanel({ params }: UnSubmitPanelProps) {
|
||||
<h3 className="text-2xl font-semibold">미제출 지표</h3>
|
||||
{data && (
|
||||
<Badge size="lg" className="rounded-full !px-3">
|
||||
{data.unSubmitCount}건
|
||||
{data.unSubmittedCount ?? 0}건
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -43,20 +43,26 @@ export function UnSubmitPanel({ params }: UnSubmitPanelProps) {
|
||||
)}
|
||||
{data && (
|
||||
<div className="h-full">
|
||||
{data.unSubmitList.length === 0 ? (
|
||||
{data.statusList.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
미제출 지표가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[48rem] space-y-2 overflow-y-auto">
|
||||
{data.unSubmitList.map((item) => (
|
||||
{data.statusList.map((item, i) => (
|
||||
<div
|
||||
key={item.statsCd}
|
||||
className="rounded-sm border border-border p-3"
|
||||
key={`${i}-${item.statsCd}`}
|
||||
className="flex items-center justify-between gap-4 rounded-sm border border-border p-3"
|
||||
>
|
||||
<p className="font-medium">
|
||||
{item.statsNm}
|
||||
</p>
|
||||
{params?.dtlInstCd !==
|
||||
"ROLE_USER05" && (
|
||||
<span className="whitespace-nowrap text-lg text-muted-foreground">
|
||||
{item.sidoSggNm}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user