[as] feat: 알림이력 페이지 구현
This commit is contained in:
@@ -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;
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user