feature/houses #11
@@ -5,14 +5,14 @@
|
||||
"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"
|
||||
"countPlural=one": "Currently there is only 1 data",
|
||||
"countPlural=other": "Display {start} to {end} of {count} data"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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_no_list": "Currently there is no data!",
|
||||
"common_is_required": "{field} is required.",
|
||||
"role_tags": [
|
||||
{
|
||||
@@ -49,6 +49,7 @@
|
||||
"ui_delete_btn": "Delete",
|
||||
"ui_ban_btn": "Lock",
|
||||
"ui_unban_btn": "Unlock",
|
||||
"ui_invite_btn": "Invite",
|
||||
"ui_update_password_btn": "Set password",
|
||||
"ui_change_role_btn": "Set role",
|
||||
"ui_edit_user_btn": "Edit User",
|
||||
@@ -56,6 +57,9 @@
|
||||
"ui_view_all_notifications": "View All Notifications",
|
||||
"ui_label_notifications": "Notifications",
|
||||
"ui_change_password_btn": "Change password",
|
||||
"nav_label_management": "Management",
|
||||
"nav_label_basic": "Basic",
|
||||
"nav_label_kanri": "Administrator",
|
||||
"nav_home": "Home",
|
||||
"nav_dashboard": "Dashboard",
|
||||
"nav_settings": "Settings",
|
||||
@@ -63,8 +67,8 @@
|
||||
"nav_edit": "Edit",
|
||||
"nav_change_password": "Change password",
|
||||
"nav_logs": "Logs",
|
||||
"nav_users": "Người dùng",
|
||||
"nav_roles": "Vai trò & quyền hạn",
|
||||
"nav_users": "Users",
|
||||
"nav_houses": "Houses",
|
||||
"nav_box": "Box",
|
||||
"nav_account": "Account",
|
||||
"nav_profile": "Profile",
|
||||
@@ -145,16 +149,55 @@
|
||||
"users_page_ui_select_placeholder_role": "Select role",
|
||||
"users_page_ui_select_placeholder_ban_exp": "Select time",
|
||||
"users_page_ui_dialog_alert_title": "Unban this user?",
|
||||
"users_page_ui_dialog_alert_ban_title": "",
|
||||
"users_page_ui_dialog_alert_description": "Detail: \nName: {name}. \nEmail: {email}",
|
||||
"users_page_ui_dialog_alert_description_2": "Reason: {reason}. \nExpiration: {exp}",
|
||||
"users_page_ui_dialog_alert_ban_title": "Lock this user?",
|
||||
"users_page_ui_dialog_alert_description_title": "Detail",
|
||||
"houses_page_ui_title": "Houses",
|
||||
"houses_page_ui_table_header_name": "Name",
|
||||
"houses_page_ui_table_header_members": "Members",
|
||||
"houses_page_ui_table_header_created_at": "Create date",
|
||||
"houses_page_ui_view_label_count": "Quantity",
|
||||
"houses_page_ui_view_table_header_email": "Email",
|
||||
"houses_page_ui_view_table_header_role": "Role",
|
||||
"houses_page_form_name": "House name",
|
||||
"houses_page_form_user": "User",
|
||||
"houses_page_form_create_for": "Create for",
|
||||
"houses_page_form_color": "Color",
|
||||
"houses_page_form_user_select_placeholder": "Select user",
|
||||
"houses_page_form_user_select_search_placeholder": "Search by name or email...",
|
||||
"houses_page_ui_dialog_alert_delete_title": "Delete house: {name}?",
|
||||
"houses_page_ui_dialog_alert_delete_description": "This action cannot be undone! It will delete all related data like: <b>Box</b>, <b>Item</b>. Please think carefully!",
|
||||
"houses_page_message_create_house_success": "Created house successfully!",
|
||||
"houses_page_message_house_not_found": "House not found!",
|
||||
"houses_page_message_update_house_success": "Updated house successfully!",
|
||||
"houses_page_message_delete_house_success": "Delete house successfully!",
|
||||
"houses_page_house_active_btn": "Active",
|
||||
"houses_user_page_message_active_house_success": "Active \"<b>{house}</b>\" successfully!",
|
||||
"houses_user_page_block_action_title": "Action",
|
||||
"houses_user_page_action_invite_user": "Invite member",
|
||||
"houses_user_page_invite_label_to": "To",
|
||||
"houses_user_page_invite_label_status": "Status",
|
||||
"invite_status": [
|
||||
{
|
||||
"match": {
|
||||
"status=pending": "Pending",
|
||||
"status=accept": "Accept",
|
||||
"status=reject": "Reject",
|
||||
"status=expired": "Expired",
|
||||
"status=canceled": "Cancel"
|
||||
}
|
||||
}
|
||||
],
|
||||
"backend_message": [
|
||||
{
|
||||
"match": {
|
||||
"code=INVALID_EMAIL_OR_PASSWORD": "Email or password incorrect!",
|
||||
"code=INVALID_PASSWORD": "Password incorrect!",
|
||||
"code=YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "You are not allowed to change users role",
|
||||
"code=USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email already exists. Please choose another email!"
|
||||
"code=USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email already exists. Please choose another email!",
|
||||
"code=BANNED_USER": "Your account get banned, please contact administrator for more information!",
|
||||
"code=VALIDATION_ERROR": "Some field value invalid!",
|
||||
"code=USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION": "User is not a member of the house",
|
||||
"code=USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION": "This member has already been invited, waiting for the member to join!"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -49,13 +49,18 @@
|
||||
"ui_delete_btn": "Xóa",
|
||||
"ui_ban_btn": "Khóa",
|
||||
"ui_unban_btn": "Mở khóa",
|
||||
"ui_invite_btn": "Mời",
|
||||
"ui_update_password_btn": "Đặt lại mật khẩu",
|
||||
"ui_change_role_btn": "Đặt lại quyền hạn",
|
||||
"ui_edit_user_btn": "Chỉnh sửa người dùng",
|
||||
"ui_dialog_view_title": "Xem chi tiết {type}",
|
||||
"ui_dialog_view_title": "{type}: Xem chi tiết thông tin",
|
||||
"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",
|
||||
"ui_edit_house_btn": "Chỉnh sửa nhà",
|
||||
"nav_label_management": "Quản lý",
|
||||
"nav_label_basic": "Cơ bản",
|
||||
"nav_label_kanri": "Quản trị viên",
|
||||
"nav_home": "Trang chủ",
|
||||
"nav_dashboard": "Bảng điều khiển",
|
||||
"nav_settings": "Cài đặt",
|
||||
@@ -64,7 +69,7 @@
|
||||
"nav_change_password": "Đổi mật khẩu",
|
||||
"nav_logs": "Lịch sử",
|
||||
"nav_users": "Người dùng",
|
||||
"nav_roles": "Vai trò & quyền hạn",
|
||||
"nav_houses": "Nhà",
|
||||
"nav_box": "Hộp chứa",
|
||||
"nav_account": "Tài khoản",
|
||||
"nav_profile": "Hồ sơ",
|
||||
@@ -146,15 +151,55 @@
|
||||
"users_page_ui_select_placeholder_ban_exp": "Hãy chọn thời gian cấm",
|
||||
"users_page_ui_dialog_alert_title": "Bạn muốn mở khóa người dùng này?",
|
||||
"users_page_ui_dialog_alert_ban_title": "Bạn muốn khóa người dùng này?",
|
||||
"users_page_ui_dialog_alert_description": "Chi tiết: \nTên: {name}. \nEmail: {email}",
|
||||
"users_page_ui_dialog_alert_description_2": "\nLý do: {reason}. \nHiệu lực: {exp}",
|
||||
"users_page_ui_dialog_alert_description_title": "Chi tiết",
|
||||
"houses_page_ui_title": "Nhà",
|
||||
"houses_page_ui_table_header_name": "Tên",
|
||||
"houses_page_ui_table_header_members": "Thành viên",
|
||||
"houses_page_ui_table_header_created_at": "Ngày tạo",
|
||||
"houses_page_ui_view_label_count": "Số lượng",
|
||||
"houses_page_ui_view_table_header_email": "Email",
|
||||
"houses_page_ui_view_table_header_role": "Quyền hạn",
|
||||
"houses_page_ui_view_table_header_invite": "Lời mời đã gởi",
|
||||
"houses_page_form_name": "Tên nhà",
|
||||
"houses_page_form_user": "Người dùng",
|
||||
"houses_page_form_create_for": "Tạo cho",
|
||||
"houses_page_form_color": "Màu sắc",
|
||||
"houses_page_form_user_select_placeholder": "Chọn người dùng",
|
||||
"houses_page_form_user_select_search_placeholder": "Tìm theo tên hoặc email...",
|
||||
"houses_page_ui_dialog_alert_delete_title": "Bạn muốn xóa nhà này: {name}?",
|
||||
"houses_page_ui_dialog_alert_delete_description": "Thao tác này không thể hoàn tác! Nó sẽ xóa hết mọi dữ liệu liên quan như: <b>Hộp chứa</b>, <b>Vật Phẩm</b>. Xin suy tính kỹ lưỡng!",
|
||||
"houses_page_message_create_house_success": "Tạo nhà thành công!",
|
||||
"houses_page_message_house_not_found": "Không tìm thấy nhà này!",
|
||||
"houses_page_message_update_house_success": "Cập nhật nhà thành công!",
|
||||
"houses_page_message_delete_house_success": "Xóa nhà thành công!",
|
||||
"houses_page_house_active_btn": "Kích hoạt",
|
||||
"houses_user_page_message_active_house_success": "Kích hoạt \"<b>{house}</b>\" thành công!",
|
||||
"houses_user_page_block_action_title": "Hành động",
|
||||
"houses_user_page_action_invite_user": "Mời thành viên",
|
||||
"houses_user_page_invite_label_to": "Đến",
|
||||
"houses_user_page_invite_label_status": "Trạng thái",
|
||||
"invite_status": [
|
||||
{
|
||||
"match": {
|
||||
"status=pending": "Đang chờ",
|
||||
"status=accept": "Đồng ý",
|
||||
"status=reject": "Không đồng ý",
|
||||
"status=expired": "Hết hạn",
|
||||
"status=canceled": "Đã hủy"
|
||||
}
|
||||
}
|
||||
],
|
||||
"backend_message": [
|
||||
{
|
||||
"match": {
|
||||
"code=INVALID_EMAIL_OR_PASSWORD": "Email hoặc mật khẩu không đúng!",
|
||||
"code=INVALID_PASSWORD": "Mật khẩu hiện tại không đúng!",
|
||||
"code=YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "Bạn không đủ quyền để chỉnh sửa quyền hạn người dùng!",
|
||||
"code=USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email này đã có người sử dụng. Vui lòng chọn một email khác!"
|
||||
"code=USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email này đã có người sử dụng. Vui lòng chọn một email khác!",
|
||||
"code=BANNED_USER": "Bạn đã bị quản trị viên khóa tài khoản, hãy liên hệ quản trị viên để tìm hiểu thêm!",
|
||||
"code=VALIDATION_ERROR": "Có giá trị không hợp lệ!",
|
||||
"code=USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION": "Người dùng này không phải thành viên nhà này",
|
||||
"code=USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION": "Thành viên này đã được mời rồi, còn đang đợi thành viên đồng ý!"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
25
package.json
25
package.json
@@ -24,7 +24,7 @@
|
||||
"@prisma/adapter-pg": "^7.1.0",
|
||||
"@prisma/client": "^7.1.0",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-devtools": "^0.7.0",
|
||||
"@tanstack/react-devtools": "^0.9.4",
|
||||
"@tanstack/react-form": "^1.27.7",
|
||||
"@tanstack/react-query": "^5.66.5",
|
||||
"@tanstack/react-query-devtools": "^5.84.2",
|
||||
@@ -37,36 +37,37 @@
|
||||
"better-auth": "^1.4.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"html-react-parser": "^5.2.16",
|
||||
"next-themes": "^0.4.6",
|
||||
"prisma": "^7.1.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"shadcn": "^3.6.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"zod": "^4.1.11"
|
||||
"vite-tsconfig-paths": "^6.0.5",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inlang/paraglide-js": "2.7.1",
|
||||
"@tanstack/devtools-vite": "^0.3.11",
|
||||
"@inlang/paraglide-js": "2.10.0",
|
||||
"@tanstack/devtools-vite": "^0.5.0",
|
||||
"@tanstack/eslint-config": "^0.3.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"dotenv-cli": "^10.0.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^3.0.5",
|
||||
"vitest": "^4.0.18",
|
||||
"web-vitals": "^5.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
3768
pnpm-lock.yaml
generated
3768
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { auth } from '@lib/auth';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js';
|
||||
import { settingsData } from './data.js';
|
||||
|
||||
20
project.inlang/.gitignore
vendored
20
project.inlang/.gitignore
vendored
@@ -1 +1,19 @@
|
||||
cache
|
||||
# IF GIT SHOWED THAT THIS FILE CHANGED
|
||||
#
|
||||
# 1. RUN THE FOLLOWING COMMAND
|
||||
#
|
||||
# ---
|
||||
# git rm --cached '**/*.inlang/.gitignore'
|
||||
# ---
|
||||
#
|
||||
# 2. COMMIT THE CHANGE
|
||||
#
|
||||
# ---
|
||||
# git commit -m "fix: remove tracked .gitignore from inlang project"
|
||||
# ---
|
||||
#
|
||||
# Inlang handles the gitignore itself starting with version ^2.5.
|
||||
#
|
||||
# everything is ignored except settings.json
|
||||
*
|
||||
!settings.json
|
||||
@@ -1,20 +1,19 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { m } from '@/paraglide/messages';
|
||||
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 { Label } from '@ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './ui/select';
|
||||
} from '@ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -22,7 +21,8 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from './ui/table';
|
||||
} from '@ui/table';
|
||||
import Pagination from './Pagination';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -56,7 +56,7 @@ const DataTable = <TData, TValue>({
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<Table className="bg-white">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
@@ -66,7 +66,7 @@ const DataTable = <TData, TValue>({
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={cn(
|
||||
'px-4',
|
||||
'px-4 bg-primary text-white text-sm',
|
||||
header.column.columnDef.meta?.thClass,
|
||||
)}
|
||||
>
|
||||
@@ -112,8 +112,8 @@ const DataTable = <TData, TValue>({
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="hidden text-sm gap-2 xl:flex">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="hidden text-sm gap-2 lg:flex">
|
||||
<span>
|
||||
{m.common_page_show({
|
||||
count: pagination.totalItem,
|
||||
@@ -122,9 +122,12 @@ const DataTable = <TData, TValue>({
|
||||
})}
|
||||
</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">
|
||||
<div className="flex max-w-full items-center gap-2 justify-between w-full lg:w-fit lg:gap-8">
|
||||
<div className="items-center gap-2 flex">
|
||||
<Label
|
||||
htmlFor="rows-per-page"
|
||||
className="hidden text-sm font-medium lg:block"
|
||||
>
|
||||
{m.common_per_page()}
|
||||
</Label>
|
||||
<Select
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { Separator } from '@base-ui/react/separator';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { BellIcon } from '@phosphor-icons/react';
|
||||
import { useAuth } from './auth/auth-provider';
|
||||
import RouterBreadcrumb from './sidebar/router-breadcrumb';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from '@ui/badge';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -13,8 +11,10 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu';
|
||||
import { SidebarTrigger } from './ui/sidebar';
|
||||
} from '@ui/dropdown-menu';
|
||||
import { SidebarTrigger } from '@ui/sidebar';
|
||||
import { useAuth } from './auth/auth-provider';
|
||||
import RouterBreadcrumb from './sidebar/router-breadcrumb';
|
||||
|
||||
export default function Header() {
|
||||
const { data: session } = useAuth();
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
CaretRightIcon,
|
||||
DotsThreeIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { Button } from './ui/button';
|
||||
import { ButtonGroup } from './ui/button-group';
|
||||
import { Button } from '@ui/button';
|
||||
import { ButtonGroup } from '@ui/button-group';
|
||||
|
||||
type PaginationProps = {
|
||||
currentPage: number;
|
||||
@@ -20,18 +20,33 @@ const Pagination = ({
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
|
||||
if (totalPages <= 5) {
|
||||
if (totalPages <= 6) {
|
||||
// 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);
|
||||
pages.push(1, 2, 3, 4, 'dot', totalPages);
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pages.push(1, 'dot', totalPages - 2, totalPages - 1, totalPages);
|
||||
pages.push(
|
||||
1,
|
||||
'dot',
|
||||
totalPages - 3,
|
||||
totalPages - 2,
|
||||
totalPages - 1,
|
||||
totalPages,
|
||||
);
|
||||
} else {
|
||||
pages.push(1, 'dot', currentPage, 'dot', totalPages);
|
||||
pages.push(
|
||||
1,
|
||||
'dot',
|
||||
currentPage - 1,
|
||||
currentPage,
|
||||
currentPage + 1,
|
||||
'dot',
|
||||
totalPages,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +63,7 @@ const Pagination = ({
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => onPageChange(Number(currentPage - 1))}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<CaretLeftIcon />
|
||||
@@ -61,6 +77,7 @@ const Pagination = ({
|
||||
size="icon-sm"
|
||||
key={idx}
|
||||
disabled={true}
|
||||
data-dot={true}
|
||||
>
|
||||
<DotsThreeIcon size={14} />
|
||||
</Button>
|
||||
@@ -84,6 +101,7 @@ const Pagination = ({
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => onPageChange(Number(currentPage + 1))}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<CaretRightIcon />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { LOG_ACTION } from '@/types/enum';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { Badge } from '@ui/badge';
|
||||
|
||||
export type UserActionType = {
|
||||
create: string;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { formatters } from '@/utils/formatters';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Badge } from '@ui/badge';
|
||||
import { formatters } from '@utils/formatters';
|
||||
|
||||
import { LOG_ACTION } from '@/types/enum';
|
||||
import ActionBadge from './action-badge';
|
||||
import ViewDetail from './view-detail-dialog';
|
||||
import ViewDetailAudit from './view-log-detail-dialog';
|
||||
|
||||
export const logColumns: ColumnDef<AuditWithUser>[] = [
|
||||
{
|
||||
@@ -56,7 +56,7 @@ export const logColumns: ColumnDef<AuditWithUser>[] = [
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<ViewDetail data={row.original} />
|
||||
<ViewDetailAudit data={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { LOG_ACTION } from '@/types/enum';
|
||||
import { formatters } from '@/utils/formatters';
|
||||
import { jsonSupport } from '@/utils/helper';
|
||||
import { useCopyToClipboard } from '@hooks/use-copy-to-clipboard';
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { CheckIcon, CopyIcon, EyeIcon } from '@phosphor-icons/react';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '@ui/badge';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,16 +12,18 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog';
|
||||
import { Label } from '../ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
} from '@ui/dialog';
|
||||
import { Label } from '@ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
|
||||
import { formatters } from '@utils/formatters';
|
||||
import { jsonSupport } from '@utils/helper';
|
||||
import ActionBadge from './action-badge';
|
||||
|
||||
type ViewDetailProps = {
|
||||
data: AuditWithUser;
|
||||
};
|
||||
|
||||
const ViewDetail = ({ data }: ViewDetailProps) => {
|
||||
const ViewDetailAudit = ({ data }: ViewDetailProps) => {
|
||||
const prevent = usePreventAutoFocus();
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard();
|
||||
|
||||
@@ -134,4 +134,4 @@ const ViewDetail = ({ data }: ViewDetailProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewDetail;
|
||||
export default ViewDetailAudit;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ClientSession, useSession } from '@/lib/auth-client';
|
||||
import { BetterFetchError } from 'better-auth/client';
|
||||
import { sessionQueries } from '@/service/queries';
|
||||
import { ClientSession } from '@lib/auth-client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createContext, useContext, useMemo } from 'react';
|
||||
|
||||
export type UserContext = {
|
||||
@@ -7,12 +8,13 @@ export type UserContext = {
|
||||
isAuth: boolean;
|
||||
isAdmin: boolean;
|
||||
isPending: boolean;
|
||||
error: BetterFetchError | null;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<UserContext | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, isPending, error } = useSession();
|
||||
const { data: session, isPending, error } = useQuery(sessionQueries.user());
|
||||
|
||||
const contextSession: UserContext = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@ui/avatar';
|
||||
import { useAuth } from '../auth/auth-provider';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
|
||||
import RoleRing from './role-ring';
|
||||
|
||||
interface AvatarUserProps {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { ROLE_NAME } from '@/types/enum';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { Badge, badgeVariants } from '@ui/badge';
|
||||
import { VariantProps } from 'class-variance-authority';
|
||||
import { Badge, badgeVariants } from '../ui/badge';
|
||||
|
||||
type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
|
||||
|
||||
@@ -27,7 +28,7 @@ const RoleBadge = ({ type, className }: RoleProps) => {
|
||||
|
||||
return (
|
||||
<Badge variant={displayVariant} className={className}>
|
||||
{m.role_tags({ role: type as string })}
|
||||
{m.role_tags({ role: type as ROLE_NAME })}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
const RING_TYPE = {
|
||||
admin: 'after:inset-ring-cyan-500',
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useAppForm } from '@/hooks/use-app-form';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import {
|
||||
ChangePassword,
|
||||
ChangePasswordFormSchema,
|
||||
} from '@/service/user.schema';
|
||||
import { useAppForm } from '@hooks/use-app-form';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { KeyIcon } from '@phosphor-icons/react';
|
||||
import { changePassword } from '@service/profile.api';
|
||||
import { ChangePassword, changePasswordFormSchema } from '@service/user.schema';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
|
||||
import { Field, FieldGroup } from '@ui/field';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Field, FieldGroup } from '../ui/field';
|
||||
|
||||
const defaultValues: ChangePassword = {
|
||||
currentPassword: '',
|
||||
@@ -17,37 +15,33 @@ const defaultValues: ChangePassword = {
|
||||
};
|
||||
|
||||
const ChangePasswordForm = () => {
|
||||
const form = useAppForm({
|
||||
defaultValues,
|
||||
validators: {
|
||||
onSubmit: ChangePasswordFormSchema,
|
||||
onChange: ChangePasswordFormSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.changePassword(
|
||||
{
|
||||
newPassword: value.newPassword,
|
||||
currentPassword: value.currentPassword,
|
||||
revokeOtherSessions: true,
|
||||
},
|
||||
{
|
||||
const { mutate: changePasswordMutation, isPending } = useMutation({
|
||||
mutationFn: changePassword,
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
toast.success(
|
||||
m.change_password_messages_change_password_success(),
|
||||
{
|
||||
richColors: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: (ctx) => {
|
||||
console.error(ctx.error.code);
|
||||
toast.error(m.backend_message({ code: ctx.error.code }), {
|
||||
toast.success(m.change_password_messages_change_password_success(), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues,
|
||||
validators: {
|
||||
onSubmit: changePasswordFormSchema,
|
||||
onChange: changePasswordFormSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
changePasswordMutation({ data: value });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -73,6 +67,7 @@ const ChangePasswordForm = () => {
|
||||
{(field) => (
|
||||
<field.TextField
|
||||
label={m.change_password_form_current_password()}
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
@@ -94,7 +89,10 @@ const ChangePasswordForm = () => {
|
||||
</form.AppField>
|
||||
<Field>
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton label={m.ui_change_password_btn()} />
|
||||
<form.SubscribeButton
|
||||
label={m.ui_change_password_btn()}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useAppForm } from '@/hooks/use-app-form';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { uploadProfileImage } from '@/service/profile.api';
|
||||
import { ProfileInput, profileUpdateSchema } from '@/service/profile.schema';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { updateProfile } from '@/service/profile.api';
|
||||
import { useAuth } from '@components/auth/auth-provider';
|
||||
import AvatarUser from '@components/avatar/avatar-user';
|
||||
import RoleBadge from '@components/avatar/role-badge';
|
||||
import { useAppForm } from '@hooks/use-app-form';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { UserCircleIcon } from '@phosphor-icons/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ProfileInput, profileUpdateSchema } from '@service/profile.schema';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
|
||||
import { Field, FieldGroup, FieldLabel } from '@ui/field';
|
||||
import { Input } from '@ui/input';
|
||||
import { useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '../auth/auth-provider';
|
||||
import AvatarUser from '../avatar/avatar-user';
|
||||
import RoleBadge from '../avatar/role-badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Field, FieldGroup, FieldLabel } from '../ui/field';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
const defaultValues: ProfileInput = {
|
||||
name: '',
|
||||
@@ -24,34 +24,8 @@ const ProfileForm = () => {
|
||||
const { data: session, isPending } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
name: session?.user?.name || '',
|
||||
},
|
||||
validators: {
|
||||
onSubmit: profileUpdateSchema,
|
||||
onChange: profileUpdateSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
try {
|
||||
let imageKey;
|
||||
if (value.image) {
|
||||
// upload image
|
||||
const formData = new FormData();
|
||||
formData.set('file', value.image);
|
||||
const { imageKey: uploadedKey } = await uploadProfileImage({
|
||||
data: formData,
|
||||
});
|
||||
imageKey = uploadedKey;
|
||||
}
|
||||
|
||||
await authClient.updateUser(
|
||||
{
|
||||
name: value.name,
|
||||
image: imageKey,
|
||||
},
|
||||
{
|
||||
const { mutate: updateProfileMutation, isPending: isRunning } = useMutation({
|
||||
mutationFn: updateProfile,
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
if (fileInputRef.current) {
|
||||
@@ -64,22 +38,40 @@ const ProfileForm = () => {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
onError: (ctx) => {
|
||||
console.error(ctx.error.code);
|
||||
toast.error(m.backend_message({ code: ctx.error.code }), {
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
name: session?.user?.name || '',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('update load file', error);
|
||||
validators: {
|
||||
onSubmit: profileUpdateSchema,
|
||||
onChange: profileUpdateSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const formData = new FormData();
|
||||
formData.set('name', value.name);
|
||||
if (value.image) {
|
||||
formData.set('file', value.image);
|
||||
}
|
||||
|
||||
updateProfileMutation({ data: formData });
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) return null;
|
||||
if (!session?.user?.name) return null;
|
||||
if (isPending || !session?.user?.name) {
|
||||
return <Skeleton className="h-100 col-span-1" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="@container/card col-span-1">
|
||||
@@ -133,7 +125,10 @@ const ProfileForm = () => {
|
||||
</Field>
|
||||
<Field>
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton label={m.ui_update_btn()} />
|
||||
<form.SubscribeButton
|
||||
label={m.ui_update_btn()}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useAppForm } from '@/hooks/use-app-form';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { Locale, setLocale } from '@/paraglide/runtime';
|
||||
import { settingQueries } from '@/service/queries';
|
||||
import { updateUserSettings } from '@/service/setting.api';
|
||||
import { UserSettingInput, userSettingSchema } from '@/service/setting.schema';
|
||||
import { ReturnError } from '@/types/common';
|
||||
import { useAppForm } from '@hooks/use-app-form';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { Locale, setLocale } from '@paraglide/runtime';
|
||||
import { GearIcon } from '@phosphor-icons/react';
|
||||
import { settingQueries } from '@service/queries';
|
||||
import { updateUserSettings } from '@service/setting.api';
|
||||
import { UserSettingInput, userSettingSchema } from '@service/setting.schema';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
|
||||
import { Field, FieldGroup } from '@ui/field';
|
||||
import { Skeleton } from '@ui/skeleton';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Field, FieldGroup } from '../ui/field';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
const defaultValues: UserSettingInput = {
|
||||
language: '',
|
||||
@@ -22,7 +21,7 @@ const UserSettingsForm = () => {
|
||||
|
||||
const { data, isLoading } = useQuery(settingQueries.listUser());
|
||||
|
||||
const updateMutation = useMutation({
|
||||
const { mutate: updateMutation, isPending } = useMutation({
|
||||
mutationFn: updateUserSettings,
|
||||
onSuccess: (_, variables) => {
|
||||
setLocale(variables.data.language as Locale);
|
||||
@@ -35,7 +34,10 @@ const UserSettingsForm = () => {
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
toast.error(m.backend_message({ code: error.code }), {
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
@@ -48,7 +50,7 @@ const UserSettingsForm = () => {
|
||||
onChange: userSettingSchema,
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
updateMutation.mutate({ data: value as UserSettingInput });
|
||||
updateMutation({ data: value as UserSettingInput });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -99,7 +101,10 @@ const UserSettingsForm = () => {
|
||||
</form.AppField>
|
||||
<Field>
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton label={m.ui_update_btn()} />
|
||||
<form.SubscribeButton
|
||||
label={m.ui_update_btn()}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
@@ -1,27 +1,37 @@
|
||||
import { useFieldContext, useFormContext } from '@/hooks/use-app-form';
|
||||
import { RoleEnum } from '@/service/user.schema';
|
||||
import { useFieldContext, useFormContext } from '@hooks/use-app-form';
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import { Button, buttonVariants } from '@ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@ui/field';
|
||||
import { Input } from '@ui/input';
|
||||
import * as ShadcnSelect from '@ui/select';
|
||||
import { SelectUser as SelectUserUI } from '@ui/select-user';
|
||||
import { Textarea } from '@ui/textarea';
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
import { Button, buttonVariants } from '../ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '../ui/field';
|
||||
import { Input } from '../ui/input';
|
||||
import * as ShadcnSelect from '../ui/select';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
|
||||
export function SubscribeButton({
|
||||
label,
|
||||
variant = 'default',
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
} & VariantProps<typeof buttonVariants>) {
|
||||
const form = useFormContext();
|
||||
return (
|
||||
<form.Subscribe selector={(state) => state.isSubmitting}>
|
||||
{(isSubmitting) => (
|
||||
<Button type="submit" disabled={isSubmitting} variant={variant}>
|
||||
{(isSubmitting) => {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || disabled}
|
||||
variant={variant}
|
||||
>
|
||||
{(isSubmitting || disabled) && <Spinner data-icon="inline-start" />}
|
||||
{label}
|
||||
</Button>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</form.Subscribe>
|
||||
);
|
||||
}
|
||||
@@ -135,12 +145,11 @@ export function Select({
|
||||
label,
|
||||
values,
|
||||
placeholder,
|
||||
isRole = false,
|
||||
// isRole = false,
|
||||
}: {
|
||||
label: string;
|
||||
values: Array<{ label: string; value: string }>;
|
||||
placeholder?: string;
|
||||
isRole?: boolean;
|
||||
}) {
|
||||
const field = useFieldContext<string>();
|
||||
const errors = useStore(field.store, (state) => state.meta.errors);
|
||||
@@ -152,11 +161,7 @@ export function Select({
|
||||
<ShadcnSelect.Select
|
||||
name={field.name}
|
||||
value={String(field.state.value)}
|
||||
onValueChange={(value) =>
|
||||
isRole
|
||||
? field.handleChange(RoleEnum.parse(value))
|
||||
: field.handleChange(value)
|
||||
}
|
||||
onValueChange={(value) => field.handleChange(value)}
|
||||
>
|
||||
<ShadcnSelect.SelectTrigger aria-invalid={isInvalid}>
|
||||
<ShadcnSelect.SelectValue placeholder={placeholder} />
|
||||
@@ -216,3 +221,46 @@ export function SelectNumber({
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectUser({
|
||||
label,
|
||||
values,
|
||||
placeholder,
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
searchPlaceholder = 'Tìm theo tên hoặc email...',
|
||||
selectKey = 'id',
|
||||
}: {
|
||||
label: string;
|
||||
values: Array<{ id: string; name: string; email: string }>;
|
||||
placeholder?: string;
|
||||
/** Khi truyền cùng onKeywordChange: tìm kiếm theo API (keyword gửi lên server) */
|
||||
keyword?: string;
|
||||
onKeywordChange?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
selectKey?: 'id' | 'email';
|
||||
}) {
|
||||
const field = useFieldContext<string>();
|
||||
const errors = useStore(field.store, (state) => state.meta.errors);
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
|
||||
return (
|
||||
<Field data-invalid={isInvalid} className="col-span-2">
|
||||
<FieldLabel htmlFor={field.name}>{label}:</FieldLabel>
|
||||
<SelectUserUI
|
||||
name={field.name}
|
||||
id={field.name}
|
||||
value={field.state.value}
|
||||
onValueChange={(userId) => field.handleChange(userId)}
|
||||
values={values}
|
||||
placeholder={placeholder}
|
||||
keyword={keyword}
|
||||
onKeywordChange={onKeywordChange}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
aria-invalid={isInvalid}
|
||||
selectKey={selectKey}
|
||||
/>
|
||||
{isInvalid && <FieldError errors={errors} />}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
141
src/components/form/house/admin-create-house-form.tsx
Normal file
141
src/components/form/house/admin-create-house-form.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useAuth } from '@/components/auth/auth-provider';
|
||||
import { useAppForm } from '@hooks/use-app-form';
|
||||
import useDebounced from '@hooks/use-debounced';
|
||||
import { authClient } from '@lib/auth-client';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { createHouse } from '@service/house.api';
|
||||
import { houseCreateSchema } from '@service/house.schema';
|
||||
import { housesQueries, usersQueries } from '@service/queries';
|
||||
import { uuid } from '@tanstack/react-form';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@ui/button';
|
||||
import { DialogClose, DialogFooter } from '@ui/dialog';
|
||||
import { Field, FieldGroup } from '@ui/field';
|
||||
import { slugify } from '@utils/helper';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type FormProps = {
|
||||
onSubmit: (open: boolean) => void;
|
||||
isPersonal?: boolean;
|
||||
};
|
||||
|
||||
const CreateNewHouseForm = ({ onSubmit, isPersonal = false }: FormProps) => {
|
||||
const { data: session } = useAuth();
|
||||
const [userKeyword, setUserKeyword] = useState('');
|
||||
const debouncedUserKeyword = useDebounced(userKeyword, 300);
|
||||
const { data: users } = useQuery(
|
||||
usersQueries.select({ keyword: debouncedUserKeyword }),
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const queryKey = isPersonal ? 'currentUser' : 'list';
|
||||
|
||||
const { mutate: createHouseMutation, isPending } = useMutation({
|
||||
mutationFn: createHouse,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...housesQueries.all, queryKey],
|
||||
});
|
||||
onSubmit(false);
|
||||
toast.success(m.houses_page_message_create_house_success(), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
userId: '',
|
||||
color: '#000000',
|
||||
},
|
||||
validators: {
|
||||
onChange: houseCreateSchema,
|
||||
onSubmit: houseCreateSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const slug = `${slugify(value.name)}-${uuid().slice(0, 5)}`;
|
||||
const { data, error } = await authClient.organization.checkSlug({
|
||||
slug,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message, {
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
if (data?.status) {
|
||||
createHouseMutation({ data: { ...value, slug } });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPersonal) {
|
||||
form.setFieldValue('userId', session.user.id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
id="admin-create-house-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<form.AppField name="name">
|
||||
{(field) => <field.TextField label={m.houses_page_form_name()} />}
|
||||
</form.AppField>
|
||||
<form.AppField name="color">
|
||||
{(field) => (
|
||||
<field.TextField type="color" label={m.houses_page_form_color()} />
|
||||
)}
|
||||
</form.AppField>
|
||||
{!isPersonal && (
|
||||
<form.AppField name="userId">
|
||||
{(field) => (
|
||||
<field.SelectUser
|
||||
label={m.houses_page_form_create_for()}
|
||||
values={users ?? []}
|
||||
placeholder={m.houses_page_form_user_select_placeholder()}
|
||||
searchPlaceholder={m.houses_page_form_user_select_search_placeholder()}
|
||||
keyword={userKeyword}
|
||||
onKeywordChange={setUserKeyword}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
)}
|
||||
<Field>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{m.ui_cancel_btn()}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton
|
||||
label={m.ui_confirm_btn()}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</DialogFooter>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateNewHouseForm;
|
||||
121
src/components/form/house/admin-edit-house-form.tsx
Normal file
121
src/components/form/house/admin-edit-house-form.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useAppForm } from '@hooks/use-app-form';
|
||||
import { authClient } from '@lib/auth-client';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { updateHouse } from '@service/house.api';
|
||||
import { houseEditSchema } from '@service/house.schema';
|
||||
import { housesQueries } from '@service/queries';
|
||||
import { uuid } from '@tanstack/react-form';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@ui/button';
|
||||
import { DialogClose, DialogFooter } from '@ui/dialog';
|
||||
import { Field, FieldGroup } from '@ui/field';
|
||||
import { slugify } from '@utils/helper';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type EditHouseFormProps = {
|
||||
data: HouseWithMembers;
|
||||
onSubmit: (open: boolean) => void;
|
||||
mutateKey: string;
|
||||
};
|
||||
|
||||
const EditHouseForm = ({ data, onSubmit, mutateKey }: EditHouseFormProps) => {
|
||||
const { refetch } = authClient.useActiveOrganization();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: updateHouseMutation, isPending } = useMutation({
|
||||
mutationFn: updateHouse,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...housesQueries.all, mutateKey],
|
||||
});
|
||||
onSubmit(false);
|
||||
refetch();
|
||||
toast.success(m.houses_page_message_update_house_success(), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
color: data.color || '#000000',
|
||||
},
|
||||
validators: {
|
||||
onChange: houseEditSchema,
|
||||
onSubmit: houseEditSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const slug = `${slugify(value.name)}-${uuid().slice(0, 5)}`;
|
||||
const { data, error } = await authClient.organization.checkSlug({
|
||||
slug,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message, {
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
if (data?.status) {
|
||||
updateHouseMutation({ data: { ...value, slug } });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
id="admin-create-house-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<form.AppField name="name">
|
||||
{(field) => <field.TextField label={m.houses_page_form_name()} />}
|
||||
</form.AppField>
|
||||
<form.AppField name="color">
|
||||
{(field) => (
|
||||
<field.TextField type="color" label={m.houses_page_form_color()} />
|
||||
)}
|
||||
</form.AppField>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="font-medium">{m.houses_page_form_user()}:</span>
|
||||
<span>
|
||||
{
|
||||
data.members.filter((member) => member.role === 'owner')[0].user
|
||||
.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<Field>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{m.ui_cancel_btn()}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton
|
||||
label={m.ui_confirm_btn()}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</DialogFooter>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditHouseForm;
|
||||
132
src/components/form/house/user-invite-member-form.tsx
Normal file
132
src/components/form/house/user-invite-member-form.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DialogClose, DialogFooter } from '@/components/ui/dialog';
|
||||
import { useAppForm } from '@/hooks/use-app-form';
|
||||
import useDebounced from '@/hooks/use-debounced';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { invitationMember } from '@/service/house.api';
|
||||
import {
|
||||
invitationCreateBESchema,
|
||||
invitationCreateFESchema,
|
||||
} from '@/service/house.schema';
|
||||
import { housesQueries, usersQueries } from '@/service/queries';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Field, FieldGroup, FieldLabel } from '@ui/field';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type FormProps = {
|
||||
onSubmit: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const UserInviteMemberForm = ({ onSubmit }: FormProps) => {
|
||||
const { data: activeHouse, refetch } = authClient.useActiveOrganization();
|
||||
const [userKeyword, setUserKeyword] = useState('');
|
||||
const debouncedUserKeyword = useDebounced(userKeyword, 300);
|
||||
const { data: users } = useQuery(
|
||||
usersQueries.select({ keyword: debouncedUserKeyword }, true),
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (!activeHouse) return null;
|
||||
|
||||
const { mutate: invitationMemberMutation, isPending } = useMutation({
|
||||
mutationFn: invitationMember,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...housesQueries.all, 'currentUser'],
|
||||
});
|
||||
onSubmit(false);
|
||||
refetch();
|
||||
toast.success(m.houses_page_message_create_house_success(), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
email: '',
|
||||
houseId: activeHouse.id,
|
||||
role: '',
|
||||
},
|
||||
validators: {
|
||||
onChange: invitationCreateFESchema,
|
||||
onSubmit: invitationCreateFESchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
invitationMemberMutation({ data: invitationCreateBESchema.parse(value) });
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form
|
||||
id="user-invite-member-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="house_name">
|
||||
{m.houses_page_form_name()}:
|
||||
</FieldLabel>
|
||||
<div className="flex gap-2">{activeHouse.name}</div>
|
||||
</Field>
|
||||
<form.AppField name="email">
|
||||
{(field) => (
|
||||
<field.SelectUser
|
||||
label={m.houses_page_form_user()}
|
||||
values={users ?? []}
|
||||
placeholder={m.houses_page_form_user_select_placeholder()}
|
||||
searchPlaceholder={m.houses_page_form_user_select_search_placeholder()}
|
||||
keyword={userKeyword}
|
||||
onKeywordChange={setUserKeyword}
|
||||
selectKey="email"
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
<form.AppField name="role">
|
||||
{(field) => (
|
||||
<field.Select
|
||||
label={m.profile_form_role()}
|
||||
placeholder={m.users_page_ui_select_placeholder_role()}
|
||||
values={[
|
||||
{ value: 'owner', label: m.role_tags({ role: 'owner' }) },
|
||||
{ value: 'admin', label: m.role_tags({ role: 'admin' }) },
|
||||
{ value: 'member', label: m.role_tags({ role: 'member' }) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
<Field>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{m.ui_cancel_btn()}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton
|
||||
label={m.ui_confirm_btn()}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</DialogFooter>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserInviteMemberForm;
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useAppForm } from '@/hooks/use-app-form';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { settingQueries } from '@/service/queries';
|
||||
import { updateAdminSettings } from '@/service/setting.api';
|
||||
import { settingSchema, SettingsInput } from '@/service/setting.schema';
|
||||
import { ReturnError } from '@/types/common';
|
||||
import { useAppForm } from '@hooks/use-app-form';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { GearIcon } from '@phosphor-icons/react';
|
||||
import { settingQueries } from '@service/queries';
|
||||
import { updateAdminSettings } from '@service/setting.api';
|
||||
import { settingSchema, SettingsInput } from '@service/setting.schema';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
|
||||
import { Field, FieldGroup } from '@ui/field';
|
||||
import { Skeleton } from '@ui/skeleton';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Field, FieldGroup } from '../ui/field';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
const defaultValues: SettingsInput = {
|
||||
site_name: '',
|
||||
@@ -22,7 +21,7 @@ const SettingsForm = () => {
|
||||
|
||||
const { data: settings, isLoading } = useQuery(settingQueries.listAdmin());
|
||||
|
||||
const updateMutation = useMutation({
|
||||
const { mutate: updateMutation, isPending } = useMutation({
|
||||
mutationFn: updateAdminSettings,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(settingQueries.listAdmin());
|
||||
@@ -32,9 +31,10 @@ const SettingsForm = () => {
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
toast.error(m.backend_message({ code: error.code }), {
|
||||
richColors: true,
|
||||
});
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), { richColors: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ const SettingsForm = () => {
|
||||
onChange: settingSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
updateMutation.mutate({ data: value as SettingsInput });
|
||||
updateMutation({ data: value as SettingsInput });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -88,7 +88,10 @@ const SettingsForm = () => {
|
||||
</form.AppField>
|
||||
<Field>
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton label={m.ui_update_btn()} />
|
||||
<form.SubscribeButton
|
||||
label={m.ui_update_btn()}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useAppForm } from '@/hooks/use-app-form';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { useAppForm } from '@hooks/use-app-form';
|
||||
import { authClient } from '@lib/auth-client';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { createLink, useNavigate } from '@tanstack/react-router';
|
||||
import { Button } from '@ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
|
||||
import { Field, FieldGroup } from '@ui/field';
|
||||
import { toast } from 'sonner';
|
||||
import z from 'zod';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Field, FieldGroup } from '../ui/field';
|
||||
|
||||
const SignInFormSchema = z.object({
|
||||
email: z
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { createLink, Link } from '@tanstack/react-router';
|
||||
import { Button } from '../ui/button';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '../ui/card';
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from '../ui/field';
|
||||
import { Input } from '../ui/input';
|
||||
} from '@ui/card';
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@ui/field';
|
||||
import { Input } from '@ui/input';
|
||||
|
||||
const ButtonLink = createLink(Button);
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useAppForm } from '@/hooks/use-app-form';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { userBanSchema } from '@/service/user.schema';
|
||||
import { useBanContext } from '@components/user/ban-user-dialog';
|
||||
import { useAppForm } from '@hooks/use-app-form';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { WarningIcon } from '@phosphor-icons/react';
|
||||
import { userBanSchema } from '@service/user.schema';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@ui/alert';
|
||||
import { Button } from '@ui/button';
|
||||
import { DialogClose, DialogFooter } from '@ui/dialog';
|
||||
import { Field, FieldGroup } from '@ui/field';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
|
||||
import { Button } from '../ui/button';
|
||||
import { DialogClose, DialogFooter } from '../ui/dialog';
|
||||
import { Field, FieldGroup } from '../ui/field';
|
||||
import { useBanContext } from '../user/ban-user-dialog';
|
||||
|
||||
type FormProps = {
|
||||
data: UserWithRole;
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useAppForm } from '@/hooks/use-app-form';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { usersQueries } from '@/service/queries';
|
||||
import { createUser } from '@/service/user.api';
|
||||
import { userCreateSchema } from '@/service/user.schema';
|
||||
import { ReturnError } from '@/types/common';
|
||||
import { useAppForm } from '@hooks/use-app-form';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { usersQueries } from '@service/queries';
|
||||
import { createUser } from '@service/user.api';
|
||||
import { userCreateBESchema, userCreateFESchema } from '@service/user.schema';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@ui/button';
|
||||
import { DialogClose, DialogFooter } from '@ui/dialog';
|
||||
import { Field, FieldGroup } from '@ui/field';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '../ui/button';
|
||||
import { DialogClose, DialogFooter } from '../ui/dialog';
|
||||
import { Field, FieldGroup } from '../ui/field';
|
||||
|
||||
type FormProps = {
|
||||
onSubmit: (open: boolean) => void;
|
||||
@@ -17,7 +16,7 @@ type FormProps = {
|
||||
const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: createUserMutation } = useMutation({
|
||||
const { mutate: createUserMutation, isPending } = useMutation({
|
||||
mutationFn: createUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -30,7 +29,10 @@ const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
toast.error(m.backend_message({ code: error.code }), {
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
@@ -44,11 +46,11 @@ const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
|
||||
role: '',
|
||||
},
|
||||
validators: {
|
||||
onChange: userCreateSchema,
|
||||
onSubmit: userCreateSchema,
|
||||
onChange: userCreateFESchema,
|
||||
onSubmit: userCreateFESchema,
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
createUserMutation({ data: userCreateSchema.parse(value) });
|
||||
createUserMutation({ data: userCreateBESchema.parse(value) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,7 +83,6 @@ const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
|
||||
<field.Select
|
||||
label={m.profile_form_role()}
|
||||
placeholder={m.users_page_ui_select_placeholder_role()}
|
||||
isRole
|
||||
values={[
|
||||
{ value: 'admin', label: m.role_tags({ role: 'admin' }) },
|
||||
{ value: 'user', label: m.role_tags({ role: 'user' }) },
|
||||
@@ -97,7 +98,10 @@ const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton label={m.ui_signup_btn()} />
|
||||
<form.SubscribeButton
|
||||
label={m.ui_signup_btn()}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</DialogFooter>
|
||||
</Field>
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useAppForm } from '@/hooks/use-app-form';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { usersQueries } from '@/service/queries';
|
||||
import { setUserPassword } from '@/service/user.api';
|
||||
import { userSetPasswordSchema } from '@/service/user.schema';
|
||||
import { ReturnError } from '@/types/common';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppForm } from '@hooks/use-app-form';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { usersQueries } from '@service/queries';
|
||||
import { setUserPassword } from '@service/user.api';
|
||||
import { userSetPasswordSchema } from '@service/user.schema';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { DialogClose, DialogFooter } from '@ui/dialog';
|
||||
import { Field, FieldGroup } from '@ui/field';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '../ui/button';
|
||||
import { DialogClose, DialogFooter } from '../ui/dialog';
|
||||
import { Field, FieldGroup } from '../ui/field';
|
||||
|
||||
type FormProps = {
|
||||
data: UserWithRole;
|
||||
@@ -19,7 +18,7 @@ type FormProps = {
|
||||
const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setUserPasswordMutation = useMutation({
|
||||
const { mutate: setUserPasswordMutation, isPending } = useMutation({
|
||||
mutationFn: setUserPassword,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -32,7 +31,10 @@ const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
toast.error(m.backend_message({ code: error.code }), {
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
@@ -47,7 +49,7 @@ const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
|
||||
onSubmit: userSetPasswordSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
setUserPasswordMutation.mutate({ data: value });
|
||||
setUserPasswordMutation({ data: value });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -80,7 +82,10 @@ const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton label={m.ui_save_btn()} />
|
||||
<form.SubscribeButton
|
||||
label={m.ui_save_btn()}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</DialogFooter>
|
||||
</Field>
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useAppForm } from '@/hooks/use-app-form';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { usersQueries } from '@/service/queries';
|
||||
import { setUserRole } from '@/service/user.api';
|
||||
import { userUpdateRoleSchema } from '@/service/user.schema';
|
||||
import { ReturnError } from '@/types/common';
|
||||
import { useAppForm } from '@hooks/use-app-form';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { usersQueries } from '@service/queries';
|
||||
import { setUserRole } from '@service/user.api';
|
||||
import {
|
||||
userUpdateRoleBESchema,
|
||||
userUpdateRoleSchema,
|
||||
} from '@service/user.schema';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@ui/button';
|
||||
import { DialogClose, DialogFooter } from '@ui/dialog';
|
||||
import { Field, FieldGroup } from '@ui/field';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '../ui/button';
|
||||
import { DialogClose, DialogFooter } from '../ui/dialog';
|
||||
import { Field, FieldGroup } from '../ui/field';
|
||||
|
||||
type SetRoleFormProps = {
|
||||
data: UserWithRole;
|
||||
@@ -24,7 +26,7 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
|
||||
role: data.role,
|
||||
};
|
||||
|
||||
const updateRoleMutation = useMutation({
|
||||
const { mutate: updateRoleMutation, isPending } = useMutation({
|
||||
mutationFn: setUserRole,
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({
|
||||
@@ -37,7 +39,10 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
toast.error(m.backend_message({ code: error.code }), {
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
@@ -50,7 +55,7 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
|
||||
onSubmit: userUpdateRoleSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
updateRoleMutation.mutate({ data: value });
|
||||
updateRoleMutation({ data: userUpdateRoleBESchema.parse(value) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -87,7 +92,10 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton label={m.ui_save_btn()} />
|
||||
<form.SubscribeButton
|
||||
label={m.ui_save_btn()}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</DialogFooter>
|
||||
</Field>
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useAppForm } from '@/hooks/use-app-form';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { usersQueries } from '@/service/queries';
|
||||
import { updateUserInformation } from '@/service/user.api';
|
||||
import { userUpdateInfoSchema } from '@/service/user.schema';
|
||||
import { ReturnError } from '@/types/common';
|
||||
import { useAppForm } from '@hooks/use-app-form';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { usersQueries } from '@service/queries';
|
||||
import { updateUserInformation } from '@service/user.api';
|
||||
import { userUpdateInfoSchema } from '@service/user.schema';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@ui/button';
|
||||
import { DialogClose, DialogFooter } from '@ui/dialog';
|
||||
import { Field, FieldGroup } from '@ui/field';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '../ui/button';
|
||||
import { DialogClose, DialogFooter } from '../ui/dialog';
|
||||
import { Field, FieldGroup } from '../ui/field';
|
||||
|
||||
type UpdateUserFormProps = {
|
||||
data: UserWithRole;
|
||||
@@ -19,7 +18,7 @@ type UpdateUserFormProps = {
|
||||
const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateUserMutation = useMutation({
|
||||
const { mutate: updateUserMutation, isPending } = useMutation({
|
||||
mutationFn: updateUserInformation,
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({
|
||||
@@ -32,7 +31,10 @@ const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
toast.error(m.backend_message({ code: error.code }), {
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
@@ -46,7 +48,7 @@ const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
|
||||
onChange: userUpdateInfoSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
updateUserMutation.mutate({ data: value });
|
||||
updateUserMutation({ data: value });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,7 +76,10 @@ const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton label={m.ui_save_btn()} />
|
||||
<form.SubscribeButton
|
||||
label={m.ui_save_btn()}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</form.AppForm>
|
||||
</DialogFooter>
|
||||
</Field>
|
||||
69
src/components/house/create-house-dialog.tsx
Normal file
69
src/components/house/create-house-dialog.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import CreateNewHouseForm from '@form/house/admin-create-house-form';
|
||||
import useHasPermission from '@hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { cn } from '@lib/utils';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { PlusIcon } from '@phosphor-icons/react';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@ui/dialog';
|
||||
import { useState } from 'react';
|
||||
|
||||
type CreateNewHouseProp = {
|
||||
isPersonal?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CreateNewHouse = ({
|
||||
className,
|
||||
isPersonal = false,
|
||||
}: CreateNewHouseProp) => {
|
||||
const { hasPermission, isLoading } = useHasPermission(
|
||||
'house',
|
||||
'create',
|
||||
isPersonal,
|
||||
);
|
||||
const [_open, _setOpen] = useState(false);
|
||||
const prevent = usePreventAutoFocus();
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (hasPermission) {
|
||||
return (
|
||||
<Dialog open={_open} onOpenChange={_setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="default" className={cn(className)}>
|
||||
<PlusIcon />
|
||||
{m.nav_add_new()}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-80 xl:max-w-xl"
|
||||
{...prevent}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-primary">
|
||||
<PlusIcon size={16} />
|
||||
{m.nav_add_new()}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{m.nav_add_new()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CreateNewHouseForm onSubmit={_setOpen} isPersonal={isPersonal} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CreateNewHouse;
|
||||
43
src/components/house/current-user-action-group.tsx
Normal file
43
src/components/house/current-user-action-group.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { authClient } from '@lib/auth-client';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { GearIcon, PenIcon } from '@phosphor-icons/react';
|
||||
import { Button } from '@ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
|
||||
import DeleteUserHouseAction from './delete-user-house-dialog';
|
||||
import EditHouseAction from './edit-house-dialog';
|
||||
|
||||
type CurrentUserActionGroupProps = {
|
||||
oneHouse: boolean;
|
||||
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
|
||||
};
|
||||
|
||||
const CurrentUserActionGroup = ({
|
||||
oneHouse,
|
||||
activeHouse,
|
||||
}: CurrentUserActionGroupProps) => {
|
||||
return (
|
||||
<Card className="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<GearIcon />
|
||||
{m.houses_user_page_block_action_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-2">
|
||||
<EditHouseAction data={activeHouse as HouseWithMembers} isPersonal>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-lg"
|
||||
className="rounded-full cursor-pointer bg-blue-500 text-white hover:bg-blue-100 hover:text-blue-600"
|
||||
>
|
||||
<PenIcon size={16} />
|
||||
<span className="sr-only">{m.ui_edit_house_btn()}</span>
|
||||
</Button>
|
||||
</EditHouseAction>
|
||||
{!oneHouse && <DeleteUserHouseAction activeHouse={activeHouse} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentUserActionGroup;
|
||||
132
src/components/house/current-user-house-list.tsx
Normal file
132
src/components/house/current-user-house-list.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { authClient } from '@lib/auth-client';
|
||||
import { cn } from '@lib/utils';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { CheckIcon, WarehouseIcon } from '@phosphor-icons/react';
|
||||
import { Button } from '@ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle,
|
||||
} from '@ui/item';
|
||||
import { ScrollArea, ScrollBar } from '@ui/scroll-area';
|
||||
import { Skeleton } from '@ui/skeleton';
|
||||
import parse from 'html-react-parser';
|
||||
import { toast } from 'sonner';
|
||||
import CreateNewHouse from './create-house-dialog';
|
||||
|
||||
type CurrentUserHouseListProps = {
|
||||
houses: HouseWithMembersCount[];
|
||||
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
|
||||
};
|
||||
|
||||
const CurrentUserHouseList = ({
|
||||
activeHouse,
|
||||
houses,
|
||||
}: CurrentUserHouseListProps) => {
|
||||
const activeHouseAction = async ({
|
||||
id,
|
||||
slug,
|
||||
}: {
|
||||
id: string;
|
||||
slug: string;
|
||||
}) => {
|
||||
const { data, error } = await authClient.organization.setActive({
|
||||
organizationId: id,
|
||||
organizationSlug: slug,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { richColors: true });
|
||||
}
|
||||
|
||||
if (data) {
|
||||
toast.success(
|
||||
parse(
|
||||
m.houses_user_page_message_active_house_success({ house: data.name }),
|
||||
),
|
||||
{
|
||||
richColors: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeHouse || !houses) {
|
||||
return <Skeleton className="col-span-2 h-80 w-full rounded-xl" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="col-span-1 lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<WarehouseIcon size={24} />
|
||||
{m.houses_page_ui_title()}
|
||||
<CreateNewHouse isPersonal className="ml-auto" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="w-full rounded-md border whitespace-nowrap bg-gray-50">
|
||||
<div className="flex w-max p-4 space-x-4">
|
||||
{houses.map((house) => {
|
||||
const isActive = house.id === activeHouse.id;
|
||||
|
||||
return (
|
||||
<Item
|
||||
variant="outline"
|
||||
className={cn('w-100 bg-white', {
|
||||
'bg-linear-to-tr from-white/2 to-green-200': isActive,
|
||||
})}
|
||||
key={house.id}
|
||||
>
|
||||
<ItemContent>
|
||||
<ItemTitle
|
||||
className="font-bold text-sm text-(--house-color)"
|
||||
style={
|
||||
{ '--house-color': house.color } as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{house.name}
|
||||
</ItemTitle>
|
||||
<ItemDescription>
|
||||
<strong>{m.houses_page_ui_table_header_members()}</strong>
|
||||
:
|
||||
{house._count.members}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
data-active={isActive}
|
||||
disabled={isActive}
|
||||
className={cn('rounded-full cursor-pointer', {
|
||||
'disabled:bg-green-500! disabled:border-green-500!':
|
||||
isActive,
|
||||
'bg-amber-50! text-amber-600! border-amber-600!':
|
||||
!isActive,
|
||||
})}
|
||||
size="icon-lg"
|
||||
onClick={() =>
|
||||
activeHouseAction({ id: house.id, slug: house.slug })
|
||||
}
|
||||
>
|
||||
<CheckIcon weight="bold" />
|
||||
<span className="sr-only">
|
||||
{m.houses_page_house_active_btn()}
|
||||
</span>
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentUserHouseList;
|
||||
116
src/components/house/current-user-invitation-list.tsx
Normal file
116
src/components/house/current-user-invitation-list.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { cancelInvitation } from '@/service/house.api';
|
||||
import { INVITE_STATUS } from '@/types/enum';
|
||||
import { authClient } from '@lib/auth-client';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Item, ItemContent, ItemDescription, ItemTitle } from '@ui/item';
|
||||
import { Skeleton } from '@ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import RoleBadge from '../avatar/role-badge';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
type InvitationListProps = {
|
||||
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
|
||||
};
|
||||
|
||||
const CurrentUserInvitationList = ({ activeHouse }: InvitationListProps) => {
|
||||
const { refetch } = authClient.useActiveOrganization();
|
||||
|
||||
const { mutate: cancelInvitationMutation } = useMutation({
|
||||
mutationFn: cancelInvitation,
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
// _setOpen(false);
|
||||
toast.success(m.houses_page_message_delete_house_success(), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleCancelInvitation = (id: string) => {
|
||||
cancelInvitationMutation({ data: { id } });
|
||||
};
|
||||
|
||||
if (!activeHouse) {
|
||||
return <Skeleton className="col-span-2 h-80 w-full rounded-xl" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border col-span-1 lg:col-span-3 shadow-xs bg-linear-to-br from-primary/5 to-card">
|
||||
<Table className="bg-white">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4 bg-primary text-white text-sm w-1/2">
|
||||
{m.houses_page_ui_view_table_header_invite()}
|
||||
</TableHead>
|
||||
<TableHead className="px-4 bg-primary text-white text-sm w-1/2"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeHouse.invitations.length > 0 ? (
|
||||
activeHouse.invitations.map((item) => (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Item>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<strong>{m.houses_user_page_invite_label_to()}:</strong>{' '}
|
||||
{item.email} - <RoleBadge type={item.role} />
|
||||
</ItemTitle>
|
||||
<ItemDescription>
|
||||
<strong>
|
||||
{m.houses_user_page_invite_label_status()}:{' '}
|
||||
{m.invite_status({
|
||||
status: item.status as INVITE_STATUS,
|
||||
})}
|
||||
</strong>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</TableCell>
|
||||
<TableCell className="p-6">
|
||||
<div className="flex justify-end gap-2">
|
||||
{item.status !== INVITE_STATUS.CANCELED && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer w-20 py-4"
|
||||
onClick={() => handleCancelInvitation(item.id)}
|
||||
>
|
||||
{m.ui_cancel_btn()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentUserInvitationList;
|
||||
71
src/components/house/current-user-member-list.tsx
Normal file
71
src/components/house/current-user-member-list.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { authClient } from '@lib/auth-client';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { Item, ItemContent, ItemDescription, ItemTitle } from '@ui/item';
|
||||
import { Skeleton } from '@ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@ui/table';
|
||||
import RoleBadge from '../avatar/role-badge';
|
||||
import InviteUserAction from './invite-user-dialog';
|
||||
|
||||
type CurrentUserMemberListProps = {
|
||||
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
|
||||
};
|
||||
|
||||
const CurrentUserMemberList = ({ activeHouse }: CurrentUserMemberListProps) => {
|
||||
if (!activeHouse) {
|
||||
return <Skeleton className="col-span-2 h-80 w-full rounded-xl" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border col-span-1 lg:col-span-2 shadow-xs bg-linear-to-br from-primary/5 to-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4 bg-primary text-white text-sm w-1/3">
|
||||
{m.houses_page_ui_table_header_name()} &{' '}
|
||||
{m.houses_page_ui_view_table_header_email()}
|
||||
</TableHead>
|
||||
<TableHead className="px-4 bg-primary text-white text-sm w-1/3">
|
||||
{m.houses_page_ui_view_table_header_role()}
|
||||
</TableHead>
|
||||
<TableHead className="px-4 bg-primary">
|
||||
<div className="flex justify-end gap-2">
|
||||
<InviteUserAction />
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeHouse.members.map((member) => (
|
||||
<TableRow key={member.user.id}>
|
||||
<TableCell className="px-4">
|
||||
<Item className="p-0">
|
||||
<ItemContent>
|
||||
<ItemTitle>{member.user.name}</ItemTitle>
|
||||
<ItemDescription>{member.user.email}</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<RoleBadge type={member.role} />
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
{activeHouse.color}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentUserMemberList;
|
||||
161
src/components/house/delete-house-dialog.tsx
Normal file
161
src/components/house/delete-house-dialog.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import useHasPermission from '@hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { ShieldWarningIcon, TrashIcon } from '@phosphor-icons/react';
|
||||
import { deleteHouse } from '@service/house.api';
|
||||
import { housesQueries } from '@service/queries';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@ui/dialog';
|
||||
import { Label } from '@ui/label';
|
||||
import { Spinner } from '@ui/spinner';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@ui/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
|
||||
import parse from 'html-react-parser';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import RoleBadge from '../avatar/role-badge';
|
||||
|
||||
type DeleteHouseProps = {
|
||||
data: HouseWithMembers;
|
||||
};
|
||||
|
||||
const DeleteHouseAction = ({ data }: DeleteHouseProps) => {
|
||||
const [_open, _setOpen] = useState(false);
|
||||
const prevent = usePreventAutoFocus();
|
||||
const { hasPermission, isLoading } = useHasPermission('house', 'delete');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: deleteHouseMutation, isPending } = useMutation({
|
||||
mutationFn: deleteHouse,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...housesQueries.all, 'list'],
|
||||
});
|
||||
_setOpen(false);
|
||||
toast.success(m.houses_page_message_delete_house_success(), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onConfirm = () => {
|
||||
deleteHouseMutation({ data });
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (hasPermission) {
|
||||
return (
|
||||
<Dialog open={_open} onOpenChange={_setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full cursor-pointer text-red-500 hover:bg-red-100 hover:text-red-600"
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
<span className="sr-only">{m.ui_delete_btn()}</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-red-500 [&_svg]:bg-red-500 [&_svg]:fill-red-500 text-white">
|
||||
<Label>{m.ui_delete_btn()}</Label>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent
|
||||
className="max-w-100 xl:max-w-xl"
|
||||
showCloseButton={false}
|
||||
{...prevent}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-500">
|
||||
<div className="rounded-full bg-red-100 p-3">
|
||||
<ShieldWarningIcon size={30} />
|
||||
</div>
|
||||
{m.houses_page_ui_dialog_alert_delete_title({ name: data.name })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-red-500">
|
||||
{parse(m.houses_page_ui_dialog_alert_delete_description())}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table className="bg-white">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
|
||||
{m.houses_page_ui_view_table_header_email()}
|
||||
</TableHead>
|
||||
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
|
||||
{m.houses_page_ui_view_table_header_role()}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.members.map((member) => (
|
||||
<TableRow key={member.user.email}>
|
||||
<TableCell>{member.user.email}</TableCell>
|
||||
<TableCell>
|
||||
<RoleBadge type={member.role} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter className="bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{m.ui_cancel_btn()}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending && <Spinner data-icon="inline-start" />}
|
||||
{m.ui_confirm_btn()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default DeleteHouseAction;
|
||||
163
src/components/house/delete-user-house-dialog.tsx
Normal file
163
src/components/house/delete-user-house-dialog.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import useHasPermission from '@hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { authClient } from '@lib/auth-client';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { ShieldWarningIcon, TrashIcon } from '@phosphor-icons/react';
|
||||
import { deleteUserHouse } from '@service/house.api';
|
||||
import { housesQueries } from '@service/queries';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@ui/dialog';
|
||||
import { Label } from '@ui/label';
|
||||
import { Spinner } from '@ui/spinner';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@ui/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
|
||||
import parse from 'html-react-parser';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import RoleBadge from '../avatar/role-badge';
|
||||
|
||||
type DeleteUserHouseProps = {
|
||||
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
|
||||
};
|
||||
|
||||
const DeleteUserHouseAction = ({ activeHouse }: DeleteUserHouseProps) => {
|
||||
const [_open, _setOpen] = useState(false);
|
||||
const prevent = usePreventAutoFocus();
|
||||
const { hasPermission, isLoading } = useHasPermission('house', 'delete');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: deleteHouseMutation, isPending } = useMutation({
|
||||
mutationFn: deleteUserHouse,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...housesQueries.all, 'currentUser'],
|
||||
});
|
||||
_setOpen(false);
|
||||
toast.success(m.houses_page_message_delete_house_success(), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || !activeHouse) return null;
|
||||
|
||||
const onConfirm = async () => {
|
||||
deleteHouseMutation({ data: { id: activeHouse.id } });
|
||||
};
|
||||
|
||||
if (hasPermission) {
|
||||
return (
|
||||
<Dialog open={_open} onOpenChange={_setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-lg"
|
||||
className="rounded-full cursor-pointer bg-red-500 text-white hover:bg-red-100 hover:text-red-600"
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
<span className="sr-only">{m.ui_delete_btn()}</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-red-500 [&_svg]:bg-red-500 [&_svg]:fill-red-500 text-white">
|
||||
<Label>{m.ui_delete_btn()}</Label>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent
|
||||
className="max-w-100 xl:max-w-xl"
|
||||
showCloseButton={false}
|
||||
{...prevent}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-500">
|
||||
<div className="rounded-full bg-red-100 p-3">
|
||||
<ShieldWarningIcon size={30} />
|
||||
</div>
|
||||
{m.houses_page_ui_dialog_alert_delete_title({
|
||||
name: activeHouse.name,
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-red-500">
|
||||
{parse(m.houses_page_ui_dialog_alert_delete_description())}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table className="bg-white">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
|
||||
{m.houses_page_ui_view_table_header_email()}
|
||||
</TableHead>
|
||||
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
|
||||
{m.houses_page_ui_view_table_header_role()}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeHouse.members.map((member) => (
|
||||
<TableRow key={member.user.email}>
|
||||
<TableCell>{member.user.email}</TableCell>
|
||||
<TableCell>
|
||||
<RoleBadge type={member.role} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter className="bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{m.ui_cancel_btn()}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending && <Spinner data-icon="inline-start" />}
|
||||
{m.ui_confirm_btn()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default DeleteUserHouseAction;
|
||||
73
src/components/house/edit-house-dialog.tsx
Normal file
73
src/components/house/edit-house-dialog.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import EditHouseForm from '@form/house/admin-edit-house-form';
|
||||
import useHasPermission from '@hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { PenIcon } from '@phosphor-icons/react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@ui/dialog';
|
||||
import { Label } from '@ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
|
||||
import { useState } from 'react';
|
||||
|
||||
type EditHouseProps = {
|
||||
data: HouseWithMembers;
|
||||
children: React.ReactNode;
|
||||
isPersonal?: boolean;
|
||||
};
|
||||
|
||||
const EditHouseAction = ({
|
||||
data,
|
||||
children,
|
||||
isPersonal = false,
|
||||
}: EditHouseProps) => {
|
||||
const [_open, _setOpen] = useState(false);
|
||||
const prevent = usePreventAutoFocus();
|
||||
const { hasPermission, isLoading } = useHasPermission('house', 'update');
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (hasPermission) {
|
||||
return (
|
||||
<Dialog open={_open} onOpenChange={_setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-blue-500 [&_svg]:bg-blue-500 [&_svg]:fill-blue-500 text-white">
|
||||
<Label>{m.ui_edit_house_btn()}</Label>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent
|
||||
className="max-w-100 xl:max-w-2xl"
|
||||
{...prevent}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-blue-600">
|
||||
<PenIcon size={16} />
|
||||
{m.ui_edit_house_btn()}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{m.ui_edit_house_btn()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<EditHouseForm
|
||||
data={data}
|
||||
onSubmit={_setOpen}
|
||||
mutateKey={isPersonal ? 'currentUser' : 'list'}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default EditHouseAction;
|
||||
63
src/components/house/house-column.tsx
Normal file
63
src/components/house/house-column.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { m } from '@paraglide/messages';
|
||||
import { PenIcon } from '@phosphor-icons/react';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Button } from '@ui/button';
|
||||
import { formatters } from '@utils/formatters';
|
||||
import DeleteHouseAction from './delete-house-dialog';
|
||||
import EditHouseAction from './edit-house-dialog';
|
||||
import ViewDetailHouse from './view-house-detail-dialog';
|
||||
|
||||
export const houseColumns: ColumnDef<HouseWithMembers>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: m.houses_page_ui_table_header_name(),
|
||||
meta: {
|
||||
thClass: 'w-1/6',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'members',
|
||||
header: m.houses_page_ui_table_header_members(),
|
||||
meta: {
|
||||
thClass: 'w-1/6',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return row.original.members.length;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: m.houses_page_ui_table_header_created_at(),
|
||||
meta: {
|
||||
thClass: 'w-1/6',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return formatters.dateTime(new Date(row.original.createdAt));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
meta: {
|
||||
thClass: 'w-1/6',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<ViewDetailHouse data={row.original} />
|
||||
<EditHouseAction data={row.original}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full cursor-pointer text-blue-500 hover:bg-blue-100 hover:text-blue-600"
|
||||
>
|
||||
<PenIcon size={16} />
|
||||
<span className="sr-only">{m.ui_edit_house_btn()}</span>
|
||||
</Button>
|
||||
</EditHouseAction>
|
||||
<DeleteHouseAction data={row.original} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
65
src/components/house/invite-user-dialog.tsx
Normal file
65
src/components/house/invite-user-dialog.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import UserInviteMemberForm from '@form/house/user-invite-member-form';
|
||||
import useHasPermission from '@hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { PaperPlaneTiltIcon } from '@phosphor-icons/react';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@ui/dialog';
|
||||
import { useState } from 'react';
|
||||
|
||||
type ActionProps = {};
|
||||
|
||||
const InviteUserAction = ({}: ActionProps) => {
|
||||
const { hasPermission, isLoading } = useHasPermission(
|
||||
'house',
|
||||
'create',
|
||||
true,
|
||||
);
|
||||
const [_open, _setOpen] = useState(false);
|
||||
const prevent = usePreventAutoFocus();
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (hasPermission) {
|
||||
return (
|
||||
<Dialog open={_open} onOpenChange={_setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-full cursor-pointer bg-white text-black hover:bg-green-500 hover:text-white"
|
||||
>
|
||||
<PaperPlaneTiltIcon weight="fill" />
|
||||
{m.houses_user_page_action_invite_user()}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-80 xl:max-w-lg"
|
||||
{...prevent}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-green-600">
|
||||
<PaperPlaneTiltIcon size={16} weight="fill" />
|
||||
{m.houses_user_page_action_invite_user()}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{m.houses_user_page_action_invite_user()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<UserInviteMemberForm onSubmit={_setOpen} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default InviteUserAction;
|
||||
123
src/components/house/view-house-detail-dialog.tsx
Normal file
123
src/components/house/view-house-detail-dialog.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { EyeIcon } from '@phosphor-icons/react';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@ui/dialog';
|
||||
import { Label } from '@ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@ui/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
|
||||
import { formatters } from '@utils/formatters';
|
||||
import RoleBadge from '../avatar/role-badge';
|
||||
|
||||
type ViewDetailProps = {
|
||||
data: HouseWithMembers;
|
||||
};
|
||||
|
||||
const ViewDetailHouse = ({ data }: ViewDetailProps) => {
|
||||
const prevent = usePreventAutoFocus();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full cursor-pointer text-teal-500 hover:bg-teal-100 hover:text-teal-600"
|
||||
>
|
||||
<EyeIcon size={16} />
|
||||
<span className="sr-only">{m.ui_view_btn()}</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="bg-teal-500 [&_svg]:bg-teal-500 [&_svg]:fill-teal-500 text-white"
|
||||
>
|
||||
<Label>{m.ui_view_btn()}</Label>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent
|
||||
className="max-w-100 xl:max-w-2xl"
|
||||
{...prevent}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-teal-600">
|
||||
<EyeIcon size={20} />
|
||||
{m.ui_dialog_view_title({ type: m.nav_houses() })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{m.ui_dialog_view_title({ type: m.nav_houses() })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">
|
||||
{m.houses_page_ui_table_header_name()}:
|
||||
</span>
|
||||
<Label>{data.name}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">
|
||||
{m.houses_page_ui_table_header_created_at()}:
|
||||
</span>
|
||||
<Label>{formatters.dateTime(new Date(data.createdAt))}</Label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="font-bold">
|
||||
{m.houses_page_ui_table_header_members()}:
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">
|
||||
{m.houses_page_ui_view_label_count()}:
|
||||
</span>
|
||||
<Label>{data.members.length}</Label>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table className="bg-white">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
|
||||
{m.houses_page_ui_view_table_header_email()}
|
||||
</TableHead>
|
||||
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
|
||||
{m.houses_page_ui_view_table_header_role()}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.members.map((member) => (
|
||||
<TableRow key={member.user.email}>
|
||||
<TableCell>{member.user.email}</TableCell>
|
||||
<TableCell>
|
||||
<RoleBadge type={member.role} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default ViewDetailHouse;
|
||||
@@ -1,14 +1,13 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { m } from '@paraglide/messages';
|
||||
import {
|
||||
CircuitryIcon,
|
||||
GaugeIcon,
|
||||
GearIcon,
|
||||
HouseIcon,
|
||||
UsersIcon,
|
||||
WarehouseIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { createLink } from '@tanstack/react-router';
|
||||
import AdminShow from '../auth/AdminShow';
|
||||
import AuthShow from '../auth/AuthShow';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
@@ -16,95 +15,118 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '../ui/sidebar';
|
||||
} from '@ui/sidebar';
|
||||
import React from 'react';
|
||||
import AdminShow from '../auth/AdminShow';
|
||||
import AuthShow from '../auth/AuthShow';
|
||||
|
||||
const SidebarMenuButtonLink = createLink(SidebarMenuButton);
|
||||
|
||||
const NAV_MAIN = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Basic',
|
||||
title: m.nav_label_basic(),
|
||||
isAuth: false,
|
||||
admin: false,
|
||||
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',
|
||||
title: m.nav_label_management(),
|
||||
isAuth: true,
|
||||
admin: false,
|
||||
items: [
|
||||
{
|
||||
title: m.nav_dashboard(),
|
||||
path: '/management/dashboard',
|
||||
icon: GaugeIcon,
|
||||
},
|
||||
{
|
||||
title: m.nav_houses(),
|
||||
path: '/management/houses',
|
||||
icon: WarehouseIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: m.nav_label_kanri(),
|
||||
isAuth: true,
|
||||
admin: true,
|
||||
items: [
|
||||
{
|
||||
title: m.nav_users(),
|
||||
path: '/kanri/users',
|
||||
icon: UsersIcon,
|
||||
isAuth: false,
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
title: m.nav_houses(),
|
||||
path: '/kanri/houses',
|
||||
icon: WarehouseIcon,
|
||||
},
|
||||
{
|
||||
title: m.nav_logs(),
|
||||
path: '/kanri/logs',
|
||||
icon: CircuitryIcon,
|
||||
isAuth: false,
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
title: m.nav_settings(),
|
||||
path: '/kanri/settings',
|
||||
icon: GearIcon,
|
||||
isAuth: false,
|
||||
admin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function EmptyComponent({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const NavMain = () => {
|
||||
return (
|
||||
<>
|
||||
{NAV_MAIN.map((nav) => (
|
||||
<SidebarGroup key={nav.id} className="overflow-hidden">
|
||||
{NAV_MAIN.map((nav) => {
|
||||
const { isAuth, admin } = nav;
|
||||
const Component = admin
|
||||
? AdminShow
|
||||
: isAuth
|
||||
? AuthShow
|
||||
: EmptyComponent;
|
||||
|
||||
return (
|
||||
<Component key={nav.id}>
|
||||
<SidebarGroup className="overflow-hidden">
|
||||
<SidebarGroupLabel>{nav.title}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{nav.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const Menu = (
|
||||
<SidebarMenuItem>
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<SidebarMenuButtonLink
|
||||
type="button"
|
||||
to={item.path}
|
||||
className="cursor-pointer"
|
||||
tooltip={item.title}
|
||||
tooltip={`${nav.title} - ${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>
|
||||
))}
|
||||
</Component>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { authClient } from '@lib/auth-client';
|
||||
import { m } from '@paraglide/messages';
|
||||
import {
|
||||
DotsThreeVerticalIcon,
|
||||
KeyIcon,
|
||||
@@ -9,10 +9,6 @@ import {
|
||||
} from '@phosphor-icons/react';
|
||||
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/avatar-user';
|
||||
import RoleBadge from '../avatar/role-badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -21,13 +17,17 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
} from '@ui/dropdown-menu';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '../ui/sidebar';
|
||||
} from '@ui/sidebar';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '../auth/auth-provider';
|
||||
import AvatarUser from '../avatar/avatar-user';
|
||||
import RoleBadge from '../avatar/role-badge';
|
||||
|
||||
const SidebarMenuButtonLink = createLink(SidebarMenuButton);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AnyRouteMatch, Link, useMatches } from '@tanstack/react-router'
|
||||
import { Fragment } from 'react/jsx-runtime'
|
||||
import { AnyRouteMatch, Link, useMatches } from '@tanstack/react-router';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -7,15 +6,16 @@ import {
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '../ui/breadcrumb'
|
||||
} from '@ui/breadcrumb';
|
||||
import { Fragment } from 'react/jsx-runtime';
|
||||
|
||||
export type BreadcrumbValue =
|
||||
| string
|
||||
| string[]
|
||||
| ((match: AnyRouteMatch) => string | string[])
|
||||
| ((match: AnyRouteMatch) => string | string[]);
|
||||
|
||||
const RouterBreadcrumb = () => {
|
||||
const matches = useMatches()
|
||||
const matches = useMatches();
|
||||
|
||||
const breadcrumbs = matches.flatMap((match) => {
|
||||
const staticData = match.staticData;
|
||||
@@ -40,7 +40,7 @@ const RouterBreadcrumb = () => {
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1
|
||||
const isLast = index === breadcrumbs.length - 1;
|
||||
|
||||
return (
|
||||
<Fragment key={`${crumb.path}-${index}`}>
|
||||
@@ -55,11 +55,11 @@ const RouterBreadcrumb = () => {
|
||||
</BreadcrumbItem>
|
||||
{!isLast && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default RouterBreadcrumb
|
||||
export default RouterBreadcrumb;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
const alertVariants = cva(
|
||||
"grid gap-0.5 rounded-lg border px-2 py-1.5 text-left text-xs/relaxed has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-3.5 w-full relative group/alert",
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
import { Avatar as AvatarPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
size = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
size?: 'default' | 'sm' | 'lg';
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
|
||||
className
|
||||
'size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
@@ -31,12 +31,12 @@ function AvatarImage({
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"rounded-full aspect-square size-full object-cover",
|
||||
className
|
||||
'rounded-full aspect-square size-full object-cover',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
@@ -47,61 +47,64 @@ function AvatarFallback({
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
'bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
|
||||
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
|
||||
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
|
||||
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
||||
className
|
||||
'*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn("bg-muted text-muted-foreground size-8 rounded-full text-xs/relaxed group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)}
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground size-8 rounded-full text-xs/relaxed group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarBadge,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
AvatarImage,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 badgeVariants = cva(
|
||||
'h-5 gap-1 rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-2.5! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-colors overflow-hidden group/badge',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Slot } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@lib/utils';
|
||||
import { CaretRightIcon, DotsThreeIcon } from '@phosphor-icons/react';
|
||||
|
||||
function Breadcrumb({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
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",
|
||||
@@ -10,22 +10,22 @@ const buttonGroupVariants = cva(
|
||||
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",
|
||||
'[&>[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",
|
||||
'[&>[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",
|
||||
orientation: 'horizontal',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
@@ -34,32 +34,32 @@ function ButtonGroup({
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}: React.ComponentProps<'div'> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
@@ -67,12 +67,12 @@ function ButtonGroupSeparator({
|
||||
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
|
||||
'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 {
|
||||
@@ -80,4 +80,4 @@ export {
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@ 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 disabled:data-[active=true]:opacity-100 disabled:data-[active=true]:bg-primary disabled:data-[active=true]:border-primary disabled:data-[active=true]:text-white [&_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-primary disabled:data-[active=true]:border-primary disabled:data-[active=true]:text-white disabled:data-[dot=true]:opacity-100 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
function Card({
|
||||
className,
|
||||
@@ -85,10 +85,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@lib/utils';
|
||||
import { XIcon } from '@phosphor-icons/react';
|
||||
|
||||
function Dialog({
|
||||
@@ -59,7 +59,7 @@ function DialogContent({
|
||||
<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',
|
||||
'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 outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@lib/utils';
|
||||
import { CaretRightIcon, CheckIcon } from '@phosphor-icons/react';
|
||||
|
||||
function DropdownMenu({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
|
||||
return (
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
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
|
||||
'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-2 has-[[data-slot][aria-invalid=true]]:ring-2 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 in-data-[slot=combobox-content]:focus-within:border-inherit in-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(
|
||||
@@ -25,25 +25,27 @@ const inputGroupAddonVariants = cva(
|
||||
{
|
||||
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",
|
||||
'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",
|
||||
align: 'inline-start',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
align = 'inline-start',
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
@@ -51,40 +53,40 @@ function InputGroupAddon({
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
if ((e.target as HTMLElement).closest('button')) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
e.currentTarget.parentElement?.querySelector('input')?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"gap-2 rounded-md text-xs/relaxed shadow-none flex items-center",
|
||||
'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",
|
||||
sm: '',
|
||||
'icon-xs': 'size-6 p-0 has-[>svg]:p-0',
|
||||
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
size: 'xs',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
type = 'button',
|
||||
variant = 'ghost',
|
||||
size = 'xs',
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
@@ -94,52 +96,58 @@ function InputGroupButton({
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
}: 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)}
|
||||
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">) {
|
||||
}: 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)}
|
||||
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,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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}
|
||||
@@ -16,4 +16,4 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
196
src/components/ui/item.tsx
Normal file
196
src/components/ui/item.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn(
|
||||
'gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2 group/item-group flex w-full flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn('my-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
'[a]:hover:bg-muted rounded-md border text-xs/relaxed w-full group/item focus-visible:border-ring focus-visible:ring-ring/50 flex items-center flex-wrap outline-none transition-colors duration-100 focus-visible:ring-[3px] [a]:transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent',
|
||||
outline: 'border-border',
|
||||
muted: 'bg-muted/50 border-transparent',
|
||||
},
|
||||
size: {
|
||||
default: 'gap-2.5 px-3 py-2.5',
|
||||
sm: 'gap-2.5 px-3 py-2.5',
|
||||
xs: 'gap-2.5 px-2.5 py-2 [[data-slot=dropdown-menu-content]_&]:p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> &
|
||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : 'div';
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
'gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start flex shrink-0 items-center justify-center [&_svg]:pointer-events-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
'size-8 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
'gap-1 group-data-[size=xs]/item:gap-0.5 flex flex-1 flex-col [&+[data-slot=item-content]]:flex-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
'gap-2 text-xs/relaxed leading-snug font-medium underline-offset-4 line-clamp-1 flex w-fit items-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
'text-muted-foreground text-left text-xs/relaxed [&>a:hover]:text-primary line-clamp-2 font-normal [&>a]:underline [&>a]:underline-offset-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn('gap-2 flex items-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
'gap-2 flex basis-full items-center justify-between',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
'gap-2 flex basis-full items-center justify-between',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemFooter,
|
||||
ItemGroup,
|
||||
ItemHeader,
|
||||
ItemMedia,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
import { Label as LabelPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
function Label({
|
||||
className,
|
||||
@@ -11,12 +11,12 @@ function Label({
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"gap-2 text-xs/relaxed leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
|
||||
className
|
||||
'gap-2 text-xs/relaxed leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
53
src/components/ui/scroll-area.tsx
Normal file
53
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="rounded-full bg-border relative flex-1"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -14,7 +14,7 @@ const SearchInput = ({ keywords, setKeyword, onChange }: SearchInputProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<InputGroup className="w-70">
|
||||
<InputGroup className="w-70 bg-white">
|
||||
<InputGroupInput
|
||||
id="keywords"
|
||||
placeholder="Search...."
|
||||
|
||||
189
src/components/ui/select-user.tsx
Normal file
189
src/components/ui/select-user.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@lib/utils';
|
||||
import { CaretDownIcon, MagnifyingGlassIcon } from '@phosphor-icons/react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
type SelectUserItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const userLabel = (u: { name: string; email: string }) =>
|
||||
`${u.name} - ${u.email}`;
|
||||
|
||||
type SelectUserProps = {
|
||||
value: string;
|
||||
onValueChange: (userId: string) => void;
|
||||
values: SelectUserItem[];
|
||||
placeholder?: string;
|
||||
/** Khi truyền cùng onKeywordChange: tìm kiếm theo API (keyword gửi lên server) */
|
||||
keyword?: string;
|
||||
onKeywordChange?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
'aria-invalid'?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
selectKey?: 'id' | 'email';
|
||||
};
|
||||
|
||||
export function SelectUser({
|
||||
value,
|
||||
onValueChange,
|
||||
values,
|
||||
placeholder,
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
searchPlaceholder = 'Tìm theo tên hoặc email...',
|
||||
name,
|
||||
id,
|
||||
'aria-invalid': ariaInvalid,
|
||||
disabled = false,
|
||||
className,
|
||||
selectKey = 'id',
|
||||
}: SelectUserProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [localQuery, setLocalQuery] = useState('');
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const useServerSearch = keyword !== undefined && onKeywordChange != null;
|
||||
const searchValue = useServerSearch ? keyword : localQuery;
|
||||
const setSearchValue = useServerSearch ? onKeywordChange! : setLocalQuery;
|
||||
|
||||
const selectedUser =
|
||||
value != null && value !== ''
|
||||
? values.find((u) => u[selectKey] === value)
|
||||
: null;
|
||||
const displayValue = selectedUser ? userLabel(selectedUser) : '';
|
||||
|
||||
const filtered = useServerSearch
|
||||
? values
|
||||
: (() => {
|
||||
const q = localQuery.trim().toLowerCase();
|
||||
return q === ''
|
||||
? values
|
||||
: values.filter(
|
||||
(u) =>
|
||||
u.name.toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q),
|
||||
);
|
||||
})();
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
if (!useServerSearch) setLocalQuery('');
|
||||
}, [useServerSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
searchInputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(e.target as Node)
|
||||
) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
return () => document.removeEventListener('mousedown', onMouseDown);
|
||||
}, [open, close]);
|
||||
|
||||
const handleSelect = (userId: string) => {
|
||||
onValueChange(userId);
|
||||
close();
|
||||
};
|
||||
|
||||
const controlId = id ?? name;
|
||||
const listboxId = controlId ? `${controlId}-listbox` : undefined;
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={cn('relative', className)}>
|
||||
{name != null && (
|
||||
<input type="hidden" name={name} value={value} readOnly />
|
||||
)}
|
||||
<div
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-controls={open ? listboxId : undefined}
|
||||
aria-disabled={disabled}
|
||||
id={controlId}
|
||||
className={cn(
|
||||
'border-input bg-input/20 dark:bg-input/30 flex h-7 w-full cursor-pointer items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs/relaxed outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
onClick={() => !disabled && setOpen((o) => !o)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{displayValue || (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
</span>
|
||||
<CaretDownIcon
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{open && (
|
||||
<div
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="border-input bg-popover text-popover-foreground absolute top-full z-50 mt-1 max-h-60 w-full min-w-(--radix-popper-anchor-width) overflow-hidden rounded-lg border shadow-md"
|
||||
>
|
||||
<div className="border-input flex items-center gap-1 border-b px-2 py-1">
|
||||
<MagnifyingGlassIcon className="text-muted-foreground size-3.5 shrink-0" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
autoComplete="off"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="min-w-0 flex-1 bg-transparent py-1 text-xs outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto p-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-muted-foreground py-4 text-center text-xs">
|
||||
Không có kết quả
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === u[selectKey]}
|
||||
className={cn(
|
||||
'hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center rounded-md px-2 py-1.5 text-left text-xs/relaxed outline-none',
|
||||
value === u[selectKey] &&
|
||||
'bg-accent text-accent-foreground',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSelect(u[selectKey]);
|
||||
}}
|
||||
>
|
||||
{userLabel(u)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Select as SelectPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@lib/utils';
|
||||
import { CaretDownIcon, CaretUpIcon, CheckIcon } from '@phosphor-icons/react';
|
||||
|
||||
function Select({
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
import { Separator as SeparatorPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
@@ -15,12 +15,12 @@ function Separator({
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
|
||||
className
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
import { Dialog as SheetPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "@phosphor-icons/react"
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@lib/utils';
|
||||
import { XIcon } from '@phosphor-icons/react';
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
@@ -34,21 +34,24 @@ function SheetOverlay({
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-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 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
|
||||
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 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
side = 'right',
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
@@ -56,42 +59,48 @@ function SheetContent({
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn("bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm", className)}
|
||||
className={cn(
|
||||
'bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||
<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm">
|
||||
<XIcon
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-4 right-4"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("gap-1.5 p-6 flex flex-col", className)}
|
||||
className={cn('gap-1.5 p-6 flex flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("gap-2 p-6 mt-auto flex flex-col", className)}
|
||||
className={cn('gap-2 p-6 mt-auto flex flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
@@ -101,10 +110,10 @@ function SheetTitle({
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground text-sm font-medium", className)}
|
||||
className={cn('text-foreground text-sm font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
@@ -114,19 +123,19 @@ function SheetDescription({
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-xs/relaxed", className)}
|
||||
className={cn('text-muted-foreground text-xs/relaxed', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
};
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIsMobile } from '@hooks/use-mobile';
|
||||
import { cn } from '@lib/utils';
|
||||
import { SidebarIcon } from '@phosphor-icons/react';
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-muted rounded-md animate-pulse", className)}
|
||||
className={cn('bg-muted rounded-md animate-pulse', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
export { Skeleton };
|
||||
|
||||
10
src/components/ui/spinner.tsx
Normal file
10
src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { SpinnerIcon } from "@phosphor-icons/react"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<SpinnerIcon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
@@ -1,99 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
return (
|
||||
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||
<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)}
|
||||
className={cn('w-full caption-bottom text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
className={cn('[&_tr]:border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
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)}
|
||||
className={cn(
|
||||
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
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)}
|
||||
className={cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
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)}
|
||||
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">) {
|
||||
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)}
|
||||
className={cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
}: React.ComponentProps<'caption'>) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-xs", className)}
|
||||
className={cn('text-muted-foreground mt-4 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Tooltip as TooltipPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@lib/utils';
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import AdminCreateUserForm from '@form/user/admin-create-user-form';
|
||||
import useHasPermission from '@hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { PlusIcon } from '@phosphor-icons/react';
|
||||
import { useState } from 'react';
|
||||
import AdminCreateUserForm from '../form/admin-create-user-form';
|
||||
import { Button } from '../ui/button';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,12 +11,17 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog';
|
||||
} from '@ui/dialog';
|
||||
import { useState } from 'react';
|
||||
|
||||
const AddNewUserButton = () => {
|
||||
const { hasPermission, isLoading } = useHasPermission('user', 'create');
|
||||
const [_open, _setOpen] = useState(false);
|
||||
const prevent = usePreventAutoFocus();
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (hasPermission) {
|
||||
return (
|
||||
<Dialog open={_open} onOpenChange={_setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -31,7 +36,7 @@ const AddNewUserButton = () => {
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-600">
|
||||
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-primary">
|
||||
<PlusIcon size={16} />
|
||||
{m.nav_add_new()}
|
||||
</DialogTitle>
|
||||
@@ -43,6 +48,9 @@ const AddNewUserButton = () => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AddNewUserButton;
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { usersQueries } from '@/service/queries';
|
||||
import { banUser } from '@/service/user.api';
|
||||
import { ReturnError } from '@/types/common';
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { ShieldWarningIcon } from '@phosphor-icons/react';
|
||||
import { usersQueries } from '@service/queries';
|
||||
import { banUser } from '@service/user.api';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { toast } from 'sonner';
|
||||
import DisplayBreakLineMessage from '../DisplayBreakLineMessage';
|
||||
import { Button } from '../ui/button';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -17,7 +13,18 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
} from '@ui/dialog';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { toast } from 'sonner';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../ui/table';
|
||||
import { useBanContext } from './ban-user-dialog';
|
||||
|
||||
type BanConfirmProps = {
|
||||
@@ -29,7 +36,7 @@ const BanUserConfirm = ({ data }: BanConfirmProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const prevent = usePreventAutoFocus();
|
||||
|
||||
const { mutate: banUserMutation } = useMutation({
|
||||
const { mutate: banUserMutation, isPending } = useMutation({
|
||||
mutationFn: banUser,
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({
|
||||
@@ -42,7 +49,10 @@ const BanUserConfirm = ({ data }: BanConfirmProps) => {
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
toast.error(m.backend_message({ code: error.code }), {
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
@@ -58,6 +68,7 @@ const BanUserConfirm = ({ data }: BanConfirmProps) => {
|
||||
showCloseButton={false}
|
||||
{...prevent}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-500">
|
||||
@@ -70,23 +81,62 @@ const BanUserConfirm = ({ data }: BanConfirmProps) => {
|
||||
{m.users_page_ui_dialog_alert_ban_title()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DisplayBreakLineMessage>
|
||||
{m.users_page_ui_dialog_alert_description({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table className="bg-white">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-primary">
|
||||
<TableHead className="px-2 h-7 text-white text-xs" colSpan={2}>
|
||||
{m.users_page_ui_dialog_alert_description_title()}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-bold">
|
||||
{m.users_page_ui_table_header_name()}:
|
||||
</TableCell>
|
||||
<TableCell>{data.name}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-bold">
|
||||
{m.users_page_ui_table_header_email()}:
|
||||
</TableCell>
|
||||
<TableCell>{data.email}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-bold">
|
||||
{m.users_page_ui_form_ban_reason()}:
|
||||
</TableCell>
|
||||
<TableCell>{submitData.banReason}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-bold">
|
||||
{m.users_page_ui_form_ban_exp()}:
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{m.exp_time({
|
||||
time: submitData.banExp.toString() as Parameters<
|
||||
typeof m.exp_time
|
||||
>[0]['time'],
|
||||
})}
|
||||
{m.users_page_ui_dialog_alert_description_2({
|
||||
reason: submitData.banReason,
|
||||
exp: m.exp_time({ time: submitData.banExp }),
|
||||
})}
|
||||
</DisplayBreakLineMessage>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter className="bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{m.ui_cancel_btn()}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" type="button" onClick={onConfirm}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending && <Spinner data-icon="inline-start" />}
|
||||
{m.ui_confirm_btn()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import useHasPermission from '@/hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import BanUserForm from '@form/user/admin-ban-user-form';
|
||||
import useHasPermission from '@hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { LockIcon } from '@phosphor-icons/react';
|
||||
import { useRouteContext } from '@tanstack/react-router';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import BanUserForm from '../form/admin-ban-user-form';
|
||||
import { Button } from '../ui/button';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,9 +12,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog';
|
||||
import { Label } from '../ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
} from '@ui/dialog';
|
||||
import { Label } from '@ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import BanUserConfirm from './ban-user-confirm-dialog';
|
||||
|
||||
type ChangeUserStatusProps = {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import useHasPermission from '@/hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import AdminSetUserRoleForm from '@form/user/admin-set-user-role-form';
|
||||
import useHasPermission from '@hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { UserGearIcon } from '@phosphor-icons/react';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { useState } from 'react';
|
||||
import AdminSetUserRoleForm from '../form/admin-set-user-role-form';
|
||||
import { Button } from '../ui/button';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,9 +11,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog';
|
||||
import { Label } from '../ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
} from '@ui/dialog';
|
||||
import { Label } from '@ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { useState } from 'react';
|
||||
|
||||
type SetRoleProps = {
|
||||
data: UserWithRole;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import useHasPermission from '@/hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import AdminUpdateUserInfoForm from '@form/user/admin-update-user-info-form';
|
||||
import useHasPermission from '@hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { PenIcon } from '@phosphor-icons/react';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { useState } from 'react';
|
||||
import AdminUpdateUserInfoForm from '../form/admin-update-user-info-form';
|
||||
import { Button } from '../ui/button';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,9 +11,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog';
|
||||
import { Label } from '../ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
} from '@ui/dialog';
|
||||
import { Label } from '@ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { useState } from 'react';
|
||||
|
||||
type EditUserProps = {
|
||||
data: UserWithRole;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import useHasPermission from '@/hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import AdminSetPasswordForm from '@form/user/admin-set-password-form';
|
||||
import useHasPermission from '@hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { KeyIcon } from '@phosphor-icons/react';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { useState } from 'react';
|
||||
import AdminSetPasswordForm from '../form/admin-set-password-form';
|
||||
import { Button } from '../ui/button';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,9 +11,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog';
|
||||
import { Label } from '../ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
} from '@ui/dialog';
|
||||
import { Label } from '@ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { useState } from 'react';
|
||||
|
||||
type UpdatePasswordProps = {
|
||||
data: UserWithRole;
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import useHasPermission from '@/hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { usersQueries } from '@/service/queries';
|
||||
import { unbanUser } from '@/service/user.api';
|
||||
import { ReturnError } from '@/types/common';
|
||||
import useHasPermission from '@hooks/use-has-permission';
|
||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { LockOpenIcon, ShieldWarningIcon } from '@phosphor-icons/react';
|
||||
import { usersQueries } from '@service/queries';
|
||||
import { unbanUser } from '@service/user.api';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouteContext } from '@tanstack/react-router';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import DisplayBreakLineMessage from '../DisplayBreakLineMessage';
|
||||
import { Button } from '../ui/button';
|
||||
import { Button } from '@ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -21,9 +16,21 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog';
|
||||
import { Label } from '../ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
} from '@ui/dialog';
|
||||
import { Label } from '@ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../ui/table';
|
||||
|
||||
type UnbanUserProps = {
|
||||
data: UserWithRole;
|
||||
@@ -38,7 +45,7 @@ const UnbanUserAction = ({ data }: UnbanUserProps) => {
|
||||
const [_open, _setOpen] = useState(false);
|
||||
const prevent = usePreventAutoFocus();
|
||||
|
||||
const { mutate: unbanMutation } = useMutation({
|
||||
const { mutate: unbanMutation, isPending } = useMutation({
|
||||
mutationFn: unbanUser,
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({
|
||||
@@ -54,7 +61,10 @@ const UnbanUserAction = ({ data }: UnbanUserProps) => {
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
toast.error(m.backend_message({ code: error.code }), {
|
||||
const code = error.code as Parameters<
|
||||
typeof m.backend_message
|
||||
>[0]['code'];
|
||||
toast.error(m.backend_message({ code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
@@ -91,6 +101,7 @@ const UnbanUserAction = ({ data }: UnbanUserProps) => {
|
||||
showCloseButton={false}
|
||||
{...prevent}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-green-500">
|
||||
@@ -103,19 +114,47 @@ const UnbanUserAction = ({ data }: UnbanUserProps) => {
|
||||
{m.users_page_ui_dialog_alert_title()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DisplayBreakLineMessage>
|
||||
{m.users_page_ui_dialog_alert_description({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
})}
|
||||
</DisplayBreakLineMessage>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table className="bg-white">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-primary">
|
||||
<TableHead
|
||||
className="px-2 h-7 text-white text-xs"
|
||||
colSpan={2}
|
||||
>
|
||||
{m.users_page_ui_dialog_alert_description_title()}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-bold">
|
||||
{m.users_page_ui_table_header_name()}:
|
||||
</TableCell>
|
||||
<TableCell>{data.name}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-bold">
|
||||
{m.users_page_ui_table_header_email()}:
|
||||
</TableCell>
|
||||
<TableCell>{data.email}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter className="bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{m.ui_cancel_btn()}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" type="button" onClick={onConfirm}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending && <Spinner data-icon="inline-start" />}
|
||||
{m.ui_confirm_btn()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { formatters } from '@/utils/formatters';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { formatters } from '@utils/formatters';
|
||||
import { UserWithRole } from 'better-auth/plugins';
|
||||
import RoleBadge from '../avatar/role-badge';
|
||||
import BanUserAction from './ban-user-dialog';
|
||||
|
||||
@@ -17,8 +17,8 @@ import type * as Prisma from "./prismaNamespace.ts"
|
||||
|
||||
const config: runtime.GetPrismaClientConfig = {
|
||||
"previewFeatures": [],
|
||||
"clientVersion": "7.2.0",
|
||||
"engineVersion": "0c8ef2ce45c83248ab3df073180d5eda9e8be7a3",
|
||||
"clientVersion": "7.3.0",
|
||||
"engineVersion": "9d6ad21cbbceab97458517b147a6a09ff43aa735",
|
||||
"activeProvider": "postgresql",
|
||||
"inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"../src/generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n id String @id @default(uuid())\n name String\n email String\n emailVerified Boolean @default(false)\n image String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n sessions Session[]\n accounts Account[]\n audit Audit[]\n\n role String?\n banned Boolean? @default(false)\n banReason String?\n banExpires DateTime?\n\n members Member[]\n invitations Invitation[]\n\n @@unique([email])\n @@map(\"user\")\n}\n\nmodel Session {\n id String @id @default(uuid())\n expiresAt DateTime\n token String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n ipAddress String?\n userAgent String?\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n impersonatedBy String?\n\n activeOrganizationId String?\n\n @@unique([token])\n @@index([userId])\n @@map(\"session\")\n}\n\nmodel Account {\n id String @id @default(uuid())\n accountId String\n providerId String\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n accessToken String?\n refreshToken String?\n idToken String?\n accessTokenExpiresAt DateTime?\n refreshTokenExpiresAt DateTime?\n scope String?\n password String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@map(\"account\")\n}\n\nmodel Verification {\n id String @id @default(uuid())\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([identifier])\n @@map(\"verification\")\n}\n\nmodel Organization {\n id String @id @default(uuid())\n name String\n slug String\n logo String?\n createdAt DateTime\n metadata String?\n members Member[]\n invitations Invitation[]\n\n color String? @default(\"#000000\")\n\n @@unique([slug])\n @@map(\"organization\")\n}\n\nmodel Member {\n id String @id @default(uuid())\n organizationId String\n organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n role String @default(\"member\")\n createdAt DateTime\n\n @@index([organizationId])\n @@index([userId])\n @@map(\"member\")\n}\n\nmodel Invitation {\n id String @id @default(uuid())\n organizationId String\n organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)\n email String\n role String?\n status String @default(\"pending\")\n expiresAt DateTime\n createdAt DateTime @default(now())\n inviterId String\n user User @relation(fields: [inviterId], references: [id], onDelete: Cascade)\n\n @@index([organizationId])\n @@index([email])\n @@map(\"invitation\")\n}\n\nmodel Setting {\n id String @id @default(uuid())\n key String @unique\n value String\n description String\n relation String @default(\"admin\")\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@map(\"setting\")\n}\n\nmodel Audit {\n id String @id @default(uuid())\n userId String\n action String\n tableName String\n recordId String\n oldValue String?\n newValue String?\n createdAt DateTime @default(now())\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@map(\"audit\")\n}\n",
|
||||
"runtimeDataModel": {
|
||||
@@ -37,12 +37,14 @@ async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Modul
|
||||
}
|
||||
|
||||
config.compilerWasm = {
|
||||
getRuntime: async () => await import("@prisma/client/runtime/query_compiler_bg.postgresql.mjs"),
|
||||
getRuntime: async () => await import("@prisma/client/runtime/query_compiler_fast_bg.postgresql.mjs"),
|
||||
|
||||
getQueryCompilerWasmModule: async () => {
|
||||
const { wasm } = await import("@prisma/client/runtime/query_compiler_bg.postgresql.wasm-base64.mjs")
|
||||
const { wasm } = await import("@prisma/client/runtime/query_compiler_fast_bg.postgresql.wasm-base64.mjs")
|
||||
return await decodeBase64AsWasm(wasm)
|
||||
}
|
||||
},
|
||||
|
||||
importName: "./query_compiler_fast_bg.js"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -80,12 +80,12 @@ export type PrismaVersion = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma Client JS version: 7.2.0
|
||||
* Query Engine version: 0c8ef2ce45c83248ab3df073180d5eda9e8be7a3
|
||||
* Prisma Client JS version: 7.3.0
|
||||
* Query Engine version: 9d6ad21cbbceab97458517b147a6a09ff43aa735
|
||||
*/
|
||||
export const prismaVersion: PrismaVersion = {
|
||||
client: "7.2.0",
|
||||
engine: "0c8ef2ce45c83248ab3df073180d5eda9e8be7a3"
|
||||
client: "7.3.0",
|
||||
engine: "9d6ad21cbbceab97458517b147a6a09ff43aa735"
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,12 +68,12 @@ export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
* Enums
|
||||
*/
|
||||
|
||||
export const TransactionIsolationLevel = {
|
||||
export const TransactionIsolationLevel = runtime.makeStrictEnum({
|
||||
ReadUncommitted: 'ReadUncommitted',
|
||||
ReadCommitted: 'ReadCommitted',
|
||||
RepeatableRead: 'RepeatableRead',
|
||||
Serializable: 'Serializable'
|
||||
} as const
|
||||
} as const)
|
||||
|
||||
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
HiddenField,
|
||||
Select,
|
||||
SelectNumber,
|
||||
SelectUser,
|
||||
SubscribeButton,
|
||||
TextArea,
|
||||
TextField,
|
||||
@@ -20,6 +21,7 @@ export const { useAppForm } = createFormHook({
|
||||
Select,
|
||||
SelectNumber,
|
||||
FileField,
|
||||
SelectUser,
|
||||
},
|
||||
formComponents: {
|
||||
SubscribeButton,
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { authClient } from '@lib/auth-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function useHasPermission(resource: string, action: string) {
|
||||
function useHasPermission(
|
||||
resource: string,
|
||||
action: string,
|
||||
houseCheck: boolean = false,
|
||||
) {
|
||||
const [hasPermission, setHasPermission] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkPermission = async () => {
|
||||
try {
|
||||
const access = await authClient.admin.hasPermission({
|
||||
const option = {
|
||||
permissions: {
|
||||
[resource]: [action],
|
||||
},
|
||||
});
|
||||
};
|
||||
const access = houseCheck
|
||||
? await authClient.organization.hasPermission(option)
|
||||
: await authClient.admin.hasPermission(option);
|
||||
|
||||
setHasPermission(access.data?.success ?? false);
|
||||
} catch (error) {
|
||||
console.error('Permission check failed:', error);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { sessionQueries } from '@/service/queries';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export function useSessionQuery() {
|
||||
return useQuery(sessionQueries.user());
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ac, admin, user } from '@/lib/auth/permissions';
|
||||
import { ac, admin, user } from '@lib/auth/permissions';
|
||||
import { adminClient, organizationClient } from 'better-auth/client/plugins';
|
||||
import { createAuthClient } from 'better-auth/react';
|
||||
import {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { prisma } from '@/db';
|
||||
import { User } from '@/generated/prisma/client';
|
||||
import { SessionModel } from '@/generated/prisma/models';
|
||||
import { DB_TABLE, LOG_ACTION } from '@/types/enum';
|
||||
import {
|
||||
acOrg,
|
||||
adminOrg,
|
||||
member,
|
||||
owner,
|
||||
} from '@/lib/auth/organization-permissions';
|
||||
import { ac, admin, user } from '@/lib/auth/permissions';
|
||||
import { createAuditLog } from '@/service/repository';
|
||||
import { DB_TABLE, LOG_ACTION } from '@/types/enum';
|
||||
} from '@lib/auth/organization-permissions';
|
||||
import { ac, admin, user } from '@lib/auth/permissions';
|
||||
import { createAuditLog, getInitialOrganization } from '@service/repository';
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
||||
import { admin as adminPlugin, organization } from 'better-auth/plugins';
|
||||
@@ -61,8 +61,8 @@ export const auth = betterAuth({
|
||||
after: async (user) => {
|
||||
await auth.api.createOrganization({
|
||||
body: {
|
||||
name: `${user.name || 'User'}'s Organization`,
|
||||
slug: `${user.name?.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}`,
|
||||
name: `${user.name || 'User'}'s House`,
|
||||
slug: `${user.name?.toLowerCase().replace(/\s+/g, '-')}-house-${Date.now()}`,
|
||||
userId: user.id,
|
||||
color: '#000000',
|
||||
},
|
||||
@@ -77,46 +77,18 @@ export const auth = betterAuth({
|
||||
});
|
||||
},
|
||||
},
|
||||
update: {
|
||||
before: async (user, ctx) => {
|
||||
if (ctx?.context.session && ctx?.path === '/update-user') {
|
||||
const newUser = JSON.parse(JSON.stringify(user));
|
||||
const keys = Object.keys(newUser);
|
||||
const oldUser = Object.fromEntries(
|
||||
Object.entries(ctx?.context.session?.user).filter(([key]) =>
|
||||
keys.includes(key),
|
||||
),
|
||||
);
|
||||
await createAuditLog({
|
||||
action: LOG_ACTION.UPDATE,
|
||||
tableName: DB_TABLE.USER,
|
||||
recordId: ctx?.context.session?.user.id,
|
||||
oldValue: JSON.stringify(oldUser),
|
||||
newValue: JSON.stringify(newUser),
|
||||
userId: ctx?.context.session?.user.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
account: {
|
||||
update: {
|
||||
after: async (account, context) => {
|
||||
if (context?.path === '/change-password') {
|
||||
await createAuditLog({
|
||||
action: LOG_ACTION.CHANGE_PASSWORD,
|
||||
tableName: DB_TABLE.ACCOUNT,
|
||||
recordId: account.id,
|
||||
oldValue: 'Change Password',
|
||||
newValue: 'Change Password',
|
||||
userId: account.userId,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: {
|
||||
before: async (session) => {
|
||||
const organization = await getInitialOrganization(session.userId);
|
||||
return {
|
||||
data: {
|
||||
...session,
|
||||
activeOrganizationId: organization?.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
after: async (session, context) => {
|
||||
if (context?.path.includes('/sign-in')) {
|
||||
await createAuditLog({
|
||||
|
||||
1
src/lib/errors/index.ts
Normal file
1
src/lib/errors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './parse-error';
|
||||
77
src/lib/errors/parse-error.ts
Normal file
77
src/lib/errors/parse-error.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Prisma } from '@/generated/prisma/client';
|
||||
|
||||
export type ErrorBody = {
|
||||
message?: unknown;
|
||||
code?: unknown;
|
||||
status?: unknown;
|
||||
};
|
||||
|
||||
function hasErrorBody(error: unknown): error is { body: ErrorBody } {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'body' in error &&
|
||||
typeof (error as any).body === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
export function parseError(error: unknown): {
|
||||
message: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
} {
|
||||
// Recognize AppError even if it's a plain object from network
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
(error as any).name === 'AppError' &&
|
||||
'code' in error &&
|
||||
'message' in error &&
|
||||
'status' in error
|
||||
) {
|
||||
return {
|
||||
message: (error as any).message,
|
||||
code: (error as any).code,
|
||||
status: (error as any).status,
|
||||
};
|
||||
}
|
||||
|
||||
// better-auth / fetch error (có body)
|
||||
if (hasErrorBody(error)) {
|
||||
return {
|
||||
message:
|
||||
typeof error.body.message === 'string'
|
||||
? error.body.message
|
||||
: 'Unknown error',
|
||||
code: typeof error.body.code === 'string' ? error.body.code : undefined,
|
||||
status:
|
||||
typeof error.body.status === 'number' ? error.body.status : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Prisma (giữ nguyên code như "P2002")
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
// Error thường
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
message: error.message,
|
||||
code: 'NORMAL_ERROR',
|
||||
status: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return {
|
||||
message: String(error),
|
||||
code: 'NORMAL_ERROR',
|
||||
status: undefined,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export const authMiddleware = createMiddleware({ type: 'function' }).server(
|
||||
name: session?.user?.name,
|
||||
email: session?.user?.email,
|
||||
image: session?.user?.image,
|
||||
activeHouseId: session?.session?.activeOrganizationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,14 +14,18 @@ import { Route as appIndexRouteImport } from './routes/(app)/index'
|
||||
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 appauthDashboardRouteImport } from './routes/(app)/(auth)/dashboard'
|
||||
import { Route as appauthManagementRouteRouteImport } from './routes/(app)/(auth)/management/route'
|
||||
import { Route as appauthKanriRouteRouteImport } from './routes/(app)/(auth)/kanri/route'
|
||||
import { Route as appauthAccountRouteRouteImport } from './routes/(app)/(auth)/account/route'
|
||||
import { Route as appauthManagementIndexRouteImport } from './routes/(app)/(auth)/management/index'
|
||||
import { Route as appauthKanriIndexRouteImport } from './routes/(app)/(auth)/kanri/index'
|
||||
import { Route as appauthAccountIndexRouteImport } from './routes/(app)/(auth)/account/index'
|
||||
import { Route as appauthManagementHousesRouteImport } from './routes/(app)/(auth)/management/houses'
|
||||
import { Route as appauthManagementDashboardRouteImport } from './routes/(app)/(auth)/management/dashboard'
|
||||
import { Route as appauthKanriUsersRouteImport } from './routes/(app)/(auth)/kanri/users'
|
||||
import { Route as appauthKanriSettingsRouteImport } from './routes/(app)/(auth)/kanri/settings'
|
||||
import { Route as appauthKanriLogsRouteImport } from './routes/(app)/(auth)/kanri/logs'
|
||||
import { Route as appauthKanriHousesRouteImport } from './routes/(app)/(auth)/kanri/houses'
|
||||
import { Route as appauthAccountSettingsRouteImport } from './routes/(app)/(auth)/account/settings'
|
||||
import { Route as appauthAccountProfileRouteImport } from './routes/(app)/(auth)/account/profile'
|
||||
import { Route as appauthAccountChangePasswordRouteImport } from './routes/(app)/(auth)/account/change-password'
|
||||
@@ -49,9 +53,9 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
path: '/api/auth/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const appauthDashboardRoute = appauthDashboardRouteImport.update({
|
||||
id: '/dashboard',
|
||||
path: '/dashboard',
|
||||
const appauthManagementRouteRoute = appauthManagementRouteRouteImport.update({
|
||||
id: '/management',
|
||||
path: '/management',
|
||||
getParentRoute: () => appauthRouteRoute,
|
||||
} as any)
|
||||
const appauthKanriRouteRoute = appauthKanriRouteRouteImport.update({
|
||||
@@ -64,6 +68,11 @@ const appauthAccountRouteRoute = appauthAccountRouteRouteImport.update({
|
||||
path: '/account',
|
||||
getParentRoute: () => appauthRouteRoute,
|
||||
} as any)
|
||||
const appauthManagementIndexRoute = appauthManagementIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => appauthManagementRouteRoute,
|
||||
} as any)
|
||||
const appauthKanriIndexRoute = appauthKanriIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -74,6 +83,17 @@ const appauthAccountIndexRoute = appauthAccountIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => appauthAccountRouteRoute,
|
||||
} as any)
|
||||
const appauthManagementHousesRoute = appauthManagementHousesRouteImport.update({
|
||||
id: '/houses',
|
||||
path: '/houses',
|
||||
getParentRoute: () => appauthManagementRouteRoute,
|
||||
} as any)
|
||||
const appauthManagementDashboardRoute =
|
||||
appauthManagementDashboardRouteImport.update({
|
||||
id: '/dashboard',
|
||||
path: '/dashboard',
|
||||
getParentRoute: () => appauthManagementRouteRoute,
|
||||
} as any)
|
||||
const appauthKanriUsersRoute = appauthKanriUsersRouteImport.update({
|
||||
id: '/users',
|
||||
path: '/users',
|
||||
@@ -89,6 +109,11 @@ const appauthKanriLogsRoute = appauthKanriLogsRouteImport.update({
|
||||
path: '/logs',
|
||||
getParentRoute: () => appauthKanriRouteRoute,
|
||||
} as any)
|
||||
const appauthKanriHousesRoute = appauthKanriHousesRouteImport.update({
|
||||
id: '/houses',
|
||||
path: '/houses',
|
||||
getParentRoute: () => appauthKanriRouteRoute,
|
||||
} as any)
|
||||
const appauthAccountSettingsRoute = appauthAccountSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
@@ -111,30 +136,37 @@ export interface FileRoutesByFullPath {
|
||||
'/': typeof appIndexRoute
|
||||
'/account': typeof appauthAccountRouteRouteWithChildren
|
||||
'/kanri': typeof appauthKanriRouteRouteWithChildren
|
||||
'/dashboard': typeof appauthDashboardRoute
|
||||
'/management': typeof appauthManagementRouteRouteWithChildren
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/account/change-password': typeof appauthAccountChangePasswordRoute
|
||||
'/account/profile': typeof appauthAccountProfileRoute
|
||||
'/account/settings': typeof appauthAccountSettingsRoute
|
||||
'/kanri/houses': typeof appauthKanriHousesRoute
|
||||
'/kanri/logs': typeof appauthKanriLogsRoute
|
||||
'/kanri/settings': typeof appauthKanriSettingsRoute
|
||||
'/kanri/users': typeof appauthKanriUsersRoute
|
||||
'/management/dashboard': typeof appauthManagementDashboardRoute
|
||||
'/management/houses': typeof appauthManagementHousesRoute
|
||||
'/account/': typeof appauthAccountIndexRoute
|
||||
'/kanri/': typeof appauthKanriIndexRoute
|
||||
'/management/': typeof appauthManagementIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/sign-in': typeof authSignInRoute
|
||||
'/': typeof appIndexRoute
|
||||
'/dashboard': typeof appauthDashboardRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/account/change-password': typeof appauthAccountChangePasswordRoute
|
||||
'/account/profile': typeof appauthAccountProfileRoute
|
||||
'/account/settings': typeof appauthAccountSettingsRoute
|
||||
'/kanri/houses': typeof appauthKanriHousesRoute
|
||||
'/kanri/logs': typeof appauthKanriLogsRoute
|
||||
'/kanri/settings': typeof appauthKanriSettingsRoute
|
||||
'/kanri/users': typeof appauthKanriUsersRoute
|
||||
'/management/dashboard': typeof appauthManagementDashboardRoute
|
||||
'/management/houses': typeof appauthManagementHousesRoute
|
||||
'/account': typeof appauthAccountIndexRoute
|
||||
'/kanri': typeof appauthKanriIndexRoute
|
||||
'/management': typeof appauthManagementIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -144,16 +176,20 @@ export interface FileRoutesById {
|
||||
'/(app)/': typeof appIndexRoute
|
||||
'/(app)/(auth)/account': typeof appauthAccountRouteRouteWithChildren
|
||||
'/(app)/(auth)/kanri': typeof appauthKanriRouteRouteWithChildren
|
||||
'/(app)/(auth)/dashboard': typeof appauthDashboardRoute
|
||||
'/(app)/(auth)/management': typeof appauthManagementRouteRouteWithChildren
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/(app)/(auth)/account/change-password': typeof appauthAccountChangePasswordRoute
|
||||
'/(app)/(auth)/account/profile': typeof appauthAccountProfileRoute
|
||||
'/(app)/(auth)/account/settings': typeof appauthAccountSettingsRoute
|
||||
'/(app)/(auth)/kanri/houses': typeof appauthKanriHousesRoute
|
||||
'/(app)/(auth)/kanri/logs': typeof appauthKanriLogsRoute
|
||||
'/(app)/(auth)/kanri/settings': typeof appauthKanriSettingsRoute
|
||||
'/(app)/(auth)/kanri/users': typeof appauthKanriUsersRoute
|
||||
'/(app)/(auth)/management/dashboard': typeof appauthManagementDashboardRoute
|
||||
'/(app)/(auth)/management/houses': typeof appauthManagementHousesRoute
|
||||
'/(app)/(auth)/account/': typeof appauthAccountIndexRoute
|
||||
'/(app)/(auth)/kanri/': typeof appauthKanriIndexRoute
|
||||
'/(app)/(auth)/management/': typeof appauthManagementIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -162,30 +198,37 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/account'
|
||||
| '/kanri'
|
||||
| '/dashboard'
|
||||
| '/management'
|
||||
| '/api/auth/$'
|
||||
| '/account/change-password'
|
||||
| '/account/profile'
|
||||
| '/account/settings'
|
||||
| '/kanri/houses'
|
||||
| '/kanri/logs'
|
||||
| '/kanri/settings'
|
||||
| '/kanri/users'
|
||||
| '/management/dashboard'
|
||||
| '/management/houses'
|
||||
| '/account/'
|
||||
| '/kanri/'
|
||||
| '/management/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/sign-in'
|
||||
| '/'
|
||||
| '/dashboard'
|
||||
| '/api/auth/$'
|
||||
| '/account/change-password'
|
||||
| '/account/profile'
|
||||
| '/account/settings'
|
||||
| '/kanri/houses'
|
||||
| '/kanri/logs'
|
||||
| '/kanri/settings'
|
||||
| '/kanri/users'
|
||||
| '/management/dashboard'
|
||||
| '/management/houses'
|
||||
| '/account'
|
||||
| '/kanri'
|
||||
| '/management'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/(app)'
|
||||
@@ -194,16 +237,20 @@ export interface FileRouteTypes {
|
||||
| '/(app)/'
|
||||
| '/(app)/(auth)/account'
|
||||
| '/(app)/(auth)/kanri'
|
||||
| '/(app)/(auth)/dashboard'
|
||||
| '/(app)/(auth)/management'
|
||||
| '/api/auth/$'
|
||||
| '/(app)/(auth)/account/change-password'
|
||||
| '/(app)/(auth)/account/profile'
|
||||
| '/(app)/(auth)/account/settings'
|
||||
| '/(app)/(auth)/kanri/houses'
|
||||
| '/(app)/(auth)/kanri/logs'
|
||||
| '/(app)/(auth)/kanri/settings'
|
||||
| '/(app)/(auth)/kanri/users'
|
||||
| '/(app)/(auth)/management/dashboard'
|
||||
| '/(app)/(auth)/management/houses'
|
||||
| '/(app)/(auth)/account/'
|
||||
| '/(app)/(auth)/kanri/'
|
||||
| '/(app)/(auth)/management/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -249,11 +296,11 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(app)/(auth)/dashboard': {
|
||||
id: '/(app)/(auth)/dashboard'
|
||||
path: '/dashboard'
|
||||
fullPath: '/dashboard'
|
||||
preLoaderRoute: typeof appauthDashboardRouteImport
|
||||
'/(app)/(auth)/management': {
|
||||
id: '/(app)/(auth)/management'
|
||||
path: '/management'
|
||||
fullPath: '/management'
|
||||
preLoaderRoute: typeof appauthManagementRouteRouteImport
|
||||
parentRoute: typeof appauthRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/kanri': {
|
||||
@@ -270,6 +317,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof appauthAccountRouteRouteImport
|
||||
parentRoute: typeof appauthRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/management/': {
|
||||
id: '/(app)/(auth)/management/'
|
||||
path: '/'
|
||||
fullPath: '/management/'
|
||||
preLoaderRoute: typeof appauthManagementIndexRouteImport
|
||||
parentRoute: typeof appauthManagementRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/kanri/': {
|
||||
id: '/(app)/(auth)/kanri/'
|
||||
path: '/'
|
||||
@@ -284,6 +338,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof appauthAccountIndexRouteImport
|
||||
parentRoute: typeof appauthAccountRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/management/houses': {
|
||||
id: '/(app)/(auth)/management/houses'
|
||||
path: '/houses'
|
||||
fullPath: '/management/houses'
|
||||
preLoaderRoute: typeof appauthManagementHousesRouteImport
|
||||
parentRoute: typeof appauthManagementRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/management/dashboard': {
|
||||
id: '/(app)/(auth)/management/dashboard'
|
||||
path: '/dashboard'
|
||||
fullPath: '/management/dashboard'
|
||||
preLoaderRoute: typeof appauthManagementDashboardRouteImport
|
||||
parentRoute: typeof appauthManagementRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/kanri/users': {
|
||||
id: '/(app)/(auth)/kanri/users'
|
||||
path: '/users'
|
||||
@@ -305,6 +373,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof appauthKanriLogsRouteImport
|
||||
parentRoute: typeof appauthKanriRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/kanri/houses': {
|
||||
id: '/(app)/(auth)/kanri/houses'
|
||||
path: '/houses'
|
||||
fullPath: '/kanri/houses'
|
||||
preLoaderRoute: typeof appauthKanriHousesRouteImport
|
||||
parentRoute: typeof appauthKanriRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/account/settings': {
|
||||
id: '/(app)/(auth)/account/settings'
|
||||
path: '/settings'
|
||||
@@ -347,6 +422,7 @@ const appauthAccountRouteRouteWithChildren =
|
||||
appauthAccountRouteRoute._addFileChildren(appauthAccountRouteRouteChildren)
|
||||
|
||||
interface appauthKanriRouteRouteChildren {
|
||||
appauthKanriHousesRoute: typeof appauthKanriHousesRoute
|
||||
appauthKanriLogsRoute: typeof appauthKanriLogsRoute
|
||||
appauthKanriSettingsRoute: typeof appauthKanriSettingsRoute
|
||||
appauthKanriUsersRoute: typeof appauthKanriUsersRoute
|
||||
@@ -354,6 +430,7 @@ interface appauthKanriRouteRouteChildren {
|
||||
}
|
||||
|
||||
const appauthKanriRouteRouteChildren: appauthKanriRouteRouteChildren = {
|
||||
appauthKanriHousesRoute: appauthKanriHousesRoute,
|
||||
appauthKanriLogsRoute: appauthKanriLogsRoute,
|
||||
appauthKanriSettingsRoute: appauthKanriSettingsRoute,
|
||||
appauthKanriUsersRoute: appauthKanriUsersRoute,
|
||||
@@ -363,16 +440,34 @@ const appauthKanriRouteRouteChildren: appauthKanriRouteRouteChildren = {
|
||||
const appauthKanriRouteRouteWithChildren =
|
||||
appauthKanriRouteRoute._addFileChildren(appauthKanriRouteRouteChildren)
|
||||
|
||||
interface appauthManagementRouteRouteChildren {
|
||||
appauthManagementDashboardRoute: typeof appauthManagementDashboardRoute
|
||||
appauthManagementHousesRoute: typeof appauthManagementHousesRoute
|
||||
appauthManagementIndexRoute: typeof appauthManagementIndexRoute
|
||||
}
|
||||
|
||||
const appauthManagementRouteRouteChildren: appauthManagementRouteRouteChildren =
|
||||
{
|
||||
appauthManagementDashboardRoute: appauthManagementDashboardRoute,
|
||||
appauthManagementHousesRoute: appauthManagementHousesRoute,
|
||||
appauthManagementIndexRoute: appauthManagementIndexRoute,
|
||||
}
|
||||
|
||||
const appauthManagementRouteRouteWithChildren =
|
||||
appauthManagementRouteRoute._addFileChildren(
|
||||
appauthManagementRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface appauthRouteRouteChildren {
|
||||
appauthAccountRouteRoute: typeof appauthAccountRouteRouteWithChildren
|
||||
appauthKanriRouteRoute: typeof appauthKanriRouteRouteWithChildren
|
||||
appauthDashboardRoute: typeof appauthDashboardRoute
|
||||
appauthManagementRouteRoute: typeof appauthManagementRouteRouteWithChildren
|
||||
}
|
||||
|
||||
const appauthRouteRouteChildren: appauthRouteRouteChildren = {
|
||||
appauthAccountRouteRoute: appauthAccountRouteRouteWithChildren,
|
||||
appauthKanriRouteRoute: appauthKanriRouteRouteWithChildren,
|
||||
appauthDashboardRoute: appauthDashboardRoute,
|
||||
appauthManagementRouteRoute: appauthManagementRouteRouteWithChildren,
|
||||
}
|
||||
|
||||
const appauthRouteRouteWithChildren = appauthRouteRoute._addFileChildren(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ChangePasswordForm from '@/components/form/change-password-form';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import ChangePasswordForm from '@form/account/change-password-form';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/account/change-password')({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ProfileForm from '@/components/form/profile-form';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import ProfileForm from '@form/account/profile-form';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/account/profile')({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/account')({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import UserSettingsForm from '@/components/form/user-settings-form';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import UserSettingsForm from '@form/account/user-settings-form';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/account/settings')({
|
||||
|
||||
82
src/routes/(app)/(auth)/kanri/houses.tsx
Normal file
82
src/routes/(app)/(auth)/kanri/houses.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import DataTable from '@/components/DataTable';
|
||||
import CreateNewHouse from '@/components/house/create-house-dialog';
|
||||
import { houseColumns } from '@/components/house/house-column';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import SearchInput from '@/components/ui/search-input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import useDebounced from '@hooks/use-debounced';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { WarehouseIcon } from '@phosphor-icons/react';
|
||||
import { housesQueries } from '@service/queries';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/kanri/houses')({
|
||||
component: RouteComponent,
|
||||
staticData: { breadcrumb: () => m.nav_houses() },
|
||||
});
|
||||
|
||||
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(
|
||||
housesQueries.list({
|
||||
page,
|
||||
limit: pageLimit,
|
||||
keyword: debouncedSearch,
|
||||
}),
|
||||
);
|
||||
|
||||
const onSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchKeyword(e.target.value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="@container/main flex flex-1 flex-col gap-2 p-4">
|
||||
<Skeleton className="h-130 w-full" />
|
||||
</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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<WarehouseIcon size={24} />
|
||||
{m.houses_page_ui_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<SearchInput
|
||||
keywords={searchKeyword}
|
||||
setKeyword={setSearchKeyword}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
<CreateNewHouse />
|
||||
</div>
|
||||
{data && (
|
||||
<DataTable
|
||||
data={data.result || []}
|
||||
columns={houseColumns}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
limit={pageLimit}
|
||||
setLimit={setPageLimit}
|
||||
pagination={data.pagination}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { logColumns } from '@/components/audit/audit-columns';
|
||||
import DataTable from '@/components/DataTable';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import SearchInput from '@/components/ui/search-input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import useDebounced from '@/hooks/use-debounced';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { auditQueries } from '@/service/queries';
|
||||
import useDebounced from '@hooks/use-debounced';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { CircuitryIcon } from '@phosphor-icons/react';
|
||||
import { auditQueries } from '@service/queries';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
@@ -37,17 +37,14 @@ function RouteComponent() {
|
||||
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>
|
||||
<Skeleton className="h-150 w-full" />
|
||||
</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">
|
||||
<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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
@@ -55,7 +52,7 @@ function RouteComponent() {
|
||||
{m.logs_page_ui_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center">
|
||||
<SearchInput
|
||||
keywords={searchKeyword}
|
||||
@@ -74,6 +71,8 @@ function RouteComponent() {
|
||||
pagination={data.pagination}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SettingsForm from '@/components/form/settings-form';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/kanri/settings')({
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import DataTable from '@/components/DataTable';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import SearchInput from '@/components/ui/search-input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import AddNewUserButton from '@/components/user/add-new-user-dialog';
|
||||
import { userColumns } from '@/components/user/user-column';
|
||||
import useDebounced from '@/hooks/use-debounced';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { usersQueries } from '@/service/queries';
|
||||
import useDebounced from '@hooks/use-debounced';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { UsersIcon } from '@phosphor-icons/react';
|
||||
import { usersQueries } from '@service/queries';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
@@ -39,25 +39,22 @@ function RouteComponent() {
|
||||
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">
|
||||
<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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<UsersIcon size={24} />
|
||||
{m.users_page_ui_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<SearchInput
|
||||
keywords={searchKeyword}
|
||||
@@ -77,6 +74,8 @@ function RouteComponent() {
|
||||
pagination={data.pagination}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { m } from '@paraglide/messages';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/dashboard')({
|
||||
export const Route = createFileRoute('/(app)/(auth)/management/dashboard')({
|
||||
component: RouteComponent,
|
||||
staticData: { breadcrumb: () => m.nav_dashboard() },
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user