[as] feat: 알림이력 페이지 구현

This commit is contained in:
2026-08-13 11:51:21 +09:00
parent fe92ba3c27
commit 9bcc8b95e7
12 changed files with 835 additions and 0 deletions
@@ -0,0 +1,23 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type { NotiAsUserResponseDto, NotiSearchDto, NotiService } from "../model/noti-interface";
const BASE = ENDPOINT.CMS_SERVICE.NOTI;
async function findMyAsNotiList(
params: NotiSearchDto,
): Promise<PageResponse<NotiAsUserResponseDto>> {
const response = await client.get<PageResponse<NotiAsUserResponseDto>>(`${BASE}/as/my/list`, {
params,
});
return response.data;
}
async function readNoti(notiId: number): Promise<number> {
const response = await client.put<number>(`${BASE}/${notiId}/read`);
return response.data;
}
export const notiService = { findMyAsNotiList, readNoti } satisfies NotiService;
+26
View File
@@ -0,0 +1,26 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type { NotiSearchDto } from "../model/noti-interface";
import { notiService } from "./noti-service";
export const notiBaseKey = [ENDPOINT.CMS_SERVICE.NOTI] as const;
export function useFindMyAsNotiList(params: NotiSearchDto) {
return useQuery({
queryKey: [...notiBaseKey, "findMyAsNotiList", params],
queryFn: () => notiService.findMyAsNotiList(params),
});
}
export function useReadNoti() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (notiId: number) => notiService.readNoti(notiId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notiBaseKey });
},
});
}
+3
View File
@@ -0,0 +1,3 @@
export { notiService } from "./api/noti-service";
export { notiBaseKey, useFindMyAsNotiList, useReadNoti } from "./api/queries";
export type { NotiAsUserResponseDto, NotiSearchDto, NotiService } from "./model/noti-interface";
@@ -0,0 +1,41 @@
import type { PageableParams, PageResponse } from "@/shared/model";
/** 백엔드 NotiAsUserResponseDto */
export interface NotiAsUserResponseDto {
notiId: number;
recvUnqId: string;
senderUnqId: string;
notiType: string;
notiTtl: string;
notiCn?: string;
notReadYn?: string;
regDt?: string;
notiUrl?: string;
notiLnkgUnqNo?: string;
docSeComCd?: string;
docSeComNm?: string;
notiIdntyDt?: string;
notiIdntyYn?: string;
notiPrcsCmptnYn?: string;
notiPrcsCmptnDt?: string;
}
/** 백엔드 NotiSearchDto + Pageable */
export interface NotiSearchDto extends PageableParams {
recvUnqId?: string;
senderUnqId?: string;
notiType?: string;
notiTtlKeyword?: string;
notiCnKeyword?: string;
notReadYn?: string;
delYn?: string;
startDt?: string;
endDt?: string;
docSeComCd?: string;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface NotiService {
findMyAsNotiList(params: NotiSearchDto): Promise<PageResponse<NotiAsUserResponseDto>>;
readNoti(notiId: number): Promise<number>;
}
+1
View File
@@ -0,0 +1 @@
export { NtiHstryListPage } from "./ui/nti-hstry-list-page";
@@ -0,0 +1,61 @@
import { dayjs } from "@/shared/lib";
import type { NotiAsUserResponseDto } from "@/entities/noti";
import type { BadgeProps } from "@common/components";
export type NotiStatusLabel = "처리완료" | "처리지연" | "처리중" | "지연" | "미조회";
export interface NotiStatusUi {
label: NotiStatusLabel;
badgeProps: Pick<BadgeProps, "variant" | "color" | "size">;
actionLabel: string;
}
function daysSince(regDt: string) {
return dayjs().startOf("day").diff(dayjs(regDt).startOf("day"), "day");
}
export function resolveNotiStatus(
row: Pick<NotiAsUserResponseDto, "notiPrcsCmptnYn" | "notReadYn" | "regDt">,
): NotiStatusUi | null {
if (row.notiPrcsCmptnYn === "Y") {
return {
label: "처리완료",
badgeProps: { variant: "default", size: "lg" },
actionLabel: "상세보기",
};
}
if (!row.regDt) return null;
const days = daysSince(row.regDt);
const isRead = row.notReadYn === "Y";
if (isRead) {
if (days > 7) {
return {
label: "처리지연",
badgeProps: { color: "red", size: "lg" },
actionLabel: "처리하기",
};
}
return {
label: "처리중",
badgeProps: { color: "green", size: "lg" },
actionLabel: "처리하기",
};
}
if (days > 3) {
return {
label: "지연",
badgeProps: { color: "orange", size: "lg" },
actionLabel: "처리하기",
};
}
return {
label: "미조회",
badgeProps: { color: "yellow" },
actionLabel: "처리하기",
};
}
@@ -0,0 +1,91 @@
"use client";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { NotiAsUserResponseDto } from "@/entities/noti";
import { Badge, Button } from "@common/components";
import { resolveNotiStatus } from "../lib/resolve-noti-status";
const columnHelper = createColumnHelper<NotiAsUserResponseDto>();
function formatOptional(value: string | number | undefined | null) {
if (value === undefined || value === null) return "-";
const text = String(value).trim();
return text ? text : "-";
}
function formatDateTime(value: string | undefined) {
return value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
}
interface CreateNotiColumnsOptions {
pageIndex: number;
pageSize: number;
isActionPending: boolean;
onAction: (row: NotiAsUserResponseDto) => void;
}
export function createNotiColumns({
pageIndex,
pageSize,
isActionPending,
onAction,
}: CreateNotiColumnsOptions) {
return [
columnHelper.display({
id: "no",
header: "No",
meta: { align: "center" },
cell: ({ row }) => pageIndex * pageSize + row.index + 1,
}),
columnHelper.accessor("regDt", {
header: "알림일시",
meta: { align: "center" },
cell: ({ getValue }) => formatDateTime(getValue()),
}),
columnHelper.accessor("notiTtl", {
header: "알림내용",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("docSeComNm", {
header: "연계업무",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.docSeComNm || row.original.docSeComCd),
}),
columnHelper.display({
id: "status",
header: "상태",
meta: { align: "center" },
cell: ({ row }) => {
const status = resolveNotiStatus(row.original);
if (!status) return "-";
return <Badge {...status.badgeProps}>{status.label}</Badge>;
},
}),
columnHelper.display({
id: "action",
header: "처리",
meta: { align: "center" },
cell: ({ row }) => {
const status = resolveNotiStatus(row.original);
const label = status?.actionLabel ?? "처리하기";
return (
<div className="flex justify-center" onClick={(event) => event.stopPropagation()}>
<Button
type="button"
size="sm"
color="white"
disabled={isActionPending}
onClick={() => onAction(row.original)}
>
{label}
</Button>
</div>
);
},
}),
];
}
@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import { useCodeList } from "@/entities/code";
import type { NotiSearchDto } from "@/entities/noti";
import {
Button,
InputDatePicker,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { NtiHstryList } from "./nti-hstry-list";
const DEFAULT_PAGE_INDEX = 0;
const DEFAULT_PAGE_SIZE = 10;
const ALL_SELECT_VALUE = "__ALL__";
function defaultStartDate() {
return dayjs().subtract(1, "month").toDate();
}
function defaultEndDate() {
return dayjs().toDate();
}
function formatDateParam(date: Date | null | undefined) {
return date ? dayjs(date).format("YYYY-MM-DD") : "";
}
function buildDefaultSearchParams(): NotiSearchDto {
return {
startDt: formatDateParam(defaultStartDate()),
endDt: formatDateParam(defaultEndDate()),
docSeComCd: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
};
}
function NtiHstryListPage() {
const [startDate, setStartDate] = useState<Date | null>(defaultStartDate);
const [endDate, setEndDate] = useState<Date | null>(defaultEndDate);
const [docSeComCd, setDocSeComCd] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: DEFAULT_PAGE_INDEX,
pageSize: DEFAULT_PAGE_SIZE,
});
const [searchParams, setSearchParams] = useState<NotiSearchDto>(buildDefaultSearchParams);
const { data: docSeCodes, isPending: isDocSeLoading } = useCodeList("ASDOCSE");
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
startDt: formatDateParam(startDate),
endDt: formatDateParam(endDate),
docSeComCd,
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
<form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">기간</Label>
<InputDatePicker
value={startDate}
onChange={(date) => setStartDate(date ?? null)}
className="w-[12rem]"
/>
<span className="text-muted-foreground">~</span>
<InputDatePicker
value={endDate}
onChange={(date) => setEndDate(date ?? null)}
className="w-[12rem]"
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="docSeComCd" className="whitespace-nowrap">
구분
</Label>
<Select
value={docSeComCd || ALL_SELECT_VALUE}
onValueChange={(value) => setDocSeComCd(value === ALL_SELECT_VALUE ? "" : value)}
disabled={isDocSeLoading}
>
<SelectTrigger id="docSeComCd" className="w-[12rem]">
<SelectValue placeholder={isDocSeLoading ? "로딩 중…" : "전체"} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{docSeCodes?.map((item) => (
<SelectItem key={item.lwpoCd} value={item.lwpoCd}>
{item.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit">검색</Button>
</form>
</section>
<NtiHstryList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
/>
</div>
);
}
export { NtiHstryListPage };
@@ -0,0 +1,105 @@
"use client";
import { useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import {
type NotiAsUserResponseDto,
type NotiSearchDto,
useFindMyAsNotiList,
useReadNoti,
} from "@/entities/noti";
import { createNotiColumns } from "../model/noti-table-columns";
interface NtiHstryListProps {
searchParams: NotiSearchDto;
pagination: PaginationState;
onPaginationChange: (pagination: PaginationState) => void;
}
function toOptionalParam(value: string | undefined) {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function navigateNotiUrl(url: string, push: (href: string) => void) {
if (/^https?:\/\//i.test(url)) {
window.location.href = url;
return;
}
push(url.startsWith("/") ? url : `/${url}`);
}
function NtiHstryList({ searchParams, pagination, onPaginationChange }: NtiHstryListProps) {
const router = useRouter();
const { mutateAsync: readNoti, isPending: isReadPending } = useReadNoti();
const queryParams = useMemo(
(): NotiSearchDto => ({
startDt: toOptionalParam(searchParams.startDt),
endDt: toOptionalParam(searchParams.endDt),
docSeComCd: toOptionalParam(searchParams.docSeComCd),
page: pagination.pageIndex,
size: pagination.pageSize,
sort: searchParams.sort,
}),
[searchParams, pagination],
);
const pageQuery = useFindMyAsNotiList(queryParams);
const handleAction = useCallback(
async (row: NotiAsUserResponseDto) => {
await readNoti(row.notiId);
const notiUrl = row.notiUrl?.trim();
if (!notiUrl) return;
navigateNotiUrl(notiUrl, (href) => router.push(href));
},
[readNoti, router],
);
const columns = useMemo(
() =>
createNotiColumns({
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
isActionPending: isReadPending,
onAction: (row) => {
void handleAction(row);
},
}),
[pagination.pageIndex, pagination.pageSize, isReadPending, handleAction],
);
const { tableComponent } = useDataTable({
query: pageQuery,
tableOptions: {
columns,
state: { pagination },
onPaginationChange: (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
onPaginationChange(next);
},
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
},
dataTableOptions: {
viewPagination: true,
},
});
return (
<section className="card card-border mx-0 bg-background pb-8 pt-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">알림 이력 목록</h3>
</div>
{tableComponent}
</section>
);
}
export { NtiHstryList };
+1
View File
@@ -40,6 +40,7 @@ export const ENDPOINT = {
MENU_ROLES: "/cms-service/api/v1/menu-roles",
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
USER_ORGANIZATION: "/cms-service/api/v1/user-ognz",
NOTI: "/cms-service/api/v1/noti",
},
STATVERIFY_SERVICE: {
ST_ANLS_MST: "/statverify-service/api/v1/st-anls-mst",