rework notification-table

This commit is contained in:
smanylov
2026-04-13 15:10:24 +07:00
parent d8d1eec3ed
commit d0901961be
2 changed files with 511 additions and 200 deletions
+277 -28
View File
@@ -5131,7 +5131,6 @@
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: v.$white; color: v.$white;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -5142,6 +5141,35 @@
display: flex; display: flex;
align-items: center; align-items: center;
} }
.select-all-header {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
.btn-primary {
background: white;
color: black;
font-weight: 600;
font-size: 14px;
padding: 5px 20px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border: none;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 15px 20px -3px rgba(0, 0, 0, 0.15);
}
&:active {
transform: translateY(0);
}
}
}
} }
&-action { &-action {
@@ -5162,30 +5190,76 @@
padding: 0; padding: 0;
background: v.$white; background: v.$white;
.notification { .notifications-table {
&-item { width: 100%;
border-bottom: 1px solid v.$b-color-2; }
padding: 20px;
.table-row {
display: flex;
border-bottom: 1px solid v.$b-color-2;
transition: background-color 0.2s ease;
&:last-child {
border-bottom: none;
}
&.n-header {
background-color: #f8f9fa;
border-bottom: 2px solid v.$b-color-2;
.table-cell {
padding: 15px 20px;
font-weight: 600;
color: v.$text-m;
font-size: 14px;
}
}
&.loading {
opacity: 0.6;
pointer-events: none;
}
&.READIED {
.notification-title,
.notification-text {
font-weight: 500;
opacity: 0.8;
}
}
&:hover:not(.n-header) {
background-color: #fafafa;
}
}
.table-cell {
padding: 20px;
&:first-child {
/* width: 60px;
min-width: 60px;
max-width: 60px; */
display: flex; display: flex;
gap: 15px; align-items: center;
}
&:last-child { &:last-child {
border-bottom: none; flex: 1;
} }
}
&.loading { .notification {
opacity: 0.6; &-body {
} flex-grow: 1;
} }
&-header { &-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} margin-bottom: 8px;
&-body {
flex-grow: 1;
} }
&-title { &-title {
@@ -5196,9 +5270,10 @@
&-text { &-text {
color: v.$text-s; color: v.$text-s;
margin-bottom: 4px; margin-bottom: 12px;
font-weight: 800; font-weight: 800;
font-size: 16px; font-size: 16px;
line-height: 1.4;
} }
&-status { &-status {
@@ -5213,21 +5288,39 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 15px;
.btn-remove { .btn-remove {
color: v.$white; color: v.$white;
background: v.$status-new; background: v.$status-new;
padding: 4px 18px; padding: 6px 18px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
transition: all 0.2s ease;
&:hover { &:hover {
background: #ff9100; background: #ff9100;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
} }
} }
} }
&-date { &-date {
color: v.$text-m; color: v.$text-m;
font-size: 14px; font-size: 13px;
text-align: start; text-align: start;
} }
@@ -5238,16 +5331,29 @@
button { button {
cursor: pointer; cursor: pointer;
border: 2px solid v.$border-color-1; border: 2px solid v.$border-color-1;
border-radius: 4px; border-radius: 6px;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 24px; width: 24px;
height: 24px; height: 24px;
transition: all .3s ease-in; transition: all 0.2s ease;
display: flex; display: flex;
background: white;
padding: 0;
&:hover { &:hover {
border: 2px solid v.$border-color-1-hover; border-color: v.$border-color-1-hover;
transform: scale(1.05);
}
&:active {
transform: scale(0.95);
}
svg {
width: 14px;
height: 14px;
color: v.$status-new;
} }
} }
@@ -5258,19 +5364,162 @@
} }
} }
.notification-item.READIED { .no-notifications {
text-align: center;
padding: 60px 20px;
color: v.$text-m;
font-size: 16px;
.notification-title, &::before {
.notification-text { content: "🔔";
font-weight: 500; display: block;
opacity: 0.8; font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
} }
} }
} }
&-footer { &-footer {
padding: 10px; padding: 15px;
border-top: 1px solid v.$b-color-2; border-top: 1px solid v.$b-color-2;
background: #fafafa;
}
.pagination-controls {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
button {
cursor: pointer;
border: 1px solid v.$b-color-2;
background: white;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.2s ease;
color: v.$text-s;
font-weight: 500;
min-width: 36px;
&:hover:not(:disabled) {
background: v.$b-color-2;
border-color: v.$border-color-1-hover;
transform: translateY(-1px);
}
&:active:not(:disabled) {
transform: translateY(0);
}
&.current {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border-color: transparent;
}
&.other {
background: white;
}
&.arrow {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 10px;
svg {
width: 16px;
height: 16px;
}
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
}
}
.pagination-controls-pages {
display: flex;
gap: 6px;
margin: 0 8px;
}
.error-message {
position: fixed;
bottom: 20px;
right: 20px;
background: #ef4444;
color: white;
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
z-index: 1000;
animation: slideIn 0.3s ease;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
}
// Responsive adjustments
@media (max-width: 768px) {
.notifications-list {
&-wrapper {
max-width: calc(100vw - var(--side-bar-width) - 20px);
}
&-header {
flex-direction: column;
gap: 15px;
align-items: flex-start;
h4 {
height: auto;
}
}
.table-cell {
&:first-child {
width: 80px !important;
min-width: 80px !important;
max-width: 80px !important;
}
}
.notification-footer {
flex-direction: column;
align-items: flex-start !important;
gap: 10px !important;
}
.pagination-controls {
flex-wrap: wrap;
gap: 6px;
}
.select-all-header {
flex-wrap: wrap;
.btn-primary {
font-size: 12px;
padding: 4px 12px;
}
}
} }
} }
+234 -172
View File
@@ -4,34 +4,205 @@ import { useTranslations } from 'next-intl';
import { IconCheck, IconArrowLeft, IconArrowRight, IconDoubleArrowLeft, IconDoubleArrowRight } from '@/app/ui/icons/icons'; import { IconCheck, IconArrowLeft, IconArrowRight, IconDoubleArrowLeft, IconDoubleArrowRight } from '@/app/ui/icons/icons';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useAllNotifications } from '@/app/hooks/react-query/useAllNotificationsList'; import { useAllNotifications } from '@/app/hooks/react-query/useAllNotificationsList';
import { notificationsMarkAsRead, notificationsDelete } from '@/app/actions/notificationActions'; import { notificationsMarkAsRead, notificationsDelete, NotificationBody } from '@/app/actions/notificationActions';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { formatDate, formatDateTime } from '@/app/lib/formatDate'; import { formatDate, formatDateTime } from '@/app/lib/formatDate';
import {
useReactTable,
getCoreRowModel,
getPaginationRowModel,
flexRender,
createColumnHelper,
RowSelectionState,
} from '@tanstack/react-table';
export function NotificationsList() { export function NotificationsList() {
const t = useTranslations('Global'); const t = useTranslations('Global');
const norificationTranslate = useTranslations('Notifications'); const norificationTranslate = useTranslations('Notifications');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set()); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [loadingIds, setLoadingIds] = useState<Set<number>>(new Set()); const [loadingIds, setLoadingIds] = useState<Set<number>>(new Set());
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(0); const [pagination, setPagination] = useState({
const [pageSize] = useState(10); pageIndex: 0,
const { data: allNotifications, isLoading } = useAllNotifications(currentPage, pageSize); pageSize: 10,
});
const { data: allNotifications, isLoading } = useAllNotifications(
pagination.pageIndex,
pagination.pageSize
);
const totalPages = allNotifications?.totalPages || 0; const totalPages = allNotifications?.totalPages || 0;
const currentPageNum = allNotifications?.currentPage || 0; const totalRecords = allNotifications?.totalElements || 0;
const goToFirstPage = () => setCurrentPage(0); const columnHelper = createColumnHelper<NotificationBody>();
const goToPrevPage = () => setCurrentPage(prev => Math.max(0, prev - 1));
const goToNextPage = () => setCurrentPage(prev => Math.min(totalPages - 1, prev + 1)); const columns = [
const goToLastPage = () => setCurrentPage(totalPages - 1); columnHelper.display({
const goToPage = (page: number) => setCurrentPage(page); id: 'select',
header: ({ table }) => {
const currentPageRows = table.getCoreRowModel().rows;
const unreadRowsOnPage = currentPageRows.filter(row => row.original.status !== 'READIED');
const allUnreadSelectedOnPage = unreadRowsOnPage.every(row => rowSelection[row.original.id]);
return (
<div className="select-all-header">
<button
className="btn btn-primary"
onClick={() => {
if (allUnreadSelectedOnPage) {
const newSelection = { ...rowSelection };
unreadRowsOnPage.forEach(row => {
delete newSelection[row.original.id];
});
setRowSelection(newSelection);
} else {
const newSelection = { ...rowSelection };
unreadRowsOnPage.forEach(row => {
newSelection[row.original.id] = true;
});
setRowSelection(newSelection);
}
}}
>
{allUnreadSelectedOnPage ? t('deselect') : t('select-all')}
</button>
{Object.keys(rowSelection).length > 0 && (
<button
className="btn btn-primary"
onClick={() => {
const selectedIds = Object.keys(rowSelection).map(Number);
notificationActionHandler(selectedIds, 'mark-as-read');
}}
>
{t('mark-as-read')} ({Object.keys(rowSelection).length})
</button>
)}
</div>
);
},
cell: ({ row }) => {
const isReadied = row.original.status === 'READIED';
const isSelected = !!rowSelection[row.original.id];
return (
<div className="notification-check">
{!isReadied ? (
<button
onClick={() => {
setRowSelection((prev) => ({
...prev,
[row.original.id]: !prev[row.original.id],
}));
}}
>
{isSelected && <IconCheck />}
</button>
) : (
<div className="empty-block"></div>
)}
</div>
);
},
}),
columnHelper.accessor('message', {
id: 'message',
header: () => null, // No header text, using custom header above
cell: ({ row }) => {
const item = row.original;
return (
<div className="notification-body">
<div className="notification-header">
{/* Optional header content */}
</div>
<div className="notification-text">
{norificationTranslate.has(item.message)
? norificationTranslate(item.message)
: item.message} ({item.id})
</div>
<div className="notification-footer">
<div className="notification-date">
{item.createdAt
? `${formatDate(item.createdAt)}: ${formatDateTime(item.createdAt)}`
: '---'}
</div>
<button
className="btn btn-remove"
onClick={() => {
notificationActionHandler([item.id], 'remove');
}}
disabled={loadingIds.has(item.id)}
>
{t('remove')}
</button>
</div>
</div>
);
},
}),
];
const table = useReactTable({
data: allNotifications?.notifications || [],
columns,
state: {
rowSelection,
pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
pageCount: totalPages,
});
async function notificationActionHandler(ids: number[], action: 'remove' | 'mark-as-read') {
setLoadingIds(prev => {
const newSet = new Set(prev);
ids.forEach(id => newSet.add(id));
return newSet;
});
try {
let response;
if (action === 'mark-as-read') {
response = await notificationsMarkAsRead(ids);
} else if (action === 'remove') {
response = await notificationsDelete(ids);
}
if (response?.success) {
await queryClient.invalidateQueries({ queryKey: ['activeNotifications'] });
await queryClient.invalidateQueries({ queryKey: ['allNotifications'] });
// Clear selection after successful action
setRowSelection({});
}
} catch (error) {
const errorMessage = action === 'mark-as-read' ? 'Failed to mark as read' : 'Failed to remove';
setError(errorMessage);
} finally {
setLoadingIds(prev => {
const newSet = new Set(prev);
ids.forEach(id => newSet.delete(id));
return newSet;
});
}
}
const getPageNumbers = (): (number | string)[] => { const getPageNumbers = (): (number | string)[] => {
const delta: number = 2; const delta: number = 2;
const currentPageNum = pagination.pageIndex;
const range: number[] = []; const range: number[] = [];
const rangeWithDots: (number | string)[] = []; const rangeWithDots: (number | string)[] = [];
let l: number | undefined; let l: number | undefined;
@@ -57,85 +228,25 @@ export function NotificationsList() {
return rangeWithDots; return rangeWithDots;
}; };
useEffect(() => {
console.log(allNotifications);
}, [allNotifications])
function selectHandler(fileId: number) {
setSelectedFiles((prev) => {
const newSet = new Set(prev);
if (newSet.has(fileId)) {
newSet.delete(fileId)
} else {
newSet.add(fileId)
}
return newSet;
});
}
function clearHandler() {
setSelectedFiles(new Set());
}
function selectAllHandler() {
const idsToSelect = allNotifications?.notifications
.filter(item => item.status !== 'READIED')
.map(item => item.id);
setSelectedFiles(new Set(idsToSelect));
}
async function notificationActionHandler(ids: number[], action: 'remove' | 'mark-as-read') {
setLoadingIds(prev => {
const newSet = new Set(prev);
ids.forEach(id => newSet.add(id));
return newSet;
});
try {
let response;
if (action === 'mark-as-read') {
response = await notificationsMarkAsRead(ids);
} else if (action === 'remove') {
response = await notificationsDelete(ids);
}
if (response?.success) {
await queryClient.invalidateQueries({ queryKey: ['activeNotifications'] });
await queryClient.invalidateQueries({ queryKey: ['allNotifications'] });
}
} catch (error) {
const errorMessage = action === 'mark-as-read' ? 'Failed to mark as read' : 'Failed to remove';
setError(errorMessage);
} finally {
setLoadingIds(prev => {
const newSet = new Set(prev);
ids.forEach(id => newSet.delete(id));
return newSet;
});
setSelectedFiles(prev => {
const newSet = new Set(prev);
ids.forEach(id => newSet.delete(id));
return newSet;
});
}
}
return ( return (
<div <div className="notifications-list-wrapper">
className="notifications-list-wrapper" <div className="notifications-list-header">
> <h4>{t('all-notifications')}</h4>
<div <div className="notifications-table-header">
className="notifications-list-header" {table.getHeaderGroups().map((headerGroup) => (
> <div key={headerGroup.id} className="table-row n-header">
<h4> {headerGroup.headers.map((header) => (
{t('all-notifications')} <div key={header.id} className="table-cell">
</h4> {flexRender(
header.column.columnDef.header,
<div header.getContext()
)}
</div>
))}
</div>
))}
</div>
{/* <div
className="notifications-list-action" className="notifications-list-action"
> >
{(selectedFiles.size !== 0) && ( {(selectedFiles.size !== 0) && (
@@ -168,100 +279,49 @@ export function NotificationsList() {
> >
{t('select-all')} {t('select-all')}
</button> </button>
</div> </div> */}
</div> </div>
<div
className="notifications-list-body" <div className="notifications-list-body">
> {allNotifications?.notifications.length !== 0 ? (
{(allNotifications?.notifications.length !== 0) && ( <div className="notifications-table">
allNotifications?.notifications.map(item => { <div className="notifications-table-body">
return ( {table.getRowModel().rows.map((row) => (
<div
className={`notification-item ${loadingIds.has(item.id) ? 'loading' : ''} ${item.status}`}
key={item.id}
>
<div <div
className="notification-check" key={row.id}
className={`table-row ${loadingIds.has(row.original.id) ? 'loading' : ''} ${row.original.status}`}
> >
{item.status !== 'READIED' ? ( {row.getVisibleCells().map((cell) => (
<button <div key={cell.id} className="table-cell">
onClick={() => { {flexRender(
selectHandler(item.id); cell.column.columnDef.cell,
}} cell.getContext()
>
{selectedFiles.has(item.id) && (
<IconCheck />
)} )}
</button>
) : (
<div className="empty-block"></div>
)}
</div>
<div
className="notification-body"
>
<div
className="notification-header"
>
{/* <div
className="notification-title"
>
{item.type} {item.id}
</div> */}
{/* <div
className="notification-status"
>
{item.status}
</div> */}
</div>
<div
className="notification-text"
>
{norificationTranslate.has(item.message) ? norificationTranslate(item.message) : item.message}
</div>
<div
className="notification-footer"
>
<div
className="notification-date"
>
{item.createdAt ? `${formatDate(item.createdAt)}: ${formatDateTime(item.createdAt)}` : '---'}
</div> </div>
))}
<button
className="btn btn-remove"
onClick={() => {
notificationActionHandler([item.id], 'remove');
}}
disabled={loadingIds.has(item.id)}
>
{t('remove')}
</button>
</div>
</div> </div>
</div> ))}
) </div>
}) </div>
) : (
<div className="no-notifications">{t('no-notifications')}</div>
)} )}
</div> </div>
<div <div className="notifications-list-footer">
className="notifications-list-footer"
>
{totalPages > 0 && ( {totalPages > 0 && (
<div className="pagination-controls"> <div className="pagination-controls">
<button <button
className="arrow" className="arrow"
onClick={goToFirstPage} onClick={() => table.firstPage()}
disabled={currentPageNum === 0} disabled={!table.getCanPreviousPage()}
> >
<IconDoubleArrowLeft /> <IconDoubleArrowLeft />
</button> </button>
<button <button
onClick={goToPrevPage} onClick={() => table.previousPage()}
className="arrow" className="arrow"
disabled={currentPageNum === 0} disabled={!table.getCanPreviousPage()}
> >
<IconArrowLeft /> <IconArrowLeft />
</button> </button>
@@ -270,8 +330,8 @@ export function NotificationsList() {
{getPageNumbers().map((pageNum, index) => ( {getPageNumbers().map((pageNum, index) => (
<button <button
key={index} key={index}
onClick={() => typeof pageNum === 'number' && goToPage(pageNum)} onClick={() => typeof pageNum === 'number' && table.setPageIndex(pageNum)}
className={currentPageNum === pageNum ? 'current' : 'other'} className={pagination.pageIndex === pageNum ? 'current' : 'other'}
disabled={pageNum === '...'} disabled={pageNum === '...'}
> >
{pageNum === '...' ? '...' : (pageNum as number) + 1} {pageNum === '...' ? '...' : (pageNum as number) + 1}
@@ -280,22 +340,24 @@ export function NotificationsList() {
</div> </div>
<button <button
onClick={goToNextPage} onClick={() => table.nextPage()}
className="arrow" className="arrow"
disabled={currentPageNum === totalPages - 1} disabled={!table.getCanNextPage()}
> >
<IconArrowRight /> <IconArrowRight />
</button> </button>
<button <button
className="arrow" className="arrow"
onClick={goToLastPage} onClick={() => table.lastPage()}
disabled={currentPageNum === totalPages - 1} disabled={!table.getCanNextPage()}
> >
<IconDoubleArrowRight /> <IconDoubleArrowRight />
</button> </button>
</div> </div>
)} )}
</div> </div>
{error && <div className="error-message">{error}</div>}
</div> </div>
) );
} }