feature/houses #11

Merged
sam merged 7 commits from feature/houses into develop 2026-02-12 15:45:48 +00:00
126 changed files with 5996 additions and 2862 deletions

View File

@@ -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!"
}
}
]

View File

@@ -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 ý!"
}
}
]

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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';

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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 />

View File

@@ -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;

View File

@@ -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>
),
},

View File

@@ -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;

View File

@@ -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(
() => ({

View File

@@ -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 {

View File

@@ -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>
);
};

View File

@@ -1,4 +1,4 @@
import { cn } from '@/lib/utils';
import { cn } from '@lib/utils';
const RING_TYPE = {
admin: 'after:inset-ring-cyan-500',

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View 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;

View 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;

View 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;

View File

@@ -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>

View File

@@ -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

View File

@@ -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);

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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;

View 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;

View 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>
:&nbsp;
{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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
},
},
];

View 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;

View 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;

View File

@@ -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>
);
})}
</>
);
};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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",

View File

@@ -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,
};

View File

@@ -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',

View File

@@ -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'>) {

View File

@@ -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,
}
};

View File

@@ -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: {

View File

@@ -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,
};

View File

@@ -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}

View File

@@ -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({

View File

@@ -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 (

View File

@@ -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,
}
};

View File

@@ -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
View 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,
};

View File

@@ -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 };

View 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 }

View File

@@ -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...."

View 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 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>
);
}

View File

@@ -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({

View File

@@ -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 };

View File

@@ -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,
};

View File

@@ -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';

View File

@@ -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 };

View 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 }

View File

@@ -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,
}
};

View File

@@ -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 (

View File

@@ -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,

View File

@@ -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;

View File

@@ -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>

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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';

View File

@@ -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"
}

View File

@@ -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"
}
/**

View File

@@ -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]

View File

@@ -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,

View File

@@ -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);

View File

@@ -1,6 +0,0 @@
import { sessionQueries } from '@/service/queries';
import { useQuery } from '@tanstack/react-query';
export function useSessionQuery() {
return useQuery(sessionQueries.user());
}

View File

@@ -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 {

View File

@@ -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
View File

@@ -0,0 +1 @@
export * from './parse-error';

View 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,
};
}

View File

@@ -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,
},
},
});

View File

@@ -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(

View File

@@ -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')({

View File

@@ -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')({

View File

@@ -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')({

View File

@@ -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')({

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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')({

View File

@@ -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>
);

View File

@@ -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