feature/houses #11

Merged
sam merged 7 commits from feature/houses into develop 2026-02-12 15:45:48 +00:00
25 changed files with 570 additions and 103 deletions
Showing only changes of commit afa26ab50d - Show all commits

View File

@@ -56,6 +56,8 @@
"ui_view_all_notifications": "View All Notifications", "ui_view_all_notifications": "View All Notifications",
"ui_label_notifications": "Notifications", "ui_label_notifications": "Notifications",
"ui_change_password_btn": "Change password", "ui_change_password_btn": "Change password",
"nav_label_management": "Management",
"nav_label_basic": "Basic",
"nav_home": "Home", "nav_home": "Home",
"nav_dashboard": "Dashboard", "nav_dashboard": "Dashboard",
"nav_settings": "Settings", "nav_settings": "Settings",
@@ -63,8 +65,8 @@
"nav_edit": "Edit", "nav_edit": "Edit",
"nav_change_password": "Change password", "nav_change_password": "Change password",
"nav_logs": "Logs", "nav_logs": "Logs",
"nav_users": "Người dùng", "nav_users": "Users",
"nav_roles": "Vai trò & quyền hạn", "nav_houses": "Houses",
"nav_box": "Box", "nav_box": "Box",
"nav_account": "Account", "nav_account": "Account",
"nav_profile": "Profile", "nav_profile": "Profile",
@@ -148,6 +150,13 @@
"users_page_ui_dialog_alert_ban_title": "", "users_page_ui_dialog_alert_ban_title": "",
"users_page_ui_dialog_alert_description": "Detail: \nName: {name}. \nEmail: {email}", "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_description_2": "Reason: {reason}. \nExpiration: {exp}",
"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": "Quyền hạn",
"backend_message": [ "backend_message": [
{ {
"match": { "match": {

View File

@@ -52,10 +52,12 @@
"ui_update_password_btn": "Đặt lại mật khẩu", "ui_update_password_btn": "Đặt lại mật khẩu",
"ui_change_role_btn": "Đặt lại quyền hạn", "ui_change_role_btn": "Đặt lại quyền hạn",
"ui_edit_user_btn": "Chỉnh sửa người dùng", "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_view_all_notifications": "Xem tất cả thông báo",
"ui_label_notifications": "Thông báo", "ui_label_notifications": "Thông báo",
"ui_change_password_btn": "Đổi mật khẩu", "ui_change_password_btn": "Đổi mật khẩu",
"nav_label_management": "Quản lý",
"nav_label_basic": "Cơ bản",
"nav_home": "Trang chủ", "nav_home": "Trang chủ",
"nav_dashboard": "Bảng điều khiển", "nav_dashboard": "Bảng điều khiển",
"nav_settings": "Cài đặt", "nav_settings": "Cài đặt",
@@ -64,7 +66,7 @@
"nav_change_password": "Đổi mật khẩu", "nav_change_password": "Đổi mật khẩu",
"nav_logs": "Lịch sử", "nav_logs": "Lịch sử",
"nav_users": "Người dùng", "nav_users": "Người dùng",
"nav_roles": "Vai trò & quyền hạn", "nav_houses": "Nhà",
"nav_box": "Hộp chứa", "nav_box": "Hộp chứa",
"nav_account": "Tài khoản", "nav_account": "Tài khoản",
"nav_profile": "Hồ sơ", "nav_profile": "Hồ sơ",
@@ -148,6 +150,13 @@
"users_page_ui_dialog_alert_ban_title": "Bạn muốn 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": "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_2": "\nLý do: {reason}. \nHiệu lực: {exp}",
"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",
"backend_message": [ "backend_message": [
{ {
"match": { "match": {

View File

@@ -56,7 +56,7 @@ const DataTable = <TData, TValue>({
return ( return (
<> <>
<div className="overflow-hidden rounded-md border"> <div className="overflow-hidden rounded-md border">
<Table> <Table className="bg-white">
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
@@ -66,7 +66,7 @@ const DataTable = <TData, TValue>({
key={header.id} key={header.id}
colSpan={header.colSpan} colSpan={header.colSpan}
className={cn( className={cn(
'px-4', 'px-4 bg-primary text-white text-sm',
header.column.columnDef.meta?.thClass, header.column.columnDef.meta?.thClass,
)} )}
> >

View File

@@ -5,7 +5,7 @@ import { Badge } from '../ui/badge';
import { LOG_ACTION } from '@/types/enum'; import { LOG_ACTION } from '@/types/enum';
import ActionBadge from './action-badge'; import ActionBadge from './action-badge';
import ViewDetail from './view-detail-dialog'; import ViewDetailAudit from './view-detail-dialog';
export const logColumns: ColumnDef<AuditWithUser>[] = [ export const logColumns: ColumnDef<AuditWithUser>[] = [
{ {
@@ -56,7 +56,7 @@ export const logColumns: ColumnDef<AuditWithUser>[] = [
}, },
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex justify-end"> <div className="flex justify-end">
<ViewDetail data={row.original} /> <ViewDetailAudit data={row.original} />
</div> </div>
), ),
}, },

View File

@@ -23,7 +23,7 @@ type ViewDetailProps = {
data: AuditWithUser; data: AuditWithUser;
}; };
const ViewDetail = ({ data }: ViewDetailProps) => { const ViewDetailAudit = ({ data }: ViewDetailProps) => {
const prevent = usePreventAutoFocus(); const prevent = usePreventAutoFocus();
const { isCopied, copyToClipboard } = useCopyToClipboard(); const { isCopied, copyToClipboard } = useCopyToClipboard();
@@ -134,4 +134,4 @@ const ViewDetail = ({ data }: ViewDetailProps) => {
); );
}; };
export default ViewDetail; export default ViewDetailAudit;

View File

@@ -74,6 +74,9 @@ const ProfileForm = () => {
); );
} catch (error) { } catch (error) {
console.error('update load file', error); console.error('update load file', error);
toast.error(JSON.stringify(error), {
richColors: true,
});
} }
}, },
}); });

View File

@@ -0,0 +1,46 @@
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
import { m } from '@/paraglide/messages';
import { PlusIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog';
const CreateNewHouse = () => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="default">
<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>
</DialogContent>
</Dialog>
);
};
export default CreateNewHouse;

View File

@@ -0,0 +1,47 @@
import { m } from '@/paraglide/messages';
import { formatters } from '@/utils/formatters';
import { ColumnDef } from '@tanstack/react-table';
import ViewDetailHouse from './view-detail-dialog';
export const houseColumns: ColumnDef<OrganizationWithMembers>[] = [
{
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">
<ViewDetailHouse data={row.original} />
</div>
);
},
},
];

View File

@@ -0,0 +1,120 @@
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
import { m } from '@/paraglide/messages';
import { formatters } from '@/utils/formatters';
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';
type ViewDetailProps = {
data: OrganizationWithMembers;
};
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-blue-500 hover:bg-blue-100 hover:text-blue-600"
>
<EyeIcon size={16} />
<span className="sr-only">{m.ui_view_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent
side="left"
className="bg-blue-500 [&_svg]:bg-blue-500 [&_svg]:fill-blue-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-blue-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>
<TableCell>{member.user.email}</TableCell>
<TableCell>{member.role}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default ViewDetailHouse;

View File

@@ -5,6 +5,7 @@ import {
GearIcon, GearIcon,
HouseIcon, HouseIcon,
UsersIcon, UsersIcon,
WarehouseIcon,
} from '@phosphor-icons/react'; } from '@phosphor-icons/react';
import { createLink } from '@tanstack/react-router'; import { createLink } from '@tanstack/react-router';
import AdminShow from '../auth/AdminShow'; import AdminShow from '../auth/AdminShow';
@@ -23,7 +24,7 @@ const SidebarMenuButtonLink = createLink(SidebarMenuButton);
const NAV_MAIN = [ const NAV_MAIN = [
{ {
id: '1', id: '1',
title: 'Basic', title: m.nav_label_basic(),
items: [ items: [
{ {
title: m.nav_home(), title: m.nav_home(),
@@ -43,8 +44,15 @@ const NAV_MAIN = [
}, },
{ {
id: '2', id: '2',
title: 'Management', title: m.nav_label_management(),
items: [ items: [
{
title: m.nav_houses(),
path: '/kanri/houses',
icon: WarehouseIcon,
isAuth: false,
admin: true,
},
{ {
title: m.nav_users(), title: m.nav_users(),
path: '/kanri/users', path: '/kanri/users',

View File

@@ -14,7 +14,7 @@ const SearchInput = ({ keywords, setKeyword, onChange }: SearchInputProps) => {
}; };
return ( return (
<InputGroup className="w-70"> <InputGroup className="w-70 bg-white">
<InputGroupInput <InputGroupInput
id="keywords" id="keywords"
placeholder="Search...." placeholder="Search...."

View File

@@ -31,7 +31,7 @@ const AddNewUserButton = () => {
onPointerDownOutside={(e) => e.preventDefault()} onPointerDownOutside={(e) => e.preventDefault()}
> >
<DialogHeader> <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} /> <PlusIcon size={16} />
{m.nav_add_new()} {m.nav_add_new()}
</DialogTitle> </DialogTitle>

View File

@@ -61,8 +61,8 @@ export const auth = betterAuth({
after: async (user) => { after: async (user) => {
await auth.api.createOrganization({ await auth.api.createOrganization({
body: { body: {
name: `${user.name || 'User'}'s Organization`, name: `${user.name || 'User'}'s House`,
slug: `${user.name?.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}`, slug: `${user.name?.toLowerCase().replace(/\s+/g, '-')}-house-${Date.now()}`,
userId: user.id, userId: user.id,
color: '#000000', color: '#000000',
}, },

View File

@@ -22,6 +22,7 @@ import { Route as appauthAccountIndexRouteImport } from './routes/(app)/(auth)/a
import { Route as appauthKanriUsersRouteImport } from './routes/(app)/(auth)/kanri/users' import { Route as appauthKanriUsersRouteImport } from './routes/(app)/(auth)/kanri/users'
import { Route as appauthKanriSettingsRouteImport } from './routes/(app)/(auth)/kanri/settings' import { Route as appauthKanriSettingsRouteImport } from './routes/(app)/(auth)/kanri/settings'
import { Route as appauthKanriLogsRouteImport } from './routes/(app)/(auth)/kanri/logs' 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 appauthAccountSettingsRouteImport } from './routes/(app)/(auth)/account/settings'
import { Route as appauthAccountProfileRouteImport } from './routes/(app)/(auth)/account/profile' import { Route as appauthAccountProfileRouteImport } from './routes/(app)/(auth)/account/profile'
import { Route as appauthAccountChangePasswordRouteImport } from './routes/(app)/(auth)/account/change-password' import { Route as appauthAccountChangePasswordRouteImport } from './routes/(app)/(auth)/account/change-password'
@@ -89,6 +90,11 @@ const appauthKanriLogsRoute = appauthKanriLogsRouteImport.update({
path: '/logs', path: '/logs',
getParentRoute: () => appauthKanriRouteRoute, getParentRoute: () => appauthKanriRouteRoute,
} as any) } as any)
const appauthKanriHousesRoute = appauthKanriHousesRouteImport.update({
id: '/houses',
path: '/houses',
getParentRoute: () => appauthKanriRouteRoute,
} as any)
const appauthAccountSettingsRoute = appauthAccountSettingsRouteImport.update({ const appauthAccountSettingsRoute = appauthAccountSettingsRouteImport.update({
id: '/settings', id: '/settings',
path: '/settings', path: '/settings',
@@ -116,6 +122,7 @@ export interface FileRoutesByFullPath {
'/account/change-password': typeof appauthAccountChangePasswordRoute '/account/change-password': typeof appauthAccountChangePasswordRoute
'/account/profile': typeof appauthAccountProfileRoute '/account/profile': typeof appauthAccountProfileRoute
'/account/settings': typeof appauthAccountSettingsRoute '/account/settings': typeof appauthAccountSettingsRoute
'/kanri/houses': typeof appauthKanriHousesRoute
'/kanri/logs': typeof appauthKanriLogsRoute '/kanri/logs': typeof appauthKanriLogsRoute
'/kanri/settings': typeof appauthKanriSettingsRoute '/kanri/settings': typeof appauthKanriSettingsRoute
'/kanri/users': typeof appauthKanriUsersRoute '/kanri/users': typeof appauthKanriUsersRoute
@@ -130,6 +137,7 @@ export interface FileRoutesByTo {
'/account/change-password': typeof appauthAccountChangePasswordRoute '/account/change-password': typeof appauthAccountChangePasswordRoute
'/account/profile': typeof appauthAccountProfileRoute '/account/profile': typeof appauthAccountProfileRoute
'/account/settings': typeof appauthAccountSettingsRoute '/account/settings': typeof appauthAccountSettingsRoute
'/kanri/houses': typeof appauthKanriHousesRoute
'/kanri/logs': typeof appauthKanriLogsRoute '/kanri/logs': typeof appauthKanriLogsRoute
'/kanri/settings': typeof appauthKanriSettingsRoute '/kanri/settings': typeof appauthKanriSettingsRoute
'/kanri/users': typeof appauthKanriUsersRoute '/kanri/users': typeof appauthKanriUsersRoute
@@ -149,6 +157,7 @@ export interface FileRoutesById {
'/(app)/(auth)/account/change-password': typeof appauthAccountChangePasswordRoute '/(app)/(auth)/account/change-password': typeof appauthAccountChangePasswordRoute
'/(app)/(auth)/account/profile': typeof appauthAccountProfileRoute '/(app)/(auth)/account/profile': typeof appauthAccountProfileRoute
'/(app)/(auth)/account/settings': typeof appauthAccountSettingsRoute '/(app)/(auth)/account/settings': typeof appauthAccountSettingsRoute
'/(app)/(auth)/kanri/houses': typeof appauthKanriHousesRoute
'/(app)/(auth)/kanri/logs': typeof appauthKanriLogsRoute '/(app)/(auth)/kanri/logs': typeof appauthKanriLogsRoute
'/(app)/(auth)/kanri/settings': typeof appauthKanriSettingsRoute '/(app)/(auth)/kanri/settings': typeof appauthKanriSettingsRoute
'/(app)/(auth)/kanri/users': typeof appauthKanriUsersRoute '/(app)/(auth)/kanri/users': typeof appauthKanriUsersRoute
@@ -167,6 +176,7 @@ export interface FileRouteTypes {
| '/account/change-password' | '/account/change-password'
| '/account/profile' | '/account/profile'
| '/account/settings' | '/account/settings'
| '/kanri/houses'
| '/kanri/logs' | '/kanri/logs'
| '/kanri/settings' | '/kanri/settings'
| '/kanri/users' | '/kanri/users'
@@ -181,6 +191,7 @@ export interface FileRouteTypes {
| '/account/change-password' | '/account/change-password'
| '/account/profile' | '/account/profile'
| '/account/settings' | '/account/settings'
| '/kanri/houses'
| '/kanri/logs' | '/kanri/logs'
| '/kanri/settings' | '/kanri/settings'
| '/kanri/users' | '/kanri/users'
@@ -199,6 +210,7 @@ export interface FileRouteTypes {
| '/(app)/(auth)/account/change-password' | '/(app)/(auth)/account/change-password'
| '/(app)/(auth)/account/profile' | '/(app)/(auth)/account/profile'
| '/(app)/(auth)/account/settings' | '/(app)/(auth)/account/settings'
| '/(app)/(auth)/kanri/houses'
| '/(app)/(auth)/kanri/logs' | '/(app)/(auth)/kanri/logs'
| '/(app)/(auth)/kanri/settings' | '/(app)/(auth)/kanri/settings'
| '/(app)/(auth)/kanri/users' | '/(app)/(auth)/kanri/users'
@@ -305,6 +317,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof appauthKanriLogsRouteImport preLoaderRoute: typeof appauthKanriLogsRouteImport
parentRoute: typeof appauthKanriRouteRoute 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': { '/(app)/(auth)/account/settings': {
id: '/(app)/(auth)/account/settings' id: '/(app)/(auth)/account/settings'
path: '/settings' path: '/settings'
@@ -347,6 +366,7 @@ const appauthAccountRouteRouteWithChildren =
appauthAccountRouteRoute._addFileChildren(appauthAccountRouteRouteChildren) appauthAccountRouteRoute._addFileChildren(appauthAccountRouteRouteChildren)
interface appauthKanriRouteRouteChildren { interface appauthKanriRouteRouteChildren {
appauthKanriHousesRoute: typeof appauthKanriHousesRoute
appauthKanriLogsRoute: typeof appauthKanriLogsRoute appauthKanriLogsRoute: typeof appauthKanriLogsRoute
appauthKanriSettingsRoute: typeof appauthKanriSettingsRoute appauthKanriSettingsRoute: typeof appauthKanriSettingsRoute
appauthKanriUsersRoute: typeof appauthKanriUsersRoute appauthKanriUsersRoute: typeof appauthKanriUsersRoute
@@ -354,6 +374,7 @@ interface appauthKanriRouteRouteChildren {
} }
const appauthKanriRouteRouteChildren: appauthKanriRouteRouteChildren = { const appauthKanriRouteRouteChildren: appauthKanriRouteRouteChildren = {
appauthKanriHousesRoute: appauthKanriHousesRoute,
appauthKanriLogsRoute: appauthKanriLogsRoute, appauthKanriLogsRoute: appauthKanriLogsRoute,
appauthKanriSettingsRoute: appauthKanriSettingsRoute, appauthKanriSettingsRoute: appauthKanriSettingsRoute,
appauthKanriUsersRoute: appauthKanriUsersRoute, appauthKanriUsersRoute: appauthKanriUsersRoute,

View File

@@ -0,0 +1,81 @@
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 { housesQueries } from '@/service/queries';
import { WarehouseIcon } from '@phosphor-icons/react';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { useState } from 'react';
export const Route = createFileRoute('/(app)/(auth)/kanri/houses')({
component: RouteComponent,
});
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">
<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,6 +1,6 @@
import { logColumns } from '@/components/audit/audit-columns'; import { logColumns } from '@/components/audit/audit-columns';
import DataTable from '@/components/DataTable'; 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 SearchInput from '@/components/ui/search-input';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import useDebounced from '@/hooks/use-debounced'; import useDebounced from '@/hooks/use-debounced';
@@ -37,17 +37,14 @@ function RouteComponent() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="@container/main flex flex-1 flex-col gap-2 p-4"> <div className="@container/main flex flex-1 flex-col gap-2 p-4">
<div className="flex flex-col gap-4"> <Skeleton className="h-150 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-130 w-full" />
</div>
</div> </div>
); );
} }
return ( return (
<div className="@container/main flex flex-1 flex-col gap-2 p-4"> <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> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-xl flex items-center gap-2"> <CardTitle className="text-xl flex items-center gap-2">
@@ -55,25 +52,27 @@ function RouteComponent() {
{m.logs_page_ui_title()} {m.logs_page_ui_title()}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center">
<SearchInput
keywords={searchKeyword}
setKeyword={setSearchKeyword}
onChange={onSearchChange}
/>
</div>
{data?.result && (
<DataTable
data={data.result || []}
columns={logColumns}
page={page}
setPage={setPage}
limit={pageLimit}
setLimit={setPageLimit}
pagination={data.pagination}
/>
)}
</CardContent>
</Card> </Card>
<div className="flex items-center">
<SearchInput
keywords={searchKeyword}
setKeyword={setSearchKeyword}
onChange={onSearchChange}
/>
</div>
{data?.result && (
<DataTable
data={data.result || []}
columns={logColumns}
page={page}
setPage={setPage}
limit={pageLimit}
setLimit={setPageLimit}
pagination={data.pagination}
/>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
import DataTable from '@/components/DataTable'; 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 SearchInput from '@/components/ui/search-input';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import AddNewUserButton from '@/components/user/add-new-user-dialog'; import AddNewUserButton from '@/components/user/add-new-user-dialog';
@@ -39,44 +39,43 @@ function RouteComponent() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="@container/main flex flex-1 flex-col gap-2 p-4"> <div className="@container/main flex flex-1 flex-col gap-2 p-4">
<div className="flex flex-col gap-4"> <Skeleton className="h-130 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-130 w-full" />
</div>
</div> </div>
); );
} }
return ( return (
<div className="@container/main flex flex-1 flex-col gap-2 p-4"> <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> <Card>
<CardHeader> <CardHeader className="border-b">
<CardTitle className="text-xl flex items-center gap-2"> <CardTitle className="text-xl flex items-center gap-2">
<UsersIcon size={24} /> <UsersIcon size={24} />
{m.users_page_ui_title()} {m.users_page_ui_title()}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<SearchInput
keywords={searchKeyword}
setKeyword={setSearchKeyword}
onChange={onSearchChange}
/>
<AddNewUserButton />
</div>
{data && (
<DataTable
data={data.result || []}
columns={userColumns}
page={page}
setPage={setPage}
limit={pageLimit}
setLimit={setPageLimit}
pagination={data.pagination}
/>
)}
</CardContent>
</Card> </Card>
<div className="flex items-center justify-between">
<SearchInput
keywords={searchKeyword}
setKeyword={setSearchKeyword}
onChange={onSearchChange}
/>
<AddNewUserButton />
</div>
{data && (
<DataTable
data={data.result || []}
columns={userColumns}
page={page}
setPage={setPage}
limit={pageLimit}
setLimit={setPageLimit}
pagination={data.pagination}
/>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,7 @@
import { prisma } from '@/db'; import { prisma } from '@/db';
import { AuditWhereInput } from '@/generated/prisma/models'; import { AuditWhereInput } from '@/generated/prisma/models';
import { authMiddleware } from '@/lib/middleware'; import { authMiddleware } from '@/lib/middleware';
import { parseError } from '@/utils/helper';
import { createServerFn } from '@tanstack/react-start'; import { createServerFn } from '@tanstack/react-start';
import { auditListSchema } from './audit.schema'; import { auditListSchema } from './audit.schema';
@@ -62,7 +63,8 @@ export const getAllAudit = createServerFn({ method: 'GET' })
}, },
}; };
} catch (error) { } catch (error) {
console.log(error); console.error(error);
throw error; const { message, code } = parseError(error);
throw { message, code };
} }
}); });

67
src/service/house.api.ts Normal file
View File

@@ -0,0 +1,67 @@
import { prisma } from '@/db';
import { OrganizationWhereInput } from '@/generated/prisma/models';
import { authMiddleware } from '@/lib/middleware';
import { parseError } from '@/utils/helper';
import { createServerFn } from '@tanstack/react-start';
import { houseListSchema } from './house.schema';
export const getAllHouse = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.inputValidator(houseListSchema)
.handler(async ({ data }) => {
try {
const { page, limit, keyword } = data;
const skip = (page - 1) * limit;
const where: OrganizationWhereInput = {
OR: [
{
name: {
contains: keyword,
mode: 'insensitive',
},
},
],
};
const [list, total]: [OrganizationWithMembers[], number] =
await Promise.all([
await prisma.organization.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
members: {
select: {
role: true,
user: {
select: {
name: true,
email: true,
image: true,
},
},
},
},
},
take: limit,
skip,
}),
await prisma.organization.count({ where }),
]);
const totalPage = Math.ceil(+total / limit);
return {
result: list,
pagination: {
currentPage: page,
totalPage,
totalItem: total,
},
};
} catch (error) {
console.error(error);
const { message, code } = parseError(error);
throw { message, code };
}
});

View File

@@ -0,0 +1,12 @@
import { m } from '@/paraglide/messages';
import z from 'zod';
export const baseHouse = z.object({
id: z.string().nonempty(m.users_page_message_user_not_found()),
});
export const houseListSchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(10).max(100).default(10),
keyword: z.string().optional(),
});

View File

@@ -1,6 +1,7 @@
import { getSession } from '@/lib/auth/session'; import { getSession } from '@/lib/auth/session';
import { queryOptions } from '@tanstack/react-query'; import { queryOptions } from '@tanstack/react-query';
import { getAllAudit } from './audit.api'; import { getAllAudit } from './audit.api';
import { getAllHouse } from './house.api';
import { import {
getAdminSettings, getAdminSettings,
getCurrentUserLanguage, getCurrentUserLanguage,
@@ -55,3 +56,12 @@ export const usersQueries = {
queryFn: () => getAllUser({ data: params }), queryFn: () => getAllUser({ data: params }),
}), }),
}; };
export const housesQueries = {
all: ['houses'],
list: (params: { page: number; limit: number; keyword?: string }) =>
queryOptions({
queryKey: [...housesQueries.all, 'list', params],
queryFn: () => getAllHouse({ data: params }),
}),
};

View File

@@ -1,5 +1,6 @@
import { prisma } from '@/db'; import { prisma } from '@/db';
import { Audit, Setting } from '@/generated/prisma/client'; import { Audit, Setting } from '@/generated/prisma/client';
import { parseError } from '@/utils/helper';
type AdminSettingValue = Pick<Setting, 'id' | 'key' | 'value'>; type AdminSettingValue = Pick<Setting, 'id' | 'key' | 'value'>;
@@ -48,7 +49,8 @@ export const createAuditLog = async (data: Omit<Audit, 'id' | 'createdAt'>) => {
}, },
}); });
} catch (error) { } catch (error) {
console.log(error); console.error(error);
throw error; const { message, code } = parseError(error);
throw { message, code };
} }
}; };

View File

@@ -1,6 +1,6 @@
import { prisma } from '@/db'; import { prisma } from '@/db';
import { authMiddleware } from '@/lib/middleware'; import { authMiddleware } from '@/lib/middleware';
import { extractDiffObjects } from '@/utils/helper'; import { extractDiffObjects, parseError } from '@/utils/helper';
import { createServerFn } from '@tanstack/react-start'; import { createServerFn } from '@tanstack/react-start';
import { createAuditLog, getAllAdminSettings } from './repository'; import { createAuditLog, getAllAdminSettings } from './repository';
import { settingSchema, userSettingSchema } from './setting.schema'; import { settingSchema, userSettingSchema } from './setting.schema';
@@ -25,8 +25,9 @@ export const getCurrentUserLanguage = createServerFn({ method: 'GET' })
return value.language; return value.language;
} catch (error) { } catch (error) {
console.log(error); console.error(error);
throw error; const { message, code } = parseError(error);
throw { message, code };
} }
}); });
@@ -71,8 +72,9 @@ export const updateAdminSettings = createServerFn({ method: 'POST' })
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.log(error); console.error(error);
throw error; const { message, code } = parseError(error);
throw { message, code };
} }
}); });
@@ -102,8 +104,9 @@ export const getUserSettings = createServerFn({ method: 'GET' })
value: JSON.parse(settings.value) as UserSetting, value: JSON.parse(settings.value) as UserSetting,
}; };
} catch (error) { } catch (error) {
console.log(error); console.error(error);
throw error; const { message, code } = parseError(error);
throw { message, code };
} }
}); });
@@ -147,7 +150,8 @@ export const updateUserSettings = createServerFn({ method: 'POST' })
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.log(error); console.error(error);
throw error; const { message, code } = parseError(error);
throw { message, code };
} }
}); });

View File

@@ -20,33 +20,39 @@ export const getAllUser = createServerFn({ method: 'GET' })
.middleware([authMiddleware]) .middleware([authMiddleware])
.inputValidator(userListSchema) .inputValidator(userListSchema)
.handler(async ({ data }) => { .handler(async ({ data }) => {
const headers = getRequestHeaders(); try {
const { page, limit, keyword } = data; const headers = getRequestHeaders();
const { page, limit, keyword } = data;
const list = await auth.api.listUsers({ const list = await auth.api.listUsers({
query: { query: {
searchValue: keyword, searchValue: keyword,
searchField: 'name', searchField: 'name',
searchOperator: 'contains', searchOperator: 'contains',
sortBy: 'createdAt', sortBy: 'createdAt',
sortDirection: 'asc', sortDirection: 'asc',
limit, limit,
offset: (page - 1) * limit, offset: (page - 1) * limit,
}, },
headers, headers,
}); });
const totalItem = list.total; const totalItem = list.total;
const totalPage = Math.ceil(totalItem / limit); const totalPage = Math.ceil(totalItem / limit);
return { return {
result: list.users, result: list.users,
pagination: { pagination: {
currentPage: page, currentPage: page,
totalPage, totalPage,
totalItem, totalItem,
}, },
}; };
} catch (error) {
console.error(error);
const { message, code } = parseError(error);
throw { message, code };
}
}); });
export const setUserPassword = createServerFn({ method: 'POST' }) export const setUserPassword = createServerFn({ method: 'POST' })
@@ -74,6 +80,7 @@ export const setUserPassword = createServerFn({ method: 'POST' })
return result; return result;
} catch (error) { } catch (error) {
console.error(error);
const { message, code } = parseError(error); const { message, code } = parseError(error);
throw { message, code }; throw { message, code };
} }
@@ -112,6 +119,7 @@ export const updateUserInformation = createServerFn({ method: 'POST' })
return result; return result;
} catch (error) { } catch (error) {
console.error(error);
const { message, code } = parseError(error); const { message, code } = parseError(error);
throw { message, code }; throw { message, code };
} }
@@ -150,6 +158,7 @@ export const setUserRole = createServerFn({ method: 'POST' })
return result; return result;
} catch (error) { } catch (error) {
console.error(error);
const { message, code } = parseError(error); const { message, code } = parseError(error);
throw { message, code }; throw { message, code };
} }
@@ -185,6 +194,7 @@ export const banUser = createServerFn({ method: 'POST' })
return result; return result;
} catch (error) { } catch (error) {
console.error(error);
const { message, code } = parseError(error); const { message, code } = parseError(error);
throw { message, code }; throw { message, code };
} }
@@ -215,6 +225,7 @@ export const unbanUser = createServerFn({ method: 'POST' })
return result; return result;
} catch (error) { } catch (error) {
console.error(error);
const { message, code } = parseError(error); const { message, code } = parseError(error);
throw { message, code }; throw { message, code };
} }

17
src/types/db.d.ts vendored
View File

@@ -11,4 +11,21 @@ declare global {
}; };
}; };
}>; }>;
type OrganizationWithMembers = Prisma.OrganizationGetPayload<{
include: {
members: {
select: {
role: true;
user: {
select: {
name: true;
email: true;
image: true;
};
};
};
};
};
}>;
} }