Merge pull request 'added data table, formatter' (#7) from feature/audit-log into main
Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
@@ -1,11 +1,29 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"common_page_show": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "Hiện chỉ có 1 dữ liệu",
|
||||
"countPlural=other": "Hiển thị {start} tới {end} của {count} dữ liệu"
|
||||
}
|
||||
}
|
||||
],
|
||||
"common_per_page": "Show",
|
||||
"common_select_page_size": "Select page size",
|
||||
"common_no_list": "Hiện tại chưa có dữ liệu nào!",
|
||||
"common_is_required": "{field} is required.",
|
||||
"role_tags_admin": "Administrator",
|
||||
"role_tags_user": "User",
|
||||
"role_tags_member": "Member",
|
||||
"role_tags_owner": "Owner",
|
||||
"role_tags": [
|
||||
{
|
||||
"match": {
|
||||
"role=admin": "Administrator",
|
||||
"role=user": "User",
|
||||
"role=member": "Member",
|
||||
"role=owner": "Owner"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ui_login_btn": "Sign in",
|
||||
"ui_logout_btn": "Sign out",
|
||||
"ui_cancel_btn": "Cancel",
|
||||
@@ -15,6 +33,10 @@
|
||||
"ui_view_btn": "View",
|
||||
"ui_save_btn": "Save",
|
||||
"ui_update_btn": "Update",
|
||||
"ui_delete_btn": "Delete",
|
||||
"ui_ban_btn": "Lock",
|
||||
"ui_unban_btn": "Unlock",
|
||||
"ui_dialog_view_title": "View {type} details",
|
||||
"ui_view_all_notifications": "View All Notifications",
|
||||
"ui_label_notifications": "Notifications",
|
||||
"ui_change_password_btn": "Change password",
|
||||
@@ -24,7 +46,7 @@
|
||||
"nav_add_new": "Add new",
|
||||
"nav_edit": "Edit",
|
||||
"nav_change_password": "Change password",
|
||||
"nav_log": "Audit log",
|
||||
"nav_log": "Logs",
|
||||
"nav_roles": "Vai trò & quyền hạn",
|
||||
"nav_box": "Box",
|
||||
"nav_account": "Account",
|
||||
@@ -57,6 +79,25 @@
|
||||
"settings_ui_title": "Settings",
|
||||
"settings_messages_update_success": "Updated settings successfully!",
|
||||
"settings_messages_update_fail": "Update fail!",
|
||||
"logs_page_ui_title": "Logs",
|
||||
"logs_page_ui_table_header_username": "User name",
|
||||
"logs_page_ui_table_header_table": "Table",
|
||||
"logs_page_ui_table_header_action": "Action",
|
||||
"logs_page_ui_table_header_create_at": "Create date",
|
||||
"logs_page_ui_badge_action": [
|
||||
{
|
||||
"match": {
|
||||
"action=create": "Create",
|
||||
"action=update": "Update",
|
||||
"action=delete": "Delete",
|
||||
"action=ban": "Lock",
|
||||
"action=unban": "Unlock",
|
||||
"action=sign_in": "Sign in",
|
||||
"action=sign_out": "Sign out",
|
||||
"action=*": "Other"
|
||||
}
|
||||
}
|
||||
],
|
||||
"backend_INVALID_EMAIL_OR_PASSWORD": "Email or password incorrect!",
|
||||
"backend_INVALID_PASSWORD": "Password incorrect!"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"common_page_show": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "Hiện chỉ có 1 dữ liệu",
|
||||
"countPlural=other": "Hiển thị {start} tới {end} của {count} dữ liệu"
|
||||
}
|
||||
}
|
||||
],
|
||||
"common_per_page": "Hiển thị",
|
||||
"common_select_page_size": "Chọn số lượng",
|
||||
"common_no_list": "Hiện tại chưa có dữ liệu nào!",
|
||||
"common_is_required": "{field} là bắt buộc.",
|
||||
"role_tags_admin": "Quản lý",
|
||||
"role_tags_user": "Người dùng",
|
||||
"role_tags_member": "Thành viên",
|
||||
"role_tags_owner": "Người sở hữu",
|
||||
"role_tags": [
|
||||
{
|
||||
"match": {
|
||||
"role=admin": "Quản lý",
|
||||
"role=user": "Người dùng",
|
||||
"role=member": "Thành viên",
|
||||
"role=owner": "Người sở hữu"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ui_login_btn": "Đăng nhập",
|
||||
"ui_logout_btn": "Đăng xuất",
|
||||
"ui_cancel_btn": "Hủy",
|
||||
@@ -15,6 +33,10 @@
|
||||
"ui_view_btn": "Xem",
|
||||
"ui_save_btn": "Lưu",
|
||||
"ui_update_btn": "Cập nhật",
|
||||
"ui_delete_btn": "Xóa",
|
||||
"ui_ban_btn": "Khóa",
|
||||
"ui_unban_btn": "Mở khóa",
|
||||
"ui_dialog_view_title": "Xem chi tiết {type}",
|
||||
"ui_view_all_notifications": "Xem tất cả thông báo",
|
||||
"ui_label_notifications": "Thông báo",
|
||||
"ui_change_password_btn": "Đổi mật khẩu",
|
||||
@@ -57,6 +79,27 @@
|
||||
"settings_ui_title": "Cài đặt",
|
||||
"settings_messages_update_success": "Cập nhật cài đặt thành công!",
|
||||
"settings_messages_update_fail": "Cập nhật cài đặt thất bại!",
|
||||
"logs_page_ui_title": "Lịch sử",
|
||||
"logs_page_ui_table_header_username": "Người dùng",
|
||||
"logs_page_ui_table_header_table": "Bảng",
|
||||
"logs_page_ui_table_header_action": "Tương tác",
|
||||
"logs_page_ui_table_header_create_at": "Ngày tạo",
|
||||
"logs_page_ui_table_header_old_value": "Giá trị cũ",
|
||||
"logs_page_ui_table_header_new_value": "Giá trị mới",
|
||||
"logs_page_ui_badge_action": [
|
||||
{
|
||||
"match": {
|
||||
"action=create": "Tạo",
|
||||
"action=update": "Cập nhật",
|
||||
"action=delete": "Xóa",
|
||||
"action=ban": "Khóa",
|
||||
"action=unban": "Mở khóa",
|
||||
"action=sign_in": "Đăng nhập",
|
||||
"action=sign_out": "Đăng xuất",
|
||||
"action=*": "Khác"
|
||||
}
|
||||
}
|
||||
],
|
||||
"backend_INVALID_EMAIL_OR_PASSWORD": "Email hoặc mật khẩu không đúng!",
|
||||
"backend_INVALID_PASSWORD": "Mật khẩu hiện tại không đúng!"
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@tanstack/react-router-devtools": "^1.132.0",
|
||||
"@tanstack/react-router-ssr-query": "^1.131.7",
|
||||
"@tanstack/react-start": "^1.132.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/router-plugin": "^1.132.0",
|
||||
"better-auth": "^1.4.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -50,6 +50,9 @@ importers:
|
||||
'@tanstack/react-start':
|
||||
specifier: ^1.132.0
|
||||
version: 1.146.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@tanstack/router-plugin':
|
||||
specifier: ^1.132.0
|
||||
version: 1.146.3(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
|
||||
@@ -2013,6 +2016,13 @@ packages:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/react-table@8.21.3':
|
||||
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
'@tanstack/router-core@1.146.2':
|
||||
resolution: {integrity: sha512-MmTDiT6fpe+WBWYAuhp8oyzULBJX4oblm1kCqHDngf9mK3qcnNm5nkKk4d3Fk80QZmHS4DcRNFaFHKbLUVlZog==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2092,6 +2102,10 @@ packages:
|
||||
'@tanstack/store@0.8.0':
|
||||
resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==}
|
||||
|
||||
'@tanstack/table-core@8.21.3':
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/virtual-file-routes@1.145.4':
|
||||
resolution: {integrity: sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -6826,6 +6840,12 @@ snapshots:
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
|
||||
'@tanstack/react-table@8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.3
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
'@tanstack/router-core@1.146.2':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.145.7
|
||||
@@ -6960,6 +6980,8 @@ snapshots:
|
||||
|
||||
'@tanstack/store@0.8.0': {}
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@tanstack/virtual-file-routes@1.145.4': {}
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
|
||||
165
src/components/DataTable.tsx
Normal file
165
src/components/DataTable.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import Pagination from './Pagination';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from './ui/table';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
limit: number;
|
||||
setLimit: (page: number) => void;
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
totalPage: number;
|
||||
totalItem: number;
|
||||
};
|
||||
}
|
||||
|
||||
const DataTable = <TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
page,
|
||||
setPage,
|
||||
limit,
|
||||
setLimit,
|
||||
pagination,
|
||||
}: DataTableProps<TData, TValue>) => {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={cn(
|
||||
'px-4',
|
||||
header.column.columnDef.meta?.thClass,
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody className="**:data-[slot=table-cell]:first:w-8">
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="px-4">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="hidden text-sm gap-2 xl:flex">
|
||||
<span>
|
||||
{m.common_page_show({
|
||||
count: pagination.totalItem,
|
||||
start: (pagination.currentPage - 1) * 10 + 1,
|
||||
end: Math.min(pagination.currentPage * 10, pagination.totalItem),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex max-w-full items-center gap-8 xl:w-ft">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
{m.common_per_page()}
|
||||
</Label>
|
||||
<Select
|
||||
value={`${limit}`}
|
||||
onValueChange={(value) => {
|
||||
setLimit(+value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-30"
|
||||
id="rows-per-page"
|
||||
>
|
||||
<SelectValue placeholder={m.common_select_page_size()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 30, 50, 100].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={pagination.totalPage}
|
||||
onPageChange={(newPage) => setPage(newPage)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataTable;
|
||||
@@ -2,7 +2,7 @@ import { m } from '@/paraglide/messages';
|
||||
import { Separator } from '@base-ui/react/separator';
|
||||
import { BellIcon } from '@phosphor-icons/react';
|
||||
import { useAuth } from './auth/auth-provider';
|
||||
import RouterBreadcrumb from './sidebar/RouterBreadcrumb';
|
||||
import RouterBreadcrumb from './sidebar/router-breadcrumb';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
|
||||
97
src/components/Pagination.tsx
Normal file
97
src/components/Pagination.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
DotsThreeIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { Button } from './ui/button';
|
||||
import { ButtonGroup } from './ui/button-group';
|
||||
|
||||
type PaginationProps = {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
const Pagination = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: PaginationProps) => {
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
|
||||
if (totalPages <= 5) {
|
||||
// Hiển thị tất cả nếu trang ít
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
if (currentPage <= 3) {
|
||||
pages.push(1, 2, 3, 'dot', totalPages);
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pages.push(1, 'dot', totalPages - 2, totalPages - 1, totalPages);
|
||||
} else {
|
||||
pages.push(1, 'dot', currentPage, 'dot', totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const pages = getPageNumbers();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<ButtonGroup>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={currentPage === 1}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<CaretLeftIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup>
|
||||
{pages.map((page, idx) =>
|
||||
page === 'dot' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
key={idx}
|
||||
disabled={true}
|
||||
>
|
||||
<DotsThreeIcon size={14} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
key={idx}
|
||||
size="icon-sm"
|
||||
data-active={currentPage === page}
|
||||
onClick={() => onPageChange(Number(page))}
|
||||
disabled={currentPage === page}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</ButtonGroup>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={currentPage === totalPages}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<CaretRightIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
43
src/components/audit/action-badge.tsx
Normal file
43
src/components/audit/action-badge.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { Badge } from '../ui/badge';
|
||||
|
||||
export type UserActionType = {
|
||||
create: string;
|
||||
update: string;
|
||||
delete: string;
|
||||
ban: string;
|
||||
unban: string;
|
||||
sign_in: string;
|
||||
sign_out: string;
|
||||
};
|
||||
|
||||
const ActionBadge = ({ action }: { action: keyof UserActionType }) => {
|
||||
const USER_ACTION = Object.freeze(
|
||||
new Proxy(
|
||||
{
|
||||
create: 'bg-green-400',
|
||||
update: 'bg-blue-400',
|
||||
delete: 'bg-red-400',
|
||||
sign_in: 'bg-lime-400',
|
||||
sign_out: 'bg-yellow-400',
|
||||
ban: 'bg-rose-400',
|
||||
unban: 'bg-emerald-400',
|
||||
} as UserActionType,
|
||||
{
|
||||
get: function (target: UserActionType, name: string | symbol) {
|
||||
return target.hasOwnProperty(name as string)
|
||||
? target[name as keyof UserActionType]
|
||||
: '';
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<Badge variant="default" className={USER_ACTION[action]}>
|
||||
{m.logs_page_ui_badge_action({ action })}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionBadge;
|
||||
159
src/components/audit/audit-columns.tsx
Normal file
159
src/components/audit/audit-columns.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { formatters } from '@/utils/formatters';
|
||||
import { jsonSupport } from '@/utils/help';
|
||||
import { EyeIcon } from '@phosphor-icons/react';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog';
|
||||
import { Label } from '../ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import ActionBadge, { UserActionType } from './action-badge';
|
||||
|
||||
export const logColumns: ColumnDef<AuditLog>[] = [
|
||||
{
|
||||
accessorKey: 'user.name',
|
||||
header: m.logs_page_ui_table_header_username(),
|
||||
meta: {
|
||||
thClass: 'w-1/6',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'tableName',
|
||||
header: m.logs_page_ui_table_header_table(),
|
||||
meta: {
|
||||
thClass: 'w-1/6',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Badge variant="table" className="px-3 py-1 text-xs">
|
||||
{row.original.tableName}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: m.logs_page_ui_table_header_action(),
|
||||
meta: {
|
||||
thClass: 'w-1/6',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<ActionBadge action={row.original.action as keyof UserActionType} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: m.logs_page_ui_table_header_action(),
|
||||
meta: {
|
||||
thClass: 'w-2/6',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return formatters.dateTime(new Date(row.original.createdAt));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
meta: {
|
||||
thClass: 'w-1/6',
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<ViewDetail data={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
type ViewDetailProps = {
|
||||
data: AuditLog;
|
||||
};
|
||||
|
||||
const ViewDetail = ({ data }: ViewDetailProps) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full cursor-pointer text-blue-500 hover:bg-blue-100 hover:text-blue-600"
|
||||
>
|
||||
<EyeIcon size={16} />
|
||||
<span className="sr-only">View</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="bg-blue-300 [&_svg]:bg-blue-300 [&_svg]:fill-blue-300 text-white"
|
||||
>
|
||||
<Label>View</Label>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent className="max-w-100 xl:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{m.ui_dialog_view_title({ type: m.nav_log() })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">
|
||||
{m.logs_page_ui_table_header_username()}:
|
||||
</span>
|
||||
<Label>{data.user.name}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">
|
||||
{m.logs_page_ui_table_header_table()}:
|
||||
</span>
|
||||
<Badge variant="table" className="px-3 py-1 text-xs">
|
||||
{data.tableName}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">
|
||||
{m.logs_page_ui_table_header_action()}:
|
||||
</span>
|
||||
<ActionBadge action={data.action as keyof UserActionType} />
|
||||
</div>
|
||||
{data.oldValue && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="font-bold">
|
||||
{m.logs_page_ui_table_header_old_value()}:
|
||||
</span>
|
||||
<pre className="whitespace-pre-wrap wrap-break-word">
|
||||
{jsonSupport(data.oldValue)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="font-bold">
|
||||
{m.logs_page_ui_table_header_new_value()}:
|
||||
</span>
|
||||
<pre className="whitespace-pre-wrap wrap-break-word">
|
||||
{data.newValue ? jsonSupport(data.newValue) : ''}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">
|
||||
{m.logs_page_ui_table_header_create_at()}:
|
||||
</span>
|
||||
<span>{formatters.dateTime(new Date(data.createdAt))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '../auth/auth-provider';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
|
||||
import RoleRing from './RoleRing';
|
||||
import RoleRing from './role-ring';
|
||||
|
||||
interface AvatarUserProps {
|
||||
className?: string;
|
||||
@@ -24,7 +24,7 @@ const AvatarUser = ({ className, textSize = 'md' }: AvatarUserProps) => {
|
||||
return (
|
||||
<RoleRing type={session?.user?.role}>
|
||||
<Avatar className={className}>
|
||||
<AvatarImage src={imagePath} />
|
||||
<AvatarImage src={imagePath} title={shortName} />
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
'bg-orange-400 text-white',
|
||||
@@ -12,35 +12,22 @@ type RoleProps = {
|
||||
const RoleBadge = ({ type, className }: RoleProps) => {
|
||||
// List all valid badge variant keys
|
||||
const validBadgeVariants: BadgeVariant[] = [
|
||||
'default',
|
||||
'secondary',
|
||||
'destructive',
|
||||
'outline',
|
||||
'ghost',
|
||||
'link',
|
||||
'admin',
|
||||
'user',
|
||||
'member',
|
||||
'owner',
|
||||
];
|
||||
|
||||
const LABEL_VALUE = {
|
||||
admin: m.role_tags_admin(),
|
||||
user: m.role_tags_user(),
|
||||
member: m.role_tags_member(),
|
||||
owner: m.role_tags_owner(),
|
||||
};
|
||||
|
||||
// Determine the actual variant to apply.
|
||||
// If 'type' is a valid variant key, use it. Otherwise, fallback to 'default'.
|
||||
const displayVariant: BadgeVariant =
|
||||
type && validBadgeVariants.includes(type as BadgeVariant)
|
||||
? (type as BadgeVariant)
|
||||
: 'default';
|
||||
: 'user';
|
||||
|
||||
return (
|
||||
<Badge variant={displayVariant} className={className}>
|
||||
{LABEL_VALUE[(type as keyof typeof LABEL_VALUE) || 'default']}
|
||||
{m.role_tags({ role: type as string })}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -8,8 +8,8 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '../auth/auth-provider';
|
||||
import AvatarUser from '../avatar/AvatarUser';
|
||||
import RoleBadge from '../avatar/RoleBadge';
|
||||
import AvatarUser from '../avatar/avatar-user';
|
||||
import RoleBadge from '../avatar/role-badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';
|
||||
|
||||
@@ -4,12 +4,11 @@ import {
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import NavMain from './nav-main'
|
||||
import NavUser from './nav-user'
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import NavMain from './nav-main';
|
||||
import NavUser from './nav-user';
|
||||
|
||||
const AppSidebar = ({ ...props }: React.ComponentProps<typeof Sidebar>) => {
|
||||
return (
|
||||
@@ -17,14 +16,12 @@ const AppSidebar = ({ ...props }: React.ComponentProps<typeof Sidebar>) => {
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg">
|
||||
<h1 className="text-xl font-semibold">
|
||||
<Link to="/" className="flex items-center gap-2" title="Fuware">
|
||||
<img src="/logo.svg" alt="Fuware Logo" className="h-8" />
|
||||
Fuware
|
||||
</Link>
|
||||
</h1>
|
||||
</SidebarMenuButton>
|
||||
<h1 className="text-xl font-semibold">
|
||||
<Link to="/" className="flex items-center gap-2" title="Fuware">
|
||||
<img src="/logo.svg" alt="Fuware Logo" className="h-8" />
|
||||
Fuware
|
||||
</Link>
|
||||
</h1>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
@@ -35,7 +32,7 @@ const AppSidebar = ({ ...props }: React.ComponentProps<typeof Sidebar>) => {
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default AppSidebar
|
||||
export default AppSidebar;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { GaugeIcon, GearIcon, HouseIcon } from '@phosphor-icons/react';
|
||||
import {
|
||||
CircuitryIcon,
|
||||
GaugeIcon,
|
||||
GearIcon,
|
||||
HouseIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { createLink } from '@tanstack/react-router';
|
||||
import AdminShow from '../auth/AdminShow';
|
||||
import AuthShow from '../auth/AuthShow';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
@@ -12,42 +19,84 @@ import {
|
||||
|
||||
const SidebarMenuButtonLink = createLink(SidebarMenuButton);
|
||||
|
||||
const NAV_MAIN = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Basic',
|
||||
items: [
|
||||
{
|
||||
title: m.nav_home(),
|
||||
path: '/',
|
||||
icon: HouseIcon,
|
||||
isAuth: true,
|
||||
admin: false,
|
||||
},
|
||||
{
|
||||
title: m.nav_dashboard(),
|
||||
path: '/dashboard',
|
||||
icon: GaugeIcon,
|
||||
isAuth: true,
|
||||
admin: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Management',
|
||||
items: [
|
||||
{
|
||||
title: m.nav_log(),
|
||||
path: '/logs',
|
||||
icon: CircuitryIcon,
|
||||
isAuth: false,
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
title: m.nav_settings(),
|
||||
path: '/settings',
|
||||
icon: GearIcon,
|
||||
isAuth: false,
|
||||
admin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const NavMain = () => {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButtonLink
|
||||
to="/"
|
||||
className="cursor-pointer"
|
||||
tooltip={m.nav_home()}
|
||||
>
|
||||
<HouseIcon size={24} />
|
||||
{m.nav_home()}
|
||||
</SidebarMenuButtonLink>
|
||||
<AuthShow>
|
||||
<SidebarMenuButtonLink
|
||||
to="/dashboard"
|
||||
className="cursor-pointer"
|
||||
tooltip={m.nav_dashboard()}
|
||||
>
|
||||
<GaugeIcon size={24} />
|
||||
{m.nav_dashboard()}
|
||||
</SidebarMenuButtonLink>
|
||||
<AdminShow>
|
||||
<SidebarMenuButtonLink
|
||||
to="/settings"
|
||||
className="cursor-pointer"
|
||||
tooltip={m.nav_settings()}
|
||||
>
|
||||
<GearIcon size={24} />
|
||||
{m.nav_settings()}
|
||||
</SidebarMenuButtonLink>
|
||||
</AdminShow>
|
||||
</AuthShow>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
<>
|
||||
{NAV_MAIN.map((nav) => (
|
||||
<SidebarGroup key={nav.id}>
|
||||
<SidebarGroupLabel>{nav.title}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{nav.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const Menu = (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButtonLink
|
||||
to={item.path}
|
||||
className="cursor-pointer"
|
||||
tooltip={item.title}
|
||||
>
|
||||
<Icon size={24} />
|
||||
{item.title}
|
||||
</SidebarMenuButtonLink>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
return item.isAuth ? (
|
||||
<AuthShow key={item.path}>{Menu}</AuthShow>
|
||||
) : item.admin ? (
|
||||
<AdminShow key={item.path}>{Menu}</AdminShow>
|
||||
) : (
|
||||
Menu
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { createLink, Link, useNavigate } from '@tanstack/react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '../auth/auth-provider';
|
||||
import AvatarUser from '../avatar/AvatarUser';
|
||||
import RoleBadge from '../avatar/RoleBadge';
|
||||
import AvatarUser from '../avatar/avatar-user';
|
||||
import RoleBadge from '../avatar/role-badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
||||
@@ -18,6 +18,7 @@ const badgeVariants = cva(
|
||||
'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground bg-input/20 dark:bg-input/30',
|
||||
ghost:
|
||||
'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||
table: 'border-border border-teal-600 text-teal-600',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
admin: 'bg-cyan-100 text-cyan-600 [a]:hover:bg-cyan-200 ',
|
||||
user: 'bg-green-100 text-green-600 [a]:hover:bg-green-200',
|
||||
|
||||
83
src/components/ui/button-group.tsx
Normal file
83
src/components/ui/button-group.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted gap-2 rounded-md border px-2.5 text-xs/relaxed font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative self-stretch data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Slot } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 disabled:data-[active=true]:opacity-100 disabled:data-[active=true]:bg-teal-400 disabled:data-[active=true]:border-teal-400 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -37,7 +37,7 @@ const buttonVariants = cva(
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
@@ -47,9 +47,9 @@ function Button({
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : 'button'
|
||||
const Comp = asChild ? Slot.Root : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -59,7 +59,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
153
src/components/ui/dialog.tsx
Normal file
153
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "@phosphor-icons/react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-xs/relaxed ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("gap-1 flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-sm font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed *:[a]:underline *:[a]:underline-offset-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
145
src/components/ui/input-group.tsx
Normal file
145
src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"border-input bg-input/20 dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 h-7 rounded-md border transition-colors has-data-[align=block-end]:rounded-md has-data-[align=block-start]:rounded-md has-[[data-slot=input-group-control]:focus-visible]:ring-[2px] has-[[data-slot][aria-invalid=true]]:ring-[2px] has-[textarea]:rounded-md has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground **:data-[slot=kbd]:bg-muted-foreground/10 h-auto gap-1 py-2 text-xs/relaxed font-medium group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-[calc(var(--radius-sm)-2px)] **:data-[slot=kbd]:px-1 **:data-[slot=kbd]:text-[0.625rem] [&>svg:not([class*='size-'])]:size-3.5 flex cursor-text items-center justify-center select-none",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start": "pl-2 has-[>button]:ml-[-0.275rem] has-[>kbd]:ml-[-0.275rem] order-first",
|
||||
"inline-end": "pr-2 has-[>button]:mr-[-0.275rem] has-[>kbd]:mr-[-0.275rem] order-last",
|
||||
"block-start":
|
||||
"px-2 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
|
||||
"block-end":
|
||||
"px-2 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"gap-2 rounded-md text-xs/relaxed shadow-none flex items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-5 gap-1 rounded-[calc(var(--radius-sm)-2px)] px-1 [&>svg:not([class*='size-'])]:size-3",
|
||||
sm: "",
|
||||
"icon-xs": "size-6 p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground gap-2 text-xs/relaxed [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn("rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent flex-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn("rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent flex-1 resize-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import * as React from 'react'
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 disabled:border-none disabled:p-0 disabled:bg-transparent disabled:text-black',
|
||||
className,
|
||||
"bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -259,6 +259,7 @@ function SidebarTrigger({
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
|
||||
99
src/components/ui/table.tsx
Normal file
99
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn("text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -7,12 +7,12 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-input bg-input/20 dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 resize-none rounded-md border px-2 py-2 text-sm transition-colors focus-visible:ring-2 aria-invalid:ring-2 md:text-xs/relaxed placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
"border-input bg-input/20 dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 resize-none rounded-md border px-2 py-2 text-sm transition-colors focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
||||
@@ -52,10 +52,10 @@ function TooltipContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground z-50" />
|
||||
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground z-50" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
|
||||
17
src/hooks/use-debounced.ts
Normal file
17
src/hooks/use-debounced.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function useDebounced<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(handler); // clear timeout nếu value thay đổi trước khi delay hết
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export default useDebounced;
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
owner,
|
||||
} from '@/lib/auth/organization-permissions';
|
||||
import { ac, admin, user } from '@/lib/auth/permissions';
|
||||
import { createAuditLog } from '@/service/audit.api';
|
||||
import { createAuditLog } from '@/service/repository';
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
||||
import { admin as adminPlugin, organization } from 'better-auth/plugins';
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
|
||||
import { Route as appauthRouteRouteImport } from './routes/(app)/(auth)/route'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api.auth.$'
|
||||
import { Route as appauthSettingsRouteImport } from './routes/(app)/(auth)/settings'
|
||||
import { Route as appauthLogsRouteImport } from './routes/(app)/(auth)/logs'
|
||||
import { Route as appauthDashboardRouteImport } from './routes/(app)/(auth)/dashboard'
|
||||
import { Route as appauthAccountRouteRouteImport } from './routes/(app)/(auth)/account/route'
|
||||
import { Route as appauthAccountIndexRouteImport } from './routes/(app)/(auth)/account/index'
|
||||
@@ -56,6 +57,11 @@ const appauthSettingsRoute = appauthSettingsRouteImport.update({
|
||||
path: '/settings',
|
||||
getParentRoute: () => appauthRouteRoute,
|
||||
} as any)
|
||||
const appauthLogsRoute = appauthLogsRouteImport.update({
|
||||
id: '/logs',
|
||||
path: '/logs',
|
||||
getParentRoute: () => appauthRouteRoute,
|
||||
} as any)
|
||||
const appauthDashboardRoute = appauthDashboardRouteImport.update({
|
||||
id: '/dashboard',
|
||||
path: '/dashboard',
|
||||
@@ -94,6 +100,7 @@ export interface FileRoutesByFullPath {
|
||||
'/': typeof appIndexRoute
|
||||
'/account': typeof appauthAccountRouteRouteWithChildren
|
||||
'/dashboard': typeof appauthDashboardRoute
|
||||
'/logs': typeof appauthLogsRoute
|
||||
'/settings': typeof appauthSettingsRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/account/change-password': typeof appauthAccountChangePasswordRoute
|
||||
@@ -106,6 +113,7 @@ export interface FileRoutesByTo {
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
'/': typeof appIndexRoute
|
||||
'/dashboard': typeof appauthDashboardRoute
|
||||
'/logs': typeof appauthLogsRoute
|
||||
'/settings': typeof appauthSettingsRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/account/change-password': typeof appauthAccountChangePasswordRoute
|
||||
@@ -122,6 +130,7 @@ export interface FileRoutesById {
|
||||
'/(app)/': typeof appIndexRoute
|
||||
'/(app)/(auth)/account': typeof appauthAccountRouteRouteWithChildren
|
||||
'/(app)/(auth)/dashboard': typeof appauthDashboardRoute
|
||||
'/(app)/(auth)/logs': typeof appauthLogsRoute
|
||||
'/(app)/(auth)/settings': typeof appauthSettingsRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/(app)/(auth)/account/change-password': typeof appauthAccountChangePasswordRoute
|
||||
@@ -137,6 +146,7 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/account'
|
||||
| '/dashboard'
|
||||
| '/logs'
|
||||
| '/settings'
|
||||
| '/api/auth/$'
|
||||
| '/account/change-password'
|
||||
@@ -149,6 +159,7 @@ export interface FileRouteTypes {
|
||||
| '/sign-up'
|
||||
| '/'
|
||||
| '/dashboard'
|
||||
| '/logs'
|
||||
| '/settings'
|
||||
| '/api/auth/$'
|
||||
| '/account/change-password'
|
||||
@@ -164,6 +175,7 @@ export interface FileRouteTypes {
|
||||
| '/(app)/'
|
||||
| '/(app)/(auth)/account'
|
||||
| '/(app)/(auth)/dashboard'
|
||||
| '/(app)/(auth)/logs'
|
||||
| '/(app)/(auth)/settings'
|
||||
| '/api/auth/$'
|
||||
| '/(app)/(auth)/account/change-password'
|
||||
@@ -230,6 +242,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof appauthSettingsRouteImport
|
||||
parentRoute: typeof appauthRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/logs': {
|
||||
id: '/(app)/(auth)/logs'
|
||||
path: '/logs'
|
||||
fullPath: '/logs'
|
||||
preLoaderRoute: typeof appauthLogsRouteImport
|
||||
parentRoute: typeof appauthRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/dashboard': {
|
||||
id: '/(app)/(auth)/dashboard'
|
||||
path: '/dashboard'
|
||||
@@ -295,12 +314,14 @@ const appauthAccountRouteRouteWithChildren =
|
||||
interface appauthRouteRouteChildren {
|
||||
appauthAccountRouteRoute: typeof appauthAccountRouteRouteWithChildren
|
||||
appauthDashboardRoute: typeof appauthDashboardRoute
|
||||
appauthLogsRoute: typeof appauthLogsRoute
|
||||
appauthSettingsRoute: typeof appauthSettingsRoute
|
||||
}
|
||||
|
||||
const appauthRouteRouteChildren: appauthRouteRouteChildren = {
|
||||
appauthAccountRouteRoute: appauthAccountRouteRouteWithChildren,
|
||||
appauthDashboardRoute: appauthDashboardRoute,
|
||||
appauthLogsRoute: appauthLogsRoute,
|
||||
appauthSettingsRoute: appauthSettingsRoute,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query
|
||||
import * as TanstackQuery from './integrations/tanstack-query/root-provider'
|
||||
|
||||
// Import the generated route tree
|
||||
import { BreadcrumbValue } from './components/sidebar/RouterBreadcrumb'
|
||||
import { BreadcrumbValue } from './components/sidebar/router-breadcrumb';
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
||||
@@ -7,5 +7,11 @@ export const Route = createFileRoute('/(app)/(auth)/dashboard')({
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "dashboard"!</div>;
|
||||
return (
|
||||
<div className="@container/main flex flex-1 flex-col gap-2 p-4">
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-linear-to-br *:data-[slot=card]:shadow-xs grid grid-cols-1 @xl/main:grid-cols-2 @5xl/main:grid-cols-3 gap-4">
|
||||
Hello Dashboard!
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
110
src/routes/(app)/(auth)/logs.tsx
Normal file
110
src/routes/(app)/(auth)/logs.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { logColumns } from '@/components/audit/audit-columns';
|
||||
import DataTable from '@/components/DataTable';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@/components/ui/input-group';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import useDebounced from '@/hooks/use-debounced';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { auditQueries } from '@/service/queries';
|
||||
import {
|
||||
CircuitryIcon,
|
||||
MagnifyingGlassIcon,
|
||||
XIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/logs')({
|
||||
component: RouteComponent,
|
||||
staticData: { breadcrumb: () => m.nav_log() },
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageLimit, setPageLimit] = useState(10);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const debouncedSearch = useDebounced(searchKeyword, 500);
|
||||
const { data, isLoading } = useQuery(
|
||||
auditQueries.list({
|
||||
page: page,
|
||||
limit: pageLimit,
|
||||
keyword: debouncedSearch,
|
||||
}),
|
||||
);
|
||||
|
||||
const onSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchKeyword(e.target.value);
|
||||
setPage(1);
|
||||
};
|
||||
const onClearSearch = () => {
|
||||
setSearchKeyword('');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="@container/main flex flex-1 flex-col gap-2 p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-130 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="@container/main flex flex-1 flex-col gap-2 p-4">
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-linear-to-br *:data-[slot=card]:shadow-xs flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<CircuitryIcon size={24} />
|
||||
{m.logs_page_ui_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<div className="flex">
|
||||
<InputGroup className="w-70">
|
||||
<InputGroupInput
|
||||
id="keywords"
|
||||
placeholder="Search...."
|
||||
value={searchKeyword}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<MagnifyingGlassIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{searchKeyword !== '' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full"
|
||||
onClick={onClearSearch}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
{data && (
|
||||
<DataTable
|
||||
data={data.result || []}
|
||||
columns={logColumns}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
limit={pageLimit}
|
||||
setLimit={setPageLimit}
|
||||
pagination={data.pagination}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)')({
|
||||
// beforeLoad: async ({ context }) => {
|
||||
// if (!context.userSession) {
|
||||
// throw redirect({ to: '/sign-in' });
|
||||
// }
|
||||
// },
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (!context.session) {
|
||||
throw redirect({ to: '/sign-in' });
|
||||
}
|
||||
},
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,5 +7,11 @@ export const Route = createFileRoute('/(app)/')({
|
||||
});
|
||||
|
||||
function App() {
|
||||
return <div className="min-h-screen bg-linear-to-b ">Home</div>;
|
||||
return (
|
||||
<div className="@container/main flex flex-1 flex-col gap-2 p-4">
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-linear-to-br *:data-[slot=card]:shadow-xs grid grid-cols-1 @xl/main:grid-cols-2 @5xl/main:grid-cols-3 gap-4">
|
||||
Home
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import NotFound from '@/components/NotFound';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { getLocale } from '@/paraglide/runtime';
|
||||
import { sessionQueries } from '@/service/queries';
|
||||
import {
|
||||
CheckIcon,
|
||||
InfoIcon,
|
||||
WarningIcon,
|
||||
WarningOctagonIcon,
|
||||
XCircleIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { TanStackDevtools } from '@tanstack/react-devtools';
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
@@ -21,12 +22,12 @@ import appCss from '../styles.css?url';
|
||||
|
||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
|
||||
{
|
||||
// beforeLoad: async ({ context }) => {
|
||||
// const userSession = await context.queryClient.fetchQuery(
|
||||
// sessionQueries.user(),
|
||||
// );
|
||||
// return { userSession };
|
||||
// },
|
||||
beforeLoad: async ({ context }) => {
|
||||
const session = await context.queryClient.fetchQuery(
|
||||
sessionQueries.user(),
|
||||
);
|
||||
return { session };
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
@@ -64,32 +65,31 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
<body>
|
||||
{children}
|
||||
<Toaster
|
||||
// richColors
|
||||
visibleToasts={5}
|
||||
position={'top-right'}
|
||||
offset={{ top: 60, right: 10 }}
|
||||
closeButton={true}
|
||||
icons={{
|
||||
success: <CheckIcon className="text-green-500" size={16} />,
|
||||
error: <WarningOctagonIcon className="text-red-500" size={16} />,
|
||||
error: <XCircleIcon className="text-red-500" size={16} />,
|
||||
info: <InfoIcon className="text-blue-500" size={16} />,
|
||||
warning: <WarningIcon className="text-yellow-500" size={16} />,
|
||||
}}
|
||||
/>
|
||||
{/* <React.Suspense> */}
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: 'bottom-right',
|
||||
}}
|
||||
plugins={[
|
||||
{
|
||||
name: 'Tanstack Router',
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
TanStackQueryDevtools,
|
||||
]}
|
||||
/>
|
||||
{/* </React.Suspense> */}
|
||||
<React.Suspense>
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: 'bottom-right',
|
||||
}}
|
||||
plugins={[
|
||||
{
|
||||
name: 'Tanstack Router',
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
TanStackQueryDevtools,
|
||||
]}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,15 +1,68 @@
|
||||
import { prisma } from '@/db';
|
||||
import { Audit } from '@/generated/prisma/client';
|
||||
import { AuditModel, AuditWhereInput } from '@/generated/prisma/models';
|
||||
import { authMiddleware } from '@/lib/middleware';
|
||||
import { createServerFn } from '@tanstack/react-start';
|
||||
import { auditListSchema } from './audit.schema';
|
||||
|
||||
export async function createAuditLog(data: Omit<Audit, 'id' | 'createdAt'>) {
|
||||
try {
|
||||
await prisma.audit.create({
|
||||
data: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export const getAllAudit = createServerFn({ method: 'GET' })
|
||||
.middleware([authMiddleware])
|
||||
.inputValidator(auditListSchema)
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
const skip = (data.page - 1) * data.limit;
|
||||
|
||||
const where: AuditWhereInput = {
|
||||
OR: [
|
||||
{
|
||||
oldValue: {
|
||||
contains: data.keyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
newValue: {
|
||||
contains: data.keyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: {
|
||||
contains: data.keyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const [auditlog, total]: [AuditModel[], number] = await Promise.all([
|
||||
await prisma.audit.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: data.limit,
|
||||
skip,
|
||||
}),
|
||||
await prisma.audit.count({ where }),
|
||||
]);
|
||||
|
||||
const totalPage = Math.ceil(+total / data.limit);
|
||||
|
||||
return {
|
||||
result: auditlog,
|
||||
pagination: {
|
||||
currentPage: data.page,
|
||||
totalPage,
|
||||
totalItem: total,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
9
src/service/audit.schema.ts
Normal file
9
src/service/audit.schema.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import z from 'zod';
|
||||
|
||||
export const auditListSchema = z.object({
|
||||
page: z.coerce.number().min(1).default(1),
|
||||
limit: z.coerce.number().min(10).max(100).default(10),
|
||||
keyword: z.string().optional(),
|
||||
});
|
||||
|
||||
export const auditSchema = z.object({});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getSession } from '@/lib/auth/session';
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getAllAudit } from './audit.api';
|
||||
import { getAdminSettings, getUserSettings } from './setting.api';
|
||||
|
||||
export const sessionQueries = {
|
||||
@@ -26,3 +27,12 @@ export const settingQueries = {
|
||||
queryFn: () => getUserSettings(),
|
||||
}),
|
||||
};
|
||||
|
||||
export const auditQueries = {
|
||||
all: ['audit'],
|
||||
list: (params: { page: number; limit: number; keyword?: string }) =>
|
||||
queryOptions({
|
||||
queryKey: [...auditQueries.all, 'list', params],
|
||||
queryFn: () => getAllAudit({ data: params }),
|
||||
}),
|
||||
};
|
||||
|
||||
15
src/service/repository.ts
Normal file
15
src/service/repository.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { prisma } from '@/db';
|
||||
import { Audit } from '@/generated/prisma/client';
|
||||
|
||||
export const createAuditLog = async (data: Omit<Audit, 'id' | 'createdAt'>) => {
|
||||
try {
|
||||
await prisma.audit.create({
|
||||
data: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { Setting } from '@/generated/prisma/client';
|
||||
import { authMiddleware } from '@/lib/middleware';
|
||||
import { extractDiffObjects } from '@/utils/help';
|
||||
import { createServerFn } from '@tanstack/react-start';
|
||||
import { createAuditLog } from './audit.api';
|
||||
import { createAuditLog } from './repository';
|
||||
import { settingSchema, userSettingSchema } from './setting.schema';
|
||||
|
||||
type AdminSettingReturn = {
|
||||
|
||||
7
src/types/db.d.ts
vendored
Normal file
7
src/types/db.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Audit, User } from '@prisma/client';
|
||||
|
||||
declare global {
|
||||
type AuditLog = Audit & {
|
||||
user: User;
|
||||
};
|
||||
}
|
||||
7
src/types/table.d.ts
vendored
Normal file
7
src/types/table.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import '@tanstack/react-table';
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
interface ColumnMeta<TData, TValue> {
|
||||
thClass?: string;
|
||||
}
|
||||
}
|
||||
20
src/utils/formatters.ts
Normal file
20
src/utils/formatters.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { getLocale } from '@/paraglide/runtime';
|
||||
|
||||
const locale = getLocale() === 'vi' ? 'vi-VN' : 'en-US';
|
||||
|
||||
export const formatters = {
|
||||
dateTime: (v: string | Date) => {
|
||||
const parts = new Intl.DateTimeFormat(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
}).formatToParts(new Date(v));
|
||||
|
||||
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
||||
|
||||
return `${map.day}/${map.month}/${map.year} ${map.hour}:${map.minute} ${map.hour12 ? 'AM' : 'PM'}`;
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,9 @@ import { defineConfig } from 'vite';
|
||||
import viteTsConfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
const config = defineConfig({
|
||||
// server: {
|
||||
// host: true,
|
||||
// },
|
||||
plugins: [
|
||||
paraglideVitePlugin({
|
||||
project: './project.inlang',
|
||||
|
||||
Reference in New Issue
Block a user