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

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