Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev

This commit is contained in:
2026-05-07 15:33:11 +09:00
10 changed files with 1104 additions and 10 deletions
@@ -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();
+7
View File
@@ -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>
+5
View File
@@ -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",
},
};