invite member to house

This commit is contained in:
2026-02-11 22:45:33 +07:00
parent 5ffdd7454a
commit ea31b61cac
27 changed files with 545 additions and 61 deletions

View File

@@ -1,3 +1,4 @@
import { ROLE_NAME } from '@/types/enum';
import { m } from '@paraglide/messages';
import { Badge, badgeVariants } from '@ui/badge';
import { VariantProps } from 'class-variance-authority';
@@ -27,7 +28,7 @@ const RoleBadge = ({ type, className }: RoleProps) => {
return (
<Badge variant={displayVariant} className={className}>
{m.role_tags({ role: type as string })}
{m.role_tags({ role: type as ROLE_NAME })}
</Badge>
);
};

View File

@@ -1,5 +1,4 @@
import { useFieldContext, useFormContext } from '@hooks/use-app-form';
import { RoleEnum } from '@service/user.schema';
import { useStore } from '@tanstack/react-form';
import { Button, buttonVariants } from '@ui/button';
import { Field, FieldError, FieldLabel } from '@ui/field';
@@ -146,12 +145,11 @@ export function Select({
label,
values,
placeholder,
isRole = false,
// isRole = false,
}: {
label: string;
values: Array<{ label: string; value: string }>;
placeholder?: string;
isRole?: boolean;
}) {
const field = useFieldContext<string>();
const errors = useStore(field.store, (state) => state.meta.errors);
@@ -163,11 +161,7 @@ export function Select({
<ShadcnSelect.Select
name={field.name}
value={String(field.state.value)}
onValueChange={(value) =>
isRole
? field.handleChange(RoleEnum.parse(value))
: field.handleChange(value)
}
onValueChange={(value) => field.handleChange(value)}
>
<ShadcnSelect.SelectTrigger aria-invalid={isInvalid}>
<ShadcnSelect.SelectValue placeholder={placeholder} />
@@ -235,6 +229,7 @@ export function SelectUser({
keyword,
onKeywordChange,
searchPlaceholder = 'Tìm theo tên hoặc email...',
selectKey = 'id',
}: {
label: string;
values: Array<{ id: string; name: string; email: string }>;
@@ -243,6 +238,7 @@ export function SelectUser({
keyword?: string;
onKeywordChange?: (value: string) => void;
searchPlaceholder?: string;
selectKey?: 'id' | 'email';
}) {
const field = useFieldContext<string>();
const errors = useStore(field.store, (state) => state.meta.errors);
@@ -262,6 +258,7 @@ export function SelectUser({
onKeywordChange={onKeywordChange}
searchPlaceholder={searchPlaceholder}
aria-invalid={isInvalid}
selectKey={selectKey}
/>
{isInvalid && <FieldError errors={errors} />}
</Field>

View File

@@ -84,7 +84,6 @@ const CreateNewHouseForm = ({ onSubmit, isPersonal = false }: FormProps) => {
if (isPersonal) {
form.setFieldValue('userId', session.user.id);
}
console.log(isPending);
}, []);
return (
@@ -111,7 +110,8 @@ const CreateNewHouseForm = ({ onSubmit, isPersonal = false }: FormProps) => {
<field.SelectUser
label={m.houses_page_form_create_for()}
values={users ?? []}
placeholder="Chọn người dùng"
placeholder={m.houses_page_form_user_select_placeholder()}
searchPlaceholder={m.houses_page_form_user_select_search_placeholder()}
keyword={userKeyword}
onKeywordChange={setUserKeyword}
/>

View File

@@ -0,0 +1,132 @@
import { Button } from '@/components/ui/button';
import { DialogClose, DialogFooter } from '@/components/ui/dialog';
import { useAppForm } from '@/hooks/use-app-form';
import useDebounced from '@/hooks/use-debounced';
import { authClient } from '@/lib/auth-client';
import { m } from '@/paraglide/messages';
import { invitationMember } from '@/service/house.api';
import {
invitationCreateBESchema,
invitationCreateFESchema,
} from '@/service/house.schema';
import { housesQueries, usersQueries } from '@/service/queries';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Field, FieldGroup, FieldLabel } from '@ui/field';
import { useState } from 'react';
import { toast } from 'sonner';
type FormProps = {
onSubmit: (open: boolean) => void;
};
const UserInviteMemberForm = ({ onSubmit }: FormProps) => {
const { data: activeHouse, refetch } = authClient.useActiveOrganization();
const [userKeyword, setUserKeyword] = useState('');
const debouncedUserKeyword = useDebounced(userKeyword, 300);
const { data: users } = useQuery(
usersQueries.select({ keyword: debouncedUserKeyword }, true),
);
const queryClient = useQueryClient();
if (!activeHouse) return null;
const { mutate: invitationMemberMutation, isPending } = useMutation({
mutationFn: invitationMember,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...housesQueries.all, 'currentUser'],
});
onSubmit(false);
refetch();
toast.success(m.houses_page_message_create_house_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
const form = useAppForm({
defaultValues: {
email: '',
houseId: activeHouse.id,
role: '',
},
validators: {
onChange: invitationCreateFESchema,
onSubmit: invitationCreateFESchema,
},
onSubmit: async ({ value }) => {
invitationMemberMutation({ data: invitationCreateBESchema.parse(value) });
},
});
return (
<form
id="user-invite-member-form"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="house_name">
{m.houses_page_form_name()}:
</FieldLabel>
<div className="flex gap-2">{activeHouse.name}</div>
</Field>
<form.AppField name="email">
{(field) => (
<field.SelectUser
label={m.houses_page_form_user()}
values={users ?? []}
placeholder={m.houses_page_form_user_select_placeholder()}
searchPlaceholder={m.houses_page_form_user_select_search_placeholder()}
keyword={userKeyword}
onKeywordChange={setUserKeyword}
selectKey="email"
/>
)}
</form.AppField>
<form.AppField name="role">
{(field) => (
<field.Select
label={m.profile_form_role()}
placeholder={m.users_page_ui_select_placeholder_role()}
values={[
{ value: 'owner', label: m.role_tags({ role: 'owner' }) },
{ value: 'admin', label: m.role_tags({ role: 'admin' }) },
{ value: 'member', label: m.role_tags({ role: 'member' }) },
]}
/>
)}
</form.AppField>
<Field>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" type="button">
{m.ui_cancel_btn()}
</Button>
</DialogClose>
<form.AppForm>
<form.SubscribeButton
label={m.ui_confirm_btn()}
disabled={isPending}
/>
</form.AppForm>
</DialogFooter>
</Field>
</FieldGroup>
</form>
);
};
export default UserInviteMemberForm;

View File

@@ -2,7 +2,7 @@ import { useAppForm } from '@hooks/use-app-form';
import { m } from '@paraglide/messages';
import { usersQueries } from '@service/queries';
import { createUser } from '@service/user.api';
import { userCreateSchema } from '@service/user.schema';
import { userCreateBESchema, userCreateFESchema } from '@service/user.schema';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@ui/button';
import { DialogClose, DialogFooter } from '@ui/dialog';
@@ -46,11 +46,11 @@ const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
role: '',
},
validators: {
onChange: userCreateSchema,
onSubmit: userCreateSchema,
onChange: userCreateFESchema,
onSubmit: userCreateFESchema,
},
onSubmit: ({ value }) => {
createUserMutation({ data: userCreateSchema.parse(value) });
createUserMutation({ data: userCreateBESchema.parse(value) });
},
});
@@ -83,7 +83,6 @@ const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
<field.Select
label={m.profile_form_role()}
placeholder={m.users_page_ui_select_placeholder_role()}
isRole
values={[
{ value: 'admin', label: m.role_tags({ role: 'admin' }) },
{ value: 'user', label: m.role_tags({ role: 'user' }) },

View File

@@ -2,7 +2,10 @@ import { useAppForm } from '@hooks/use-app-form';
import { m } from '@paraglide/messages';
import { usersQueries } from '@service/queries';
import { setUserRole } from '@service/user.api';
import { userUpdateRoleSchema } from '@service/user.schema';
import {
userUpdateRoleBESchema,
userUpdateRoleSchema,
} from '@service/user.schema';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@ui/button';
import { DialogClose, DialogFooter } from '@ui/dialog';
@@ -52,7 +55,7 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
onSubmit: userUpdateRoleSchema,
},
onSubmit: async ({ value }) => {
updateRoleMutation({ data: value });
updateRoleMutation({ data: userUpdateRoleBESchema.parse(value) });
},
});

View File

@@ -1,7 +1,7 @@
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';
import { cn } from '@lib/utils';
import { m } from '@paraglide/messages';
import { PlusIcon } from '@phosphor-icons/react';
import { Button } from '@ui/button';
@@ -24,7 +24,11 @@ const CreateNewHouse = ({
className,
isPersonal = false,
}: CreateNewHouseProp) => {
const { hasPermission, isLoading } = useHasPermission('house', 'create');
const { hasPermission, isLoading } = useHasPermission(
'house',
'create',
isPersonal,
);
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();

View File

@@ -1,8 +1,8 @@
import { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages';
import { GearIcon, PenIcon } from '@phosphor-icons/react';
import { Button } from '@ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
import { Button } from '../ui/button';
import DeleteUserHouseAction from './delete-user-house-dialog';
import EditHouseAction from './edit-house-dialog';

View File

@@ -4,17 +4,17 @@ import { m } from '@paraglide/messages';
import { CheckIcon, WarehouseIcon } from '@phosphor-icons/react';
import { Button } from '@ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
import 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';
} from '@ui/item';
import { ScrollArea, ScrollBar } from '@ui/scroll-area';
import { Skeleton } from '@ui/skeleton';
import parse from 'html-react-parser';
import { toast } from 'sonner';
import CreateNewHouse from './create-house-dialog';
type CurrentUserHouseListProps = {
@@ -26,8 +26,6 @@ const CurrentUserHouseList = ({
activeHouse,
houses,
}: CurrentUserHouseListProps) => {
// const { data: houses } = useQuery(housesQueries.currentUser());
const activeHouseAction = async ({
id,
slug,

View File

@@ -0,0 +1,116 @@
import { cancelInvitation } from '@/service/house.api';
import { INVITE_STATUS } from '@/types/enum';
import { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages';
import { useMutation } from '@tanstack/react-query';
import { Item, ItemContent, ItemDescription, ItemTitle } from '@ui/item';
import { Skeleton } from '@ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@ui/table';
import { toast } from 'sonner';
import RoleBadge from '../avatar/role-badge';
import { Button } from '../ui/button';
type InvitationListProps = {
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
};
const CurrentUserInvitationList = ({ activeHouse }: InvitationListProps) => {
const { refetch } = authClient.useActiveOrganization();
const { mutate: cancelInvitationMutation } = useMutation({
mutationFn: cancelInvitation,
onSuccess: () => {
refetch();
// _setOpen(false);
toast.success(m.houses_page_message_delete_house_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
const handleCancelInvitation = (id: string) => {
cancelInvitationMutation({ data: { id } });
};
if (!activeHouse) {
return <Skeleton className="col-span-2 h-80 w-full rounded-xl" />;
}
return (
<div className="overflow-hidden rounded-md border col-span-1 lg:col-span-3 shadow-xs bg-linear-to-br from-primary/5 to-card">
<Table className="bg-white">
<TableHeader>
<TableRow>
<TableHead className="px-4 bg-primary text-white text-sm w-1/2">
{m.houses_page_ui_view_table_header_invite()}
</TableHead>
<TableHead className="px-4 bg-primary text-white text-sm w-1/2"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeHouse.invitations.length > 0 ? (
activeHouse.invitations.map((item) => (
<TableRow>
<TableCell>
<Item>
<ItemContent>
<ItemTitle>
<strong>{m.houses_user_page_invite_label_to()}:</strong>{' '}
{item.email} - <RoleBadge type={item.role} />
</ItemTitle>
<ItemDescription>
<strong>
{m.houses_user_page_invite_label_status()}:{' '}
{m.invite_status({
status: item.status as INVITE_STATUS,
})}
</strong>
</ItemDescription>
</ItemContent>
</Item>
</TableCell>
<TableCell className="p-6">
<div className="flex justify-end gap-2">
{item.status !== INVITE_STATUS.CANCELED && (
<Button
variant="outline"
className="cursor-pointer w-20 py-4"
onClick={() => handleCancelInvitation(item.id)}
>
{m.ui_cancel_btn()}
</Button>
)}
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={2} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
};
export default CurrentUserInvitationList;

View File

@@ -1,5 +1,6 @@
import { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages';
import { Item, ItemContent, ItemDescription, ItemTitle } from '@ui/item';
import { Skeleton } from '@ui/skeleton';
import {
Table,
@@ -10,7 +11,7 @@ import {
TableRow,
} from '@ui/table';
import RoleBadge from '../avatar/role-badge';
import { Item, ItemContent, ItemDescription, ItemTitle } from '../ui/item';
import InviteUserAction from './invite-user-dialog';
type CurrentUserMemberListProps = {
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
@@ -23,7 +24,7 @@ const CurrentUserMemberList = ({ activeHouse }: CurrentUserMemberListProps) => {
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="">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4 bg-primary text-white text-sm w-1/3">
@@ -33,7 +34,11 @@ const CurrentUserMemberList = ({ activeHouse }: CurrentUserMemberListProps) => {
<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" />
<TableHead className="px-4 bg-primary">
<div className="flex justify-end gap-2">
<InviteUserAction />
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>

View File

@@ -1,9 +1,9 @@
import { m } from '@/paraglide/messages';
import { deleteHouse } 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 { m } from '@paraglide/messages';
import { ShieldWarningIcon, TrashIcon } from '@phosphor-icons/react';
import { deleteHouse } from '@service/house.api';
import { housesQueries } from '@service/queries';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@ui/button';
import {
@@ -17,6 +17,7 @@ import {
DialogTrigger,
} from '@ui/dialog';
import { Label } from '@ui/label';
import { Spinner } from '@ui/spinner';
import {
Table,
TableBody,
@@ -30,7 +31,6 @@ 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: HouseWithMembers;

View File

@@ -1,10 +1,10 @@
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 { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages';
import { ShieldWarningIcon, TrashIcon } from '@phosphor-icons/react';
import { deleteUserHouse } from '@service/house.api';
import { housesQueries } from '@service/queries';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@ui/button';
import {
@@ -18,6 +18,7 @@ import {
DialogTrigger,
} from '@ui/dialog';
import { Label } from '@ui/label';
import { Spinner } from '@ui/spinner';
import {
Table,
TableBody,
@@ -31,7 +32,6 @@ 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'];

View File

@@ -1,8 +1,8 @@
import { m } from '@paraglide/messages';
import { PenIcon } from '@phosphor-icons/react';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@ui/button';
import { formatters } from '@utils/formatters';
import { Button } from '../ui/button';
import DeleteHouseAction from './delete-house-dialog';
import EditHouseAction from './edit-house-dialog';
import ViewDetailHouse from './view-house-detail-dialog';

View File

@@ -0,0 +1,65 @@
import UserInviteMemberForm from '@form/house/user-invite-member-form';
import useHasPermission from '@hooks/use-has-permission';
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
import { m } from '@paraglide/messages';
import { PaperPlaneTiltIcon } from '@phosphor-icons/react';
import { Button } from '@ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@ui/dialog';
import { useState } from 'react';
type ActionProps = {};
const InviteUserAction = ({}: ActionProps) => {
const { hasPermission, isLoading } = useHasPermission(
'house',
'create',
true,
);
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
if (isLoading) return null;
if (hasPermission) {
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="rounded-full cursor-pointer bg-white text-black hover:bg-green-500 hover:text-white"
>
<PaperPlaneTiltIcon weight="fill" />
{m.houses_user_page_action_invite_user()}
</Button>
</DialogTrigger>
<DialogContent
className="max-w-80 xl:max-w-lg"
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-green-600">
<PaperPlaneTiltIcon size={16} weight="fill" />
{m.houses_user_page_action_invite_user()}
</DialogTitle>
<DialogDescription className="sr-only">
{m.houses_user_page_action_invite_user()}
</DialogDescription>
</DialogHeader>
<UserInviteMemberForm onSubmit={_setOpen} />
</DialogContent>
</Dialog>
);
}
};
export default InviteUserAction;

View File

@@ -4,7 +4,7 @@ import { cn } from '@lib/utils';
import { CaretDownIcon, MagnifyingGlassIcon } from '@phosphor-icons/react';
import { useCallback, useEffect, useRef, useState } from 'react';
export type SelectUserItem = {
type SelectUserItem = {
id: string;
name: string;
email: string;
@@ -13,7 +13,7 @@ export type SelectUserItem = {
const userLabel = (u: { name: string; email: string }) =>
`${u.name} - ${u.email}`;
export type SelectUserProps = {
type SelectUserProps = {
value: string;
onValueChange: (userId: string) => void;
values: SelectUserItem[];
@@ -27,6 +27,7 @@ export type SelectUserProps = {
'aria-invalid'?: boolean;
disabled?: boolean;
className?: string;
selectKey?: 'id' | 'email';
};
export function SelectUser({
@@ -42,6 +43,7 @@ export function SelectUser({
'aria-invalid': ariaInvalid,
disabled = false,
className,
selectKey = 'id',
}: SelectUserProps) {
const [open, setOpen] = useState(false);
const [localQuery, setLocalQuery] = useState('');
@@ -53,7 +55,9 @@ export function SelectUser({
const setSearchValue = useServerSearch ? onKeywordChange! : setLocalQuery;
const selectedUser =
value != null && value !== '' ? values.find((u) => u.id === value) : null;
value != null && value !== ''
? values.find((u) => u[selectKey] === value)
: null;
const displayValue = selectedUser ? userLabel(selectedUser) : '';
const filtered = useServerSearch
@@ -161,15 +165,16 @@ export function SelectUser({
key={u.id}
type="button"
role="option"
aria-selected={value === u.id}
aria-selected={value === u[selectKey]}
className={cn(
'hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center rounded-md px-2 py-1.5 text-left text-xs/relaxed outline-none',
value === u.id && 'bg-accent text-accent-foreground',
value === u[selectKey] &&
'bg-accent text-accent-foreground',
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleSelect(u.id);
handleSelect(u[selectKey]);
}}
>
{userLabel(u)}