add function for user

- create house
- edit house
- delete house
- list all member for active house
This commit is contained in:
2026-02-08 13:43:14 +07:00
parent 42435faa7f
commit 1d3e79c546
40 changed files with 1006 additions and 170 deletions

View File

@@ -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",
@@ -58,6 +59,7 @@
"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",
@@ -166,6 +168,9 @@
"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",
"backend_message": [
{
"match": {
@@ -174,7 +179,8 @@
"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=BANNED_USER": "Your account get banned, please contact administrator for more information!",
"code=VALIDATION_ERROR": "Some field value invalid!"
"code=VALIDATION_ERROR": "Some field value invalid!",
"code=USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION": "User is not a member of the house"
}
}
]

View File

@@ -49,6 +49,7 @@
"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",
@@ -59,6 +60,7 @@
"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",
@@ -167,6 +169,9 @@
"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",
"backend_message": [
{
"match": {
@@ -175,7 +180,8 @@
"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=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=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"
}
}
]

View File

@@ -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,4 +1,3 @@
import { ReturnError } from '@/types/common';
import { useAppForm } from '@hooks/use-app-form';
import { m } from '@paraglide/messages';
import { Locale, setLocale } from '@paraglide/runtime';
@@ -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);
@@ -51,7 +50,7 @@ const UserSettingsForm = () => {
onChange: userSettingSchema,
},
onSubmit: ({ value }) => {
updateMutation.mutate({ data: value as UserSettingInput });
updateMutation({ data: value as UserSettingInput });
},
});
@@ -102,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

@@ -8,21 +8,31 @@ 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 { 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}>
{label}
</Button>
)}
{(isSubmitting) => {
return (
<Button
type="submit"
disabled={isSubmitting || disabled}
variant={variant}
>
{(isSubmitting || disabled) && <Spinner data-icon="inline-start" />}
{label}
</Button>
);
}}
</form.Subscribe>
);
}

View File

@@ -1,4 +1,4 @@
import { ReturnError } from '@/types/common';
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';
@@ -12,14 +12,16 @@ import { Button } from '@ui/button';
import { DialogClose, DialogFooter } from '@ui/dialog';
import { Field, FieldGroup } from '@ui/field';
import { slugify } from '@utils/helper';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
type FormProps = {
onSubmit: (open: boolean) => void;
isPersonal?: boolean;
};
const CreateNewHouseForm = ({ onSubmit }: FormProps) => {
const CreateNewHouseForm = ({ onSubmit, isPersonal = false }: FormProps) => {
const { data: session } = useAuth();
const [userKeyword, setUserKeyword] = useState('');
const debouncedUserKeyword = useDebounced(userKeyword, 300);
const { data: users } = useQuery(
@@ -28,11 +30,13 @@ const CreateNewHouseForm = ({ onSubmit }: FormProps) => {
const queryClient = useQueryClient();
const { mutate: createHouseMutation } = useMutation({
const queryKey = isPersonal ? 'currentUser' : 'list';
const { mutate: createHouseMutation, isPending } = useMutation({
mutationFn: createHouse,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...housesQueries.all, 'list'],
queryKey: [...housesQueries.all, queryKey],
});
onSubmit(false);
toast.success(m.houses_page_message_create_house_success(), {
@@ -76,6 +80,13 @@ const CreateNewHouseForm = ({ onSubmit }: FormProps) => {
},
});
useEffect(() => {
if (isPersonal) {
form.setFieldValue('userId', session.user.id);
}
console.log(isPending);
}, []);
return (
<form
id="admin-create-house-form"
@@ -94,17 +105,19 @@ const CreateNewHouseForm = ({ onSubmit }: FormProps) => {
<field.TextField type="color" label={m.houses_page_form_color()} />
)}
</form.AppField>
<form.AppField name="userId">
{(field) => (
<field.SelectUser
label={m.houses_page_form_create_for()}
values={users ?? []}
placeholder="Chọn người dùng"
keyword={userKeyword}
onKeywordChange={setUserKeyword}
/>
)}
</form.AppField>
{!isPersonal && (
<form.AppField name="userId">
{(field) => (
<field.SelectUser
label={m.houses_page_form_create_for()}
values={users ?? []}
placeholder="Chọn người dùng"
keyword={userKeyword}
onKeywordChange={setUserKeyword}
/>
)}
</form.AppField>
)}
<Field>
<DialogFooter>
<DialogClose asChild>
@@ -113,7 +126,10 @@ const CreateNewHouseForm = ({ onSubmit }: FormProps) => {
</Button>
</DialogClose>
<form.AppForm>
<form.SubscribeButton label={m.ui_confirm_btn()} />
<form.SubscribeButton
label={m.ui_confirm_btn()}
disabled={isPending}
/>
</form.AppForm>
</DialogFooter>
</Field>

View File

@@ -1,4 +1,3 @@
import { ReturnError } from '@/types/common';
import { useAppForm } from '@hooks/use-app-form';
import { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages';
@@ -16,18 +15,21 @@ import { toast } from 'sonner';
type EditHouseFormProps = {
data: OrganizationWithMembers;
onSubmit: (open: boolean) => void;
mutateKey: string;
};
const EditHouseForm = ({ data, onSubmit }: EditHouseFormProps) => {
const EditHouseForm = ({ data, onSubmit, mutateKey }: EditHouseFormProps) => {
const { refetch } = authClient.useActiveOrganization();
const queryClient = useQueryClient();
const { mutate: updateHouseMutation } = useMutation({
const { mutate: updateHouseMutation, isPending } = useMutation({
mutationFn: updateHouse,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...housesQueries.all, 'list'],
queryKey: [...housesQueries.all, mutateKey],
});
onSubmit(false);
refetch();
toast.success(m.houses_page_message_update_house_success(), {
richColors: true,
});
@@ -104,7 +106,10 @@ const EditHouseForm = ({ data, onSubmit }: EditHouseFormProps) => {
</Button>
</DialogClose>
<form.AppForm>
<form.SubscribeButton label={m.ui_confirm_btn()} />
<form.SubscribeButton
label={m.ui_confirm_btn()}
disabled={isPending}
/>
</form.AppForm>
</DialogFooter>
</Field>

View File

@@ -1,4 +1,3 @@
import { ReturnError } from '@/types/common';
import { useAppForm } from '@hooks/use-app-form';
import { m } from '@paraglide/messages';
import { GearIcon } from '@phosphor-icons/react';
@@ -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());
@@ -51,7 +50,7 @@ const SettingsForm = () => {
onChange: settingSchema,
},
onSubmit: async ({ value }) => {
updateMutation.mutate({ data: value as SettingsInput });
updateMutation({ data: value as SettingsInput });
},
});
@@ -89,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,4 +1,3 @@
import { ReturnError } from '@/types/common';
import { useAppForm } from '@hooks/use-app-form';
import { m } from '@paraglide/messages';
import { usersQueries } from '@service/queries';
@@ -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({
@@ -100,7 +99,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,5 +1,4 @@
import { Button } from '@/components/ui/button';
import { ReturnError } from '@/types/common';
import { useAppForm } from '@hooks/use-app-form';
import { m } from '@paraglide/messages';
import { usersQueries } from '@service/queries';
@@ -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({
@@ -50,7 +49,7 @@ const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
onSubmit: userSetPasswordSchema,
},
onSubmit: async ({ value }) => {
setUserPasswordMutation.mutate({ data: value });
setUserPasswordMutation({ data: value });
},
});
@@ -83,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,4 +1,3 @@
import { ReturnError } from '@/types/common';
import { useAppForm } from '@hooks/use-app-form';
import { m } from '@paraglide/messages';
import { usersQueries } from '@service/queries';
@@ -24,7 +23,7 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
role: data.role,
};
const updateRoleMutation = useMutation({
const { mutate: updateRoleMutation, isPending } = useMutation({
mutationFn: setUserRole,
onSuccess: () => {
queryClient.refetchQueries({
@@ -53,7 +52,7 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
onSubmit: userUpdateRoleSchema,
},
onSubmit: async ({ value }) => {
updateRoleMutation.mutate({ data: value });
updateRoleMutation({ data: value });
},
});
@@ -90,7 +89,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,4 +1,3 @@
import { ReturnError } from '@/types/common';
import { useAppForm } from '@hooks/use-app-form';
import { m } from '@paraglide/messages';
import { usersQueries } from '@service/queries';
@@ -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({
@@ -49,7 +48,7 @@ const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
onChange: userUpdateInfoSchema,
},
onSubmit: async ({ value }) => {
updateUserMutation.mutate({ data: value });
updateUserMutation({ data: value });
},
});
@@ -77,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

@@ -1,3 +1,4 @@
import { cn } from '@/lib/utils';
import CreateNewHouseForm from '@form/house/admin-create-house-form';
import useHasPermission from '@hooks/use-has-permission';
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
@@ -14,7 +15,15 @@ import {
} from '@ui/dialog';
import { useState } from 'react';
const CreateNewHouse = () => {
type CreateNewHouseProp = {
isPersonal?: boolean;
className?: string;
};
const CreateNewHouse = ({
className,
isPersonal = false,
}: CreateNewHouseProp) => {
const { hasPermission, isLoading } = useHasPermission('house', 'create');
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
@@ -25,7 +34,7 @@ const CreateNewHouse = () => {
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="default">
<Button type="button" variant="default" className={cn(className)}>
<PlusIcon />
{m.nav_add_new()}
</Button>
@@ -44,7 +53,7 @@ const CreateNewHouse = () => {
{m.nav_add_new()}
</DialogDescription>
</DialogHeader>
<CreateNewHouseForm onSubmit={_setOpen} />
<CreateNewHouseForm onSubmit={_setOpen} isPersonal={isPersonal} />
</DialogContent>
</Dialog>
);

View File

@@ -0,0 +1,44 @@
import { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages';
import { GearIcon, PenIcon } from '@phosphor-icons/react';
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
import { Button } from '../ui/button';
import DeleteUserHouseAction from './delete-user-house-dialog';
import EditHouseAction from './edit-house-dialog';
type CurrentUserActionGroupProps = {
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
};
const CurrentUserActionGroup = ({
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 OrganizationWithMembers}
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>
<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 { housesQueries } from '@service/queries';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
import parse from 'html-react-parser';
import { toast } from 'sonner';
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from '../ui/item';
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
import { Skeleton } from '../ui/skeleton';
import CreateNewHouse from './create-house-dialog';
type CurrentUserHouseListProps = {
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
};
const CurrentUserHouseList = ({ activeHouse }: CurrentUserHouseListProps) => {
const { data: houses } = useQuery(housesQueries.currentUser());
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,66 @@
import { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages';
import { Skeleton } from '@ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@ui/table';
import RoleBadge from '../avatar/role-badge';
import { Item, ItemContent, ItemDescription, ItemTitle } from '../ui/item';
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 className="">
<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" />
</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

@@ -1,7 +1,6 @@
import { m } from '@/paraglide/messages';
import { deleteHouse } from '@/service/house.api';
import { housesQueries } from '@/service/queries';
import { ReturnError } from '@/types/common';
import useHasPermission from '@hooks/use-has-permission';
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
import { ShieldWarningIcon, TrashIcon } from '@phosphor-icons/react';
@@ -31,6 +30,7 @@ import parse from 'html-react-parser';
import { useState } from 'react';
import { toast } from 'sonner';
import RoleBadge from '../avatar/role-badge';
import { Spinner } from '../ui/spinner';
type DeleteHouseProps = {
data: OrganizationWithMembers;
@@ -43,7 +43,7 @@ const DeleteHouseAction = ({ data }: DeleteHouseProps) => {
const queryClient = useQueryClient();
const { mutate: deleteHouseMutation } = useMutation({
const { mutate: deleteHouseMutation, isPending } = useMutation({
mutationFn: deleteHouse,
onSuccess: () => {
queryClient.invalidateQueries({
@@ -140,7 +140,13 @@ const DeleteHouseAction = ({ data }: DeleteHouseProps) => {
{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

@@ -0,0 +1,163 @@
import { authClient } from '@/lib/auth-client';
import { m } from '@/paraglide/messages';
import { deleteUserHouse } from '@/service/house.api';
import { housesQueries } from '@/service/queries';
import useHasPermission from '@hooks/use-has-permission';
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
import { ShieldWarningIcon, TrashIcon } from '@phosphor-icons/react';
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 {
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';
import { Spinner } from '../ui/spinner';
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

@@ -3,7 +3,6 @@ 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 { Button } from '@ui/button';
import {
Dialog,
DialogContent,
@@ -18,9 +17,15 @@ import { useState } from 'react';
type EditHouseProps = {
data: OrganizationWithMembers;
children: React.ReactNode;
isPersonal?: boolean;
};
const EditHouseAction = ({ data }: EditHouseProps) => {
const EditHouseAction = ({
data,
children,
isPersonal = false,
}: EditHouseProps) => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
const { hasPermission, isLoading } = useHasPermission('house', 'update');
@@ -32,17 +37,7 @@ const EditHouseAction = ({ data }: EditHouseProps) => {
<Dialog open={_open} onOpenChange={_setOpen}>
<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"
>
<PenIcon size={16} />
<span className="sr-only">{m.ui_edit_house_btn()}</span>
</Button>
</DialogTrigger>
<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>
@@ -62,7 +57,11 @@ const EditHouseAction = ({ data }: EditHouseProps) => {
{m.ui_edit_house_btn()}
</DialogDescription>
</DialogHeader>
<EditHouseForm data={data} onSubmit={_setOpen} />
<EditHouseForm
data={data}
onSubmit={_setOpen}
mutateKey={isPersonal ? 'currentUser' : 'list'}
/>
</DialogContent>
</Dialog>
);

View File

@@ -1,6 +1,8 @@
import { m } from '@paraglide/messages';
import { PenIcon } from '@phosphor-icons/react';
import { ColumnDef } from '@tanstack/react-table';
import { formatters } from '@utils/formatters';
import { Button } from '../ui/button';
import DeleteHouseAction from './delete-house-dialog';
import EditHouseAction from './edit-house-dialog';
import ViewDetailHouse from './view-house-detail-dialog';
@@ -42,7 +44,17 @@ export const houseColumns: ColumnDef<OrganizationWithMembers>[] = [
return (
<div className="flex justify-end gap-2">
<ViewDetailHouse data={row.original} />
<EditHouseAction 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

@@ -16,6 +16,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@ui/sidebar';
import React from 'react';
import AdminShow from '../auth/AdminShow';
import AuthShow from '../auth/AuthShow';
@@ -25,94 +26,107 @@ const NAV_MAIN = [
{
id: '1',
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: m.nav_label_management(),
isAuth: true,
admin: false,
items: [
{
title: m.nav_houses(),
path: '/kanri/houses',
icon: WarehouseIcon,
isAuth: false,
admin: true,
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">
<SidebarGroupLabel>{nav.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{nav.items.map((item) => {
const Icon = item.icon;
const Menu = (
<SidebarMenuItem>
<SidebarMenuButtonLink
type="button"
to={item.path}
className="cursor-pointer"
tooltip={item.title}
>
<Icon size={24} />
{item.title}
</SidebarMenuButtonLink>
</SidebarMenuItem>
);
return item.isAuth ? (
<AuthShow key={item.path}>{Menu}</AuthShow>
) : item.admin ? (
<AdminShow key={item.path}>{Menu}</AdminShow>
) : (
Menu
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
{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;
return (
<SidebarMenuItem key={item.path}>
<SidebarMenuButtonLink
type="button"
to={item.path}
className="cursor-pointer"
tooltip={`${nav.title} - ${item.title}`}
>
<Icon size={24} />
{item.title}
</SidebarMenuButtonLink>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</Component>
);
})}
</>
);
};

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

@@ -135,7 +135,7 @@ export function SelectUser({
<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-[var(--radix-popper-anchor-width)] overflow-hidden rounded-lg border shadow-md"
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" />

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,4 +1,3 @@
import { ReturnError } from '@/types/common';
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
import { m } from '@paraglide/messages';
import { ShieldWarningIcon } from '@phosphor-icons/react';
@@ -17,6 +16,7 @@ import {
} from '@ui/dialog';
import { UserWithRole } from 'better-auth/plugins';
import { toast } from 'sonner';
import { Spinner } from '../ui/spinner';
import {
Table,
TableBody,
@@ -36,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({
@@ -130,7 +130,13 @@ const BanUserConfirm = ({ data }: BanConfirmProps) => {
{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,4 +1,3 @@
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';
@@ -23,7 +22,15 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
import { UserWithRole } from 'better-auth/plugins';
import { useState } from 'react';
import { toast } from 'sonner';
import DisplayBreakLineMessage from '../DisplayBreakLineMessage';
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({
@@ -107,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

@@ -9,7 +9,7 @@ import {
owner,
} from '@lib/auth/organization-permissions';
import { ac, admin, user } from '@lib/auth/permissions';
import { createAuditLog } from '@service/repository';
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';
@@ -117,6 +117,15 @@ export const auth = betterAuth({
},
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({

View File

@@ -14,11 +14,14 @@ 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'
@@ -50,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({
@@ -65,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: '/',
@@ -75,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',
@@ -117,7 +136,7 @@ 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
@@ -126,13 +145,15 @@ export interface FileRoutesByFullPath {
'/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
@@ -141,8 +162,11 @@ export interface FileRoutesByTo {
'/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
@@ -152,7 +176,7 @@ 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
@@ -161,8 +185,11 @@ export interface FileRoutesById {
'/(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
@@ -171,7 +198,7 @@ export interface FileRouteTypes {
| '/'
| '/account'
| '/kanri'
| '/dashboard'
| '/management'
| '/api/auth/$'
| '/account/change-password'
| '/account/profile'
@@ -180,13 +207,15 @@ export interface FileRouteTypes {
| '/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'
@@ -195,8 +224,11 @@ export interface FileRouteTypes {
| '/kanri/logs'
| '/kanri/settings'
| '/kanri/users'
| '/management/dashboard'
| '/management/houses'
| '/account'
| '/kanri'
| '/management'
id:
| '__root__'
| '/(app)'
@@ -205,7 +237,7 @@ 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'
@@ -214,8 +246,11 @@ export interface FileRouteTypes {
| '/(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 {
@@ -261,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': {
@@ -282,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: '/'
@@ -296,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'
@@ -384,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

@@ -14,6 +14,7 @@ import { useState } from 'react';
export const Route = createFileRoute('/(app)/(auth)/kanri/houses')({
component: RouteComponent,
staticData: { breadcrumb: () => m.nav_houses() },
});
function RouteComponent() {
@@ -54,7 +55,7 @@ function RouteComponent() {
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<SearchInput
keywords={searchKeyword}
setKeyword={setSearchKeyword}

View File

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

View File

@@ -0,0 +1,30 @@
import CurrentUserActionGroup from '@/components/house/current-user-action-group';
import CurrentUserHouseList from '@/components/house/current-user-house-list';
import CurrentUserMemberList from '@/components/house/current-user-member-list';
import { m } from '@/paraglide/messages';
import { housesQueries } from '@/service/queries';
import { authClient } from '@lib/auth-client';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/(app)/(auth)/management/houses')({
component: RouteComponent,
staticData: { breadcrumb: () => m.nav_houses() },
});
function RouteComponent() {
const { data: houses } = useQuery(housesQueries.currentUser());
const { data: activeHouse } = authClient.useActiveOrganization();
if (!activeHouse || !houses) return null;
return (
<div className="@container/main p-4">
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-linear-to-br *:data-[slot=card]:shadow-xs grid grid-cols-1 @xl/main:grid-cols-2 @5xl/main:grid-cols-3 gap-4">
<CurrentUserHouseList activeHouse={activeHouse} />
<CurrentUserMemberList activeHouse={activeHouse} />
<CurrentUserActionGroup activeHouse={activeHouse} />
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/(app)/(auth)/management/')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/(app)/(auth)/management/"!</div>
}

View File

@@ -0,0 +1,6 @@
import { m } from '@/paraglide/messages';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/(app)/(auth)/management')({
staticData: { breadcrumb: () => m.nav_label_management() },
});

View File

@@ -23,7 +23,7 @@ function RouteComponent() {
}, [language]);
return (
<AuthProvider>
<SidebarProvider defaultOpen={false}>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<Header />

View File

@@ -4,6 +4,7 @@ import { DB_TABLE, LOG_ACTION } from '@/types/enum';
import { auth } from '@lib/auth';
import { authMiddleware } from '@lib/middleware';
import { createServerFn } from '@tanstack/react-start';
import { getRequestHeaders } from '@tanstack/react-start/server';
import { parseError } from '@utils/helper';
import {
baseHouse,
@@ -43,6 +44,7 @@ export const getAllHouse = createServerFn({ method: 'GET' })
role: true,
user: {
select: {
id: true,
name: true,
email: true,
image: true,
@@ -74,6 +76,43 @@ export const getAllHouse = createServerFn({ method: 'GET' })
}
});
export const getCurrentUserHouses = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.handler(async ({ context: { user } }) => {
try {
const houses = await prisma.organization.findMany({
where: { members: { some: { userId: user.id } } },
orderBy: { createdAt: 'asc' },
include: {
_count: {
select: {
members: true,
},
},
members: {
select: {
role: true,
user: {
select: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
},
});
return houses;
} catch (error) {
console.error(error);
const { message, code } = parseError(error);
throw { message, code };
}
});
export const createHouse = createServerFn({ method: 'POST' })
.middleware([authMiddleware])
.inputValidator(houseCreateBESchema)
@@ -177,3 +216,40 @@ export const deleteHouse = createServerFn({ method: 'POST' })
throw { message, code };
}
});
export const deleteUserHouse = createServerFn({ method: 'POST' })
.middleware([authMiddleware])
.inputValidator(baseHouse)
.handler(async ({ data, context: { user } }) => {
try {
const currentHouse = await prisma.organization.findUnique({
where: { id: data.id },
});
if (!currentHouse) throw Error('House not found');
const headers = getRequestHeaders();
const result = await auth.api.deleteOrganization({
body: {
organizationId: data.id, // required
},
headers,
});
if (result) {
await createAuditLog({
action: LOG_ACTION.DELETE,
tableName: DB_TABLE.ORGANIZATION,
recordId: result?.id,
oldValue: JSON.stringify(currentHouse),
newValue: '',
userId: user.id,
});
}
return result;
} catch (error) {
console.error(error);
const { message, code } = parseError(error);
throw { message, code };
}
});

View File

@@ -27,6 +27,10 @@ export const houseCreateBESchema = houseCreateSchema.extend({
slug: z.string().nonempty(m.common_is_required({ field: 'Slug' })),
});
export const houseCreateByUserBESchema = houseCreateBESchema.omit({
userId: true,
});
export const houseEditSchema = baseHouse.extend({
name: z
.string()

View File

@@ -1,7 +1,7 @@
import { getSession } from '@lib/auth/session';
import { queryOptions } from '@tanstack/react-query';
import { getAllAudit } from './audit.api';
import { getAllHouse } from './house.api';
import { getAllHouse, getCurrentUserHouses } from './house.api';
import {
getAdminSettings,
getCurrentUserLanguage,
@@ -69,4 +69,9 @@ export const housesQueries = {
queryKey: [...housesQueries.all, 'list', params],
queryFn: () => getAllHouse({ data: params }),
}),
currentUser: () =>
queryOptions({
queryKey: [...housesQueries.all, 'currentUser'],
queryFn: () => getCurrentUserHouses(),
}),
};

View File

@@ -54,3 +54,16 @@ export const createAuditLog = async (data: Omit<Audit, 'id' | 'createdAt'>) => {
throw { message, code };
}
};
export const getInitialOrganization = async (userId: string) => {
const organization = await prisma.organization.findFirst({
where: {
members: {
some: {
userId,
},
},
},
});
return organization;
};

View File

@@ -1,4 +0,0 @@
export interface ReturnError extends Error {
message: string;
code: string;
}

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

@@ -19,6 +19,7 @@ declare global {
role: true;
user: {
select: {
id: true;
name: true;
email: true;
image: true;
@@ -28,4 +29,9 @@ declare global {
};
};
}>;
type ReturnError = Error & {
message: string;
code: string;
};
}