add function for user
- create house - edit house - delete house - list all member for active house
This commit is contained in:
@@ -49,6 +49,7 @@
|
|||||||
"ui_delete_btn": "Delete",
|
"ui_delete_btn": "Delete",
|
||||||
"ui_ban_btn": "Lock",
|
"ui_ban_btn": "Lock",
|
||||||
"ui_unban_btn": "Unlock",
|
"ui_unban_btn": "Unlock",
|
||||||
|
"ui_invite_btn": "Invite",
|
||||||
"ui_update_password_btn": "Set password",
|
"ui_update_password_btn": "Set password",
|
||||||
"ui_change_role_btn": "Set role",
|
"ui_change_role_btn": "Set role",
|
||||||
"ui_edit_user_btn": "Edit User",
|
"ui_edit_user_btn": "Edit User",
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
"ui_change_password_btn": "Change password",
|
"ui_change_password_btn": "Change password",
|
||||||
"nav_label_management": "Management",
|
"nav_label_management": "Management",
|
||||||
"nav_label_basic": "Basic",
|
"nav_label_basic": "Basic",
|
||||||
|
"nav_label_kanri": "Administrator",
|
||||||
"nav_home": "Home",
|
"nav_home": "Home",
|
||||||
"nav_dashboard": "Dashboard",
|
"nav_dashboard": "Dashboard",
|
||||||
"nav_settings": "Settings",
|
"nav_settings": "Settings",
|
||||||
@@ -166,6 +168,9 @@
|
|||||||
"houses_page_message_house_not_found": "House not found!",
|
"houses_page_message_house_not_found": "House not found!",
|
||||||
"houses_page_message_update_house_success": "Updated house successfully!",
|
"houses_page_message_update_house_success": "Updated house successfully!",
|
||||||
"houses_page_message_delete_house_success": "Delete 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": [
|
"backend_message": [
|
||||||
{
|
{
|
||||||
"match": {
|
"match": {
|
||||||
@@ -174,7 +179,8 @@
|
|||||||
"code=YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "You are not allowed to change users role",
|
"code=YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "You are not allowed to change users role",
|
||||||
"code=USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email already exists. Please choose another email!",
|
"code=USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email already exists. Please choose another email!",
|
||||||
"code=BANNED_USER": "Your account get banned, please contact administrator for more information!",
|
"code=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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
"ui_delete_btn": "Xóa",
|
"ui_delete_btn": "Xóa",
|
||||||
"ui_ban_btn": "Khóa",
|
"ui_ban_btn": "Khóa",
|
||||||
"ui_unban_btn": "Mở 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_update_password_btn": "Đặt lại mật khẩu",
|
||||||
"ui_change_role_btn": "Đặt lại quyền hạn",
|
"ui_change_role_btn": "Đặt lại quyền hạn",
|
||||||
"ui_edit_user_btn": "Chỉnh sửa người dùng",
|
"ui_edit_user_btn": "Chỉnh sửa người dùng",
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"ui_edit_house_btn": "Chỉnh sửa nhà",
|
"ui_edit_house_btn": "Chỉnh sửa nhà",
|
||||||
"nav_label_management": "Quản lý",
|
"nav_label_management": "Quản lý",
|
||||||
"nav_label_basic": "Cơ bản",
|
"nav_label_basic": "Cơ bản",
|
||||||
|
"nav_label_kanri": "Quản trị viên",
|
||||||
"nav_home": "Trang chủ",
|
"nav_home": "Trang chủ",
|
||||||
"nav_dashboard": "Bảng điều khiển",
|
"nav_dashboard": "Bảng điều khiển",
|
||||||
"nav_settings": "Cài đặt",
|
"nav_settings": "Cài đặt",
|
||||||
@@ -167,6 +169,9 @@
|
|||||||
"houses_page_message_house_not_found": "Không tìm thấy nhà này!",
|
"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_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_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": [
|
"backend_message": [
|
||||||
{
|
{
|
||||||
"match": {
|
"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=YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "Bạn không đủ quyền để chỉnh sửa quyền hạn người dùng!",
|
||||||
"code=USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email này đã có người sử dụng. Vui lòng chọn một email khác!",
|
"code=USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email này đã có người sử dụng. Vui lòng chọn một email khác!",
|
||||||
"code=BANNED_USER": "Bạn đã bị quản trị viên khóa tài khoản, hãy liên hệ quản trị viên để tìm hiểu thêm!",
|
"code=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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ const DataTable = <TData, TValue>({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="hidden text-sm gap-2 xl:flex">
|
<div className="hidden text-sm gap-2 lg:flex">
|
||||||
<span>
|
<span>
|
||||||
{m.common_page_show({
|
{m.common_page_show({
|
||||||
count: pagination.totalItem,
|
count: pagination.totalItem,
|
||||||
@@ -122,9 +122,12 @@ const DataTable = <TData, TValue>({
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex max-w-full items-center gap-8 xl:w-ft">
|
<div className="flex max-w-full items-center gap-2 justify-between w-full lg:w-fit lg:gap-8">
|
||||||
<div className="hidden items-center gap-2 lg:flex">
|
<div className="items-center gap-2 flex">
|
||||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
<Label
|
||||||
|
htmlFor="rows-per-page"
|
||||||
|
className="hidden text-sm font-medium lg:block"
|
||||||
|
>
|
||||||
{m.common_per_page()}
|
{m.common_per_page()}
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ReturnError } from '@/types/common';
|
|
||||||
import { useAppForm } from '@hooks/use-app-form';
|
import { useAppForm } from '@hooks/use-app-form';
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
import { Locale, setLocale } from '@paraglide/runtime';
|
import { Locale, setLocale } from '@paraglide/runtime';
|
||||||
@@ -22,7 +21,7 @@ const UserSettingsForm = () => {
|
|||||||
|
|
||||||
const { data, isLoading } = useQuery(settingQueries.listUser());
|
const { data, isLoading } = useQuery(settingQueries.listUser());
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const { mutate: updateMutation, isPending } = useMutation({
|
||||||
mutationFn: updateUserSettings,
|
mutationFn: updateUserSettings,
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
setLocale(variables.data.language as Locale);
|
setLocale(variables.data.language as Locale);
|
||||||
@@ -51,7 +50,7 @@ const UserSettingsForm = () => {
|
|||||||
onChange: userSettingSchema,
|
onChange: userSettingSchema,
|
||||||
},
|
},
|
||||||
onSubmit: ({ value }) => {
|
onSubmit: ({ value }) => {
|
||||||
updateMutation.mutate({ data: value as UserSettingInput });
|
updateMutation({ data: value as UserSettingInput });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,7 +101,10 @@ const UserSettingsForm = () => {
|
|||||||
</form.AppField>
|
</form.AppField>
|
||||||
<Field>
|
<Field>
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.SubscribeButton label={m.ui_update_btn()} />
|
<form.SubscribeButton
|
||||||
|
label={m.ui_update_btn()}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
</form.AppForm>
|
</form.AppForm>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|||||||
@@ -8,21 +8,31 @@ import * as ShadcnSelect from '@ui/select';
|
|||||||
import { SelectUser as SelectUserUI } from '@ui/select-user';
|
import { SelectUser as SelectUserUI } from '@ui/select-user';
|
||||||
import { Textarea } from '@ui/textarea';
|
import { Textarea } from '@ui/textarea';
|
||||||
import { type VariantProps } from 'class-variance-authority';
|
import { type VariantProps } from 'class-variance-authority';
|
||||||
|
import { Spinner } from '../ui/spinner';
|
||||||
|
|
||||||
export function SubscribeButton({
|
export function SubscribeButton({
|
||||||
label,
|
label,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
} & VariantProps<typeof buttonVariants>) {
|
} & VariantProps<typeof buttonVariants>) {
|
||||||
const form = useFormContext();
|
const form = useFormContext();
|
||||||
return (
|
return (
|
||||||
<form.Subscribe selector={(state) => state.isSubmitting}>
|
<form.Subscribe selector={(state) => state.isSubmitting}>
|
||||||
{(isSubmitting) => (
|
{(isSubmitting) => {
|
||||||
<Button type="submit" disabled={isSubmitting} variant={variant}>
|
return (
|
||||||
{label}
|
<Button
|
||||||
</Button>
|
type="submit"
|
||||||
)}
|
disabled={isSubmitting || disabled}
|
||||||
|
variant={variant}
|
||||||
|
>
|
||||||
|
{(isSubmitting || disabled) && <Spinner data-icon="inline-start" />}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</form.Subscribe>
|
</form.Subscribe>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReturnError } from '@/types/common';
|
import { useAuth } from '@/components/auth/auth-provider';
|
||||||
import { useAppForm } from '@hooks/use-app-form';
|
import { useAppForm } from '@hooks/use-app-form';
|
||||||
import useDebounced from '@hooks/use-debounced';
|
import useDebounced from '@hooks/use-debounced';
|
||||||
import { authClient } from '@lib/auth-client';
|
import { authClient } from '@lib/auth-client';
|
||||||
@@ -12,14 +12,16 @@ import { Button } from '@ui/button';
|
|||||||
import { DialogClose, DialogFooter } from '@ui/dialog';
|
import { DialogClose, DialogFooter } from '@ui/dialog';
|
||||||
import { Field, FieldGroup } from '@ui/field';
|
import { Field, FieldGroup } from '@ui/field';
|
||||||
import { slugify } from '@utils/helper';
|
import { slugify } from '@utils/helper';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
type FormProps = {
|
type FormProps = {
|
||||||
onSubmit: (open: boolean) => void;
|
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 [userKeyword, setUserKeyword] = useState('');
|
||||||
const debouncedUserKeyword = useDebounced(userKeyword, 300);
|
const debouncedUserKeyword = useDebounced(userKeyword, 300);
|
||||||
const { data: users } = useQuery(
|
const { data: users } = useQuery(
|
||||||
@@ -28,11 +30,13 @@ const CreateNewHouseForm = ({ onSubmit }: FormProps) => {
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { mutate: createHouseMutation } = useMutation({
|
const queryKey = isPersonal ? 'currentUser' : 'list';
|
||||||
|
|
||||||
|
const { mutate: createHouseMutation, isPending } = useMutation({
|
||||||
mutationFn: createHouse,
|
mutationFn: createHouse,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: [...housesQueries.all, 'list'],
|
queryKey: [...housesQueries.all, queryKey],
|
||||||
});
|
});
|
||||||
onSubmit(false);
|
onSubmit(false);
|
||||||
toast.success(m.houses_page_message_create_house_success(), {
|
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 (
|
return (
|
||||||
<form
|
<form
|
||||||
id="admin-create-house-form"
|
id="admin-create-house-form"
|
||||||
@@ -94,17 +105,19 @@ const CreateNewHouseForm = ({ onSubmit }: FormProps) => {
|
|||||||
<field.TextField type="color" label={m.houses_page_form_color()} />
|
<field.TextField type="color" label={m.houses_page_form_color()} />
|
||||||
)}
|
)}
|
||||||
</form.AppField>
|
</form.AppField>
|
||||||
<form.AppField name="userId">
|
{!isPersonal && (
|
||||||
{(field) => (
|
<form.AppField name="userId">
|
||||||
<field.SelectUser
|
{(field) => (
|
||||||
label={m.houses_page_form_create_for()}
|
<field.SelectUser
|
||||||
values={users ?? []}
|
label={m.houses_page_form_create_for()}
|
||||||
placeholder="Chọn người dùng"
|
values={users ?? []}
|
||||||
keyword={userKeyword}
|
placeholder="Chọn người dùng"
|
||||||
onKeywordChange={setUserKeyword}
|
keyword={userKeyword}
|
||||||
/>
|
onKeywordChange={setUserKeyword}
|
||||||
)}
|
/>
|
||||||
</form.AppField>
|
)}
|
||||||
|
</form.AppField>
|
||||||
|
)}
|
||||||
<Field>
|
<Field>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
@@ -113,7 +126,10 @@ const CreateNewHouseForm = ({ onSubmit }: FormProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.SubscribeButton label={m.ui_confirm_btn()} />
|
<form.SubscribeButton
|
||||||
|
label={m.ui_confirm_btn()}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
</form.AppForm>
|
</form.AppForm>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ReturnError } from '@/types/common';
|
|
||||||
import { useAppForm } from '@hooks/use-app-form';
|
import { useAppForm } from '@hooks/use-app-form';
|
||||||
import { authClient } from '@lib/auth-client';
|
import { authClient } from '@lib/auth-client';
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
@@ -16,18 +15,21 @@ import { toast } from 'sonner';
|
|||||||
type EditHouseFormProps = {
|
type EditHouseFormProps = {
|
||||||
data: OrganizationWithMembers;
|
data: OrganizationWithMembers;
|
||||||
onSubmit: (open: boolean) => void;
|
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 queryClient = useQueryClient();
|
||||||
|
|
||||||
const { mutate: updateHouseMutation } = useMutation({
|
const { mutate: updateHouseMutation, isPending } = useMutation({
|
||||||
mutationFn: updateHouse,
|
mutationFn: updateHouse,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: [...housesQueries.all, 'list'],
|
queryKey: [...housesQueries.all, mutateKey],
|
||||||
});
|
});
|
||||||
onSubmit(false);
|
onSubmit(false);
|
||||||
|
refetch();
|
||||||
toast.success(m.houses_page_message_update_house_success(), {
|
toast.success(m.houses_page_message_update_house_success(), {
|
||||||
richColors: true,
|
richColors: true,
|
||||||
});
|
});
|
||||||
@@ -104,7 +106,10 @@ const EditHouseForm = ({ data, onSubmit }: EditHouseFormProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.SubscribeButton label={m.ui_confirm_btn()} />
|
<form.SubscribeButton
|
||||||
|
label={m.ui_confirm_btn()}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
</form.AppForm>
|
</form.AppForm>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ReturnError } from '@/types/common';
|
|
||||||
import { useAppForm } from '@hooks/use-app-form';
|
import { useAppForm } from '@hooks/use-app-form';
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
import { GearIcon } from '@phosphor-icons/react';
|
import { GearIcon } from '@phosphor-icons/react';
|
||||||
@@ -22,7 +21,7 @@ const SettingsForm = () => {
|
|||||||
|
|
||||||
const { data: settings, isLoading } = useQuery(settingQueries.listAdmin());
|
const { data: settings, isLoading } = useQuery(settingQueries.listAdmin());
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const { mutate: updateMutation, isPending } = useMutation({
|
||||||
mutationFn: updateAdminSettings,
|
mutationFn: updateAdminSettings,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries(settingQueries.listAdmin());
|
queryClient.invalidateQueries(settingQueries.listAdmin());
|
||||||
@@ -51,7 +50,7 @@ const SettingsForm = () => {
|
|||||||
onChange: settingSchema,
|
onChange: settingSchema,
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
updateMutation.mutate({ data: value as SettingsInput });
|
updateMutation({ data: value as SettingsInput });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,7 +88,10 @@ const SettingsForm = () => {
|
|||||||
</form.AppField>
|
</form.AppField>
|
||||||
<Field>
|
<Field>
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.SubscribeButton label={m.ui_update_btn()} />
|
<form.SubscribeButton
|
||||||
|
label={m.ui_update_btn()}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
</form.AppForm>
|
</form.AppForm>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ReturnError } from '@/types/common';
|
|
||||||
import { useAppForm } from '@hooks/use-app-form';
|
import { useAppForm } from '@hooks/use-app-form';
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
import { usersQueries } from '@service/queries';
|
import { usersQueries } from '@service/queries';
|
||||||
@@ -17,7 +16,7 @@ type FormProps = {
|
|||||||
const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
|
const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { mutate: createUserMutation } = useMutation({
|
const { mutate: createUserMutation, isPending } = useMutation({
|
||||||
mutationFn: createUser,
|
mutationFn: createUser,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -100,7 +99,10 @@ const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.SubscribeButton label={m.ui_signup_btn()} />
|
<form.SubscribeButton
|
||||||
|
label={m.ui_signup_btn()}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
</form.AppForm>
|
</form.AppForm>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ReturnError } from '@/types/common';
|
|
||||||
import { useAppForm } from '@hooks/use-app-form';
|
import { useAppForm } from '@hooks/use-app-form';
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
import { usersQueries } from '@service/queries';
|
import { usersQueries } from '@service/queries';
|
||||||
@@ -19,7 +18,7 @@ type FormProps = {
|
|||||||
const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
|
const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const setUserPasswordMutation = useMutation({
|
const { mutate: setUserPasswordMutation, isPending } = useMutation({
|
||||||
mutationFn: setUserPassword,
|
mutationFn: setUserPassword,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -50,7 +49,7 @@ const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
|
|||||||
onSubmit: userSetPasswordSchema,
|
onSubmit: userSetPasswordSchema,
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
setUserPasswordMutation.mutate({ data: value });
|
setUserPasswordMutation({ data: value });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +82,10 @@ const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.SubscribeButton label={m.ui_save_btn()} />
|
<form.SubscribeButton
|
||||||
|
label={m.ui_save_btn()}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
</form.AppForm>
|
</form.AppForm>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ReturnError } from '@/types/common';
|
|
||||||
import { useAppForm } from '@hooks/use-app-form';
|
import { useAppForm } from '@hooks/use-app-form';
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
import { usersQueries } from '@service/queries';
|
import { usersQueries } from '@service/queries';
|
||||||
@@ -24,7 +23,7 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
|
|||||||
role: data.role,
|
role: data.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateRoleMutation = useMutation({
|
const { mutate: updateRoleMutation, isPending } = useMutation({
|
||||||
mutationFn: setUserRole,
|
mutationFn: setUserRole,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.refetchQueries({
|
queryClient.refetchQueries({
|
||||||
@@ -53,7 +52,7 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
|
|||||||
onSubmit: userUpdateRoleSchema,
|
onSubmit: userUpdateRoleSchema,
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
updateRoleMutation.mutate({ data: value });
|
updateRoleMutation({ data: value });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +89,10 @@ const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.SubscribeButton label={m.ui_save_btn()} />
|
<form.SubscribeButton
|
||||||
|
label={m.ui_save_btn()}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
</form.AppForm>
|
</form.AppForm>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ReturnError } from '@/types/common';
|
|
||||||
import { useAppForm } from '@hooks/use-app-form';
|
import { useAppForm } from '@hooks/use-app-form';
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
import { usersQueries } from '@service/queries';
|
import { usersQueries } from '@service/queries';
|
||||||
@@ -19,7 +18,7 @@ type UpdateUserFormProps = {
|
|||||||
const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
|
const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const updateUserMutation = useMutation({
|
const { mutate: updateUserMutation, isPending } = useMutation({
|
||||||
mutationFn: updateUserInformation,
|
mutationFn: updateUserInformation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.refetchQueries({
|
queryClient.refetchQueries({
|
||||||
@@ -49,7 +48,7 @@ const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
|
|||||||
onChange: userUpdateInfoSchema,
|
onChange: userUpdateInfoSchema,
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
updateUserMutation.mutate({ data: value });
|
updateUserMutation({ data: value });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,7 +76,10 @@ const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.SubscribeButton label={m.ui_save_btn()} />
|
<form.SubscribeButton
|
||||||
|
label={m.ui_save_btn()}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
</form.AppForm>
|
</form.AppForm>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
import CreateNewHouseForm from '@form/house/admin-create-house-form';
|
import CreateNewHouseForm from '@form/house/admin-create-house-form';
|
||||||
import useHasPermission from '@hooks/use-has-permission';
|
import useHasPermission from '@hooks/use-has-permission';
|
||||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||||
@@ -14,7 +15,15 @@ import {
|
|||||||
} from '@ui/dialog';
|
} from '@ui/dialog';
|
||||||
import { useState } from 'react';
|
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 { hasPermission, isLoading } = useHasPermission('house', 'create');
|
||||||
const [_open, _setOpen] = useState(false);
|
const [_open, _setOpen] = useState(false);
|
||||||
const prevent = usePreventAutoFocus();
|
const prevent = usePreventAutoFocus();
|
||||||
@@ -25,7 +34,7 @@ const CreateNewHouse = () => {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={_open} onOpenChange={_setOpen}>
|
<Dialog open={_open} onOpenChange={_setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button type="button" variant="default">
|
<Button type="button" variant="default" className={cn(className)}>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
{m.nav_add_new()}
|
{m.nav_add_new()}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -44,7 +53,7 @@ const CreateNewHouse = () => {
|
|||||||
{m.nav_add_new()}
|
{m.nav_add_new()}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<CreateNewHouseForm onSubmit={_setOpen} />
|
<CreateNewHouseForm onSubmit={_setOpen} isPersonal={isPersonal} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
44
src/components/house/current-user-action-group.tsx
Normal file
44
src/components/house/current-user-action-group.tsx
Normal 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;
|
||||||
132
src/components/house/current-user-house-list.tsx
Normal file
132
src/components/house/current-user-house-list.tsx
Normal 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>
|
||||||
|
:
|
||||||
|
{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;
|
||||||
66
src/components/house/current-user-member-list.tsx
Normal file
66
src/components/house/current-user-member-list.tsx
Normal 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;
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { m } from '@/paraglide/messages';
|
import { m } from '@/paraglide/messages';
|
||||||
import { deleteHouse } from '@/service/house.api';
|
import { deleteHouse } from '@/service/house.api';
|
||||||
import { housesQueries } from '@/service/queries';
|
import { housesQueries } from '@/service/queries';
|
||||||
import { ReturnError } from '@/types/common';
|
|
||||||
import useHasPermission from '@hooks/use-has-permission';
|
import useHasPermission from '@hooks/use-has-permission';
|
||||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||||
import { ShieldWarningIcon, TrashIcon } from '@phosphor-icons/react';
|
import { ShieldWarningIcon, TrashIcon } from '@phosphor-icons/react';
|
||||||
@@ -31,6 +30,7 @@ import parse from 'html-react-parser';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import RoleBadge from '../avatar/role-badge';
|
import RoleBadge from '../avatar/role-badge';
|
||||||
|
import { Spinner } from '../ui/spinner';
|
||||||
|
|
||||||
type DeleteHouseProps = {
|
type DeleteHouseProps = {
|
||||||
data: OrganizationWithMembers;
|
data: OrganizationWithMembers;
|
||||||
@@ -43,7 +43,7 @@ const DeleteHouseAction = ({ data }: DeleteHouseProps) => {
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { mutate: deleteHouseMutation } = useMutation({
|
const { mutate: deleteHouseMutation, isPending } = useMutation({
|
||||||
mutationFn: deleteHouse,
|
mutationFn: deleteHouse,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -140,7 +140,13 @@ const DeleteHouseAction = ({ data }: DeleteHouseProps) => {
|
|||||||
{m.ui_cancel_btn()}
|
{m.ui_cancel_btn()}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</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()}
|
{m.ui_confirm_btn()}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
163
src/components/house/delete-user-house-dialog.tsx
Normal file
163
src/components/house/delete-user-house-dialog.tsx
Normal 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;
|
||||||
@@ -3,7 +3,6 @@ import useHasPermission from '@hooks/use-has-permission';
|
|||||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
import { PenIcon } from '@phosphor-icons/react';
|
import { PenIcon } from '@phosphor-icons/react';
|
||||||
import { Button } from '@ui/button';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -18,9 +17,15 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
type EditHouseProps = {
|
type EditHouseProps = {
|
||||||
data: OrganizationWithMembers;
|
data: OrganizationWithMembers;
|
||||||
|
children: React.ReactNode;
|
||||||
|
isPersonal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditHouseAction = ({ data }: EditHouseProps) => {
|
const EditHouseAction = ({
|
||||||
|
data,
|
||||||
|
children,
|
||||||
|
isPersonal = false,
|
||||||
|
}: EditHouseProps) => {
|
||||||
const [_open, _setOpen] = useState(false);
|
const [_open, _setOpen] = useState(false);
|
||||||
const prevent = usePreventAutoFocus();
|
const prevent = usePreventAutoFocus();
|
||||||
const { hasPermission, isLoading } = useHasPermission('house', 'update');
|
const { hasPermission, isLoading } = useHasPermission('house', 'update');
|
||||||
@@ -32,17 +37,7 @@ const EditHouseAction = ({ data }: EditHouseProps) => {
|
|||||||
<Dialog open={_open} onOpenChange={_setOpen}>
|
<Dialog open={_open} onOpenChange={_setOpen}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<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>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="bg-blue-500 [&_svg]:bg-blue-500 [&_svg]:fill-blue-500 text-white">
|
<TooltipContent className="bg-blue-500 [&_svg]:bg-blue-500 [&_svg]:fill-blue-500 text-white">
|
||||||
<Label>{m.ui_edit_house_btn()}</Label>
|
<Label>{m.ui_edit_house_btn()}</Label>
|
||||||
@@ -62,7 +57,11 @@ const EditHouseAction = ({ data }: EditHouseProps) => {
|
|||||||
{m.ui_edit_house_btn()}
|
{m.ui_edit_house_btn()}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<EditHouseForm data={data} onSubmit={_setOpen} />
|
<EditHouseForm
|
||||||
|
data={data}
|
||||||
|
onSubmit={_setOpen}
|
||||||
|
mutateKey={isPersonal ? 'currentUser' : 'list'}
|
||||||
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
|
import { PenIcon } from '@phosphor-icons/react';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { formatters } from '@utils/formatters';
|
import { formatters } from '@utils/formatters';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
import DeleteHouseAction from './delete-house-dialog';
|
import DeleteHouseAction from './delete-house-dialog';
|
||||||
import EditHouseAction from './edit-house-dialog';
|
import EditHouseAction from './edit-house-dialog';
|
||||||
import ViewDetailHouse from './view-house-detail-dialog';
|
import ViewDetailHouse from './view-house-detail-dialog';
|
||||||
@@ -42,7 +44,17 @@ export const houseColumns: ColumnDef<OrganizationWithMembers>[] = [
|
|||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<ViewDetailHouse data={row.original} />
|
<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} />
|
<DeleteHouseAction data={row.original} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@ui/sidebar';
|
} from '@ui/sidebar';
|
||||||
|
import React from 'react';
|
||||||
import AdminShow from '../auth/AdminShow';
|
import AdminShow from '../auth/AdminShow';
|
||||||
import AuthShow from '../auth/AuthShow';
|
import AuthShow from '../auth/AuthShow';
|
||||||
|
|
||||||
@@ -25,94 +26,107 @@ const NAV_MAIN = [
|
|||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
title: m.nav_label_basic(),
|
title: m.nav_label_basic(),
|
||||||
|
isAuth: false,
|
||||||
|
admin: false,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: m.nav_home(),
|
title: m.nav_home(),
|
||||||
path: '/',
|
path: '/',
|
||||||
icon: HouseIcon,
|
icon: HouseIcon,
|
||||||
isAuth: true,
|
|
||||||
admin: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: m.nav_dashboard(),
|
|
||||||
path: '/dashboard',
|
|
||||||
icon: GaugeIcon,
|
|
||||||
isAuth: true,
|
|
||||||
admin: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
title: m.nav_label_management(),
|
title: m.nav_label_management(),
|
||||||
|
isAuth: true,
|
||||||
|
admin: false,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: m.nav_houses(),
|
title: m.nav_dashboard(),
|
||||||
path: '/kanri/houses',
|
path: '/management/dashboard',
|
||||||
icon: WarehouseIcon,
|
icon: GaugeIcon,
|
||||||
isAuth: false,
|
|
||||||
admin: true,
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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(),
|
title: m.nav_users(),
|
||||||
path: '/kanri/users',
|
path: '/kanri/users',
|
||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
isAuth: false,
|
},
|
||||||
admin: true,
|
{
|
||||||
|
title: m.nav_houses(),
|
||||||
|
path: '/kanri/houses',
|
||||||
|
icon: WarehouseIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: m.nav_logs(),
|
title: m.nav_logs(),
|
||||||
path: '/kanri/logs',
|
path: '/kanri/logs',
|
||||||
icon: CircuitryIcon,
|
icon: CircuitryIcon,
|
||||||
isAuth: false,
|
|
||||||
admin: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: m.nav_settings(),
|
title: m.nav_settings(),
|
||||||
path: '/kanri/settings',
|
path: '/kanri/settings',
|
||||||
icon: GearIcon,
|
icon: GearIcon,
|
||||||
isAuth: false,
|
|
||||||
admin: true,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function EmptyComponent({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
const NavMain = () => {
|
const NavMain = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{NAV_MAIN.map((nav) => (
|
{NAV_MAIN.map((nav) => {
|
||||||
<SidebarGroup key={nav.id} className="overflow-hidden">
|
const { isAuth, admin } = nav;
|
||||||
<SidebarGroupLabel>{nav.title}</SidebarGroupLabel>
|
const Component = admin
|
||||||
<SidebarGroupContent>
|
? AdminShow
|
||||||
<SidebarMenu>
|
: isAuth
|
||||||
{nav.items.map((item) => {
|
? AuthShow
|
||||||
const Icon = item.icon;
|
: EmptyComponent;
|
||||||
const Menu = (
|
|
||||||
<SidebarMenuItem>
|
return (
|
||||||
<SidebarMenuButtonLink
|
<Component key={nav.id}>
|
||||||
type="button"
|
<SidebarGroup className="overflow-hidden">
|
||||||
to={item.path}
|
<SidebarGroupLabel>{nav.title}</SidebarGroupLabel>
|
||||||
className="cursor-pointer"
|
<SidebarGroupContent>
|
||||||
tooltip={item.title}
|
<SidebarMenu>
|
||||||
>
|
{nav.items.map((item) => {
|
||||||
<Icon size={24} />
|
const Icon = item.icon;
|
||||||
{item.title}
|
return (
|
||||||
</SidebarMenuButtonLink>
|
<SidebarMenuItem key={item.path}>
|
||||||
</SidebarMenuItem>
|
<SidebarMenuButtonLink
|
||||||
);
|
type="button"
|
||||||
return item.isAuth ? (
|
to={item.path}
|
||||||
<AuthShow key={item.path}>{Menu}</AuthShow>
|
className="cursor-pointer"
|
||||||
) : item.admin ? (
|
tooltip={`${nav.title} - ${item.title}`}
|
||||||
<AdminShow key={item.path}>{Menu}</AdminShow>
|
>
|
||||||
) : (
|
<Icon size={24} />
|
||||||
Menu
|
{item.title}
|
||||||
);
|
</SidebarMenuButtonLink>
|
||||||
})}
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
);
|
||||||
</SidebarGroupContent>
|
})}
|
||||||
</SidebarGroup>
|
</SidebarMenu>
|
||||||
))}
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
53
src/components/ui/scroll-area.tsx
Normal file
53
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||||
@@ -135,7 +135,7 @@ export function SelectUser({
|
|||||||
<div
|
<div
|
||||||
id={listboxId}
|
id={listboxId}
|
||||||
role="listbox"
|
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">
|
<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" />
|
<MagnifyingGlassIcon className="text-muted-foreground size-3.5 shrink-0" />
|
||||||
|
|||||||
10
src/components/ui/spinner.tsx
Normal file
10
src/components/ui/spinner.tsx
Normal 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 }
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ReturnError } from '@/types/common';
|
|
||||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
import { ShieldWarningIcon } from '@phosphor-icons/react';
|
import { ShieldWarningIcon } from '@phosphor-icons/react';
|
||||||
@@ -17,6 +16,7 @@ import {
|
|||||||
} from '@ui/dialog';
|
} from '@ui/dialog';
|
||||||
import { UserWithRole } from 'better-auth/plugins';
|
import { UserWithRole } from 'better-auth/plugins';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { Spinner } from '../ui/spinner';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -36,7 +36,7 @@ const BanUserConfirm = ({ data }: BanConfirmProps) => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const prevent = usePreventAutoFocus();
|
const prevent = usePreventAutoFocus();
|
||||||
|
|
||||||
const { mutate: banUserMutation } = useMutation({
|
const { mutate: banUserMutation, isPending } = useMutation({
|
||||||
mutationFn: banUser,
|
mutationFn: banUser,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.refetchQueries({
|
queryClient.refetchQueries({
|
||||||
@@ -130,7 +130,13 @@ const BanUserConfirm = ({ data }: BanConfirmProps) => {
|
|||||||
{m.ui_cancel_btn()}
|
{m.ui_cancel_btn()}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</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()}
|
{m.ui_confirm_btn()}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ReturnError } from '@/types/common';
|
|
||||||
import useHasPermission from '@hooks/use-has-permission';
|
import useHasPermission from '@hooks/use-has-permission';
|
||||||
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
@@ -23,7 +22,15 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
|
|||||||
import { UserWithRole } from 'better-auth/plugins';
|
import { UserWithRole } from 'better-auth/plugins';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
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 = {
|
type UnbanUserProps = {
|
||||||
data: UserWithRole;
|
data: UserWithRole;
|
||||||
@@ -38,7 +45,7 @@ const UnbanUserAction = ({ data }: UnbanUserProps) => {
|
|||||||
const [_open, _setOpen] = useState(false);
|
const [_open, _setOpen] = useState(false);
|
||||||
const prevent = usePreventAutoFocus();
|
const prevent = usePreventAutoFocus();
|
||||||
|
|
||||||
const { mutate: unbanMutation } = useMutation({
|
const { mutate: unbanMutation, isPending } = useMutation({
|
||||||
mutationFn: unbanUser,
|
mutationFn: unbanUser,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.refetchQueries({
|
queryClient.refetchQueries({
|
||||||
@@ -107,19 +114,47 @@ const UnbanUserAction = ({ data }: UnbanUserProps) => {
|
|||||||
{m.users_page_ui_dialog_alert_title()}
|
{m.users_page_ui_dialog_alert_title()}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DisplayBreakLineMessage>
|
<div className="overflow-hidden rounded-md border">
|
||||||
{m.users_page_ui_dialog_alert_description({
|
<Table className="bg-white">
|
||||||
name: data.name,
|
<TableHeader>
|
||||||
email: data.email,
|
<TableRow className="bg-primary">
|
||||||
})}
|
<TableHead
|
||||||
</DisplayBreakLineMessage>
|
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">
|
<DialogFooter className="bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4">
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button variant="outline" type="button">
|
<Button variant="outline" type="button">
|
||||||
{m.ui_cancel_btn()}
|
{m.ui_cancel_btn()}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</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()}
|
{m.ui_confirm_btn()}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
owner,
|
owner,
|
||||||
} from '@lib/auth/organization-permissions';
|
} from '@lib/auth/organization-permissions';
|
||||||
import { ac, admin, user } from '@lib/auth/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 { betterAuth } from 'better-auth';
|
||||||
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
||||||
import { admin as adminPlugin, organization } from 'better-auth/plugins';
|
import { admin as adminPlugin, organization } from 'better-auth/plugins';
|
||||||
@@ -117,6 +117,15 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
create: {
|
create: {
|
||||||
|
before: async (session) => {
|
||||||
|
const organization = await getInitialOrganization(session.userId);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
...session,
|
||||||
|
activeOrganizationId: organization?.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
after: async (session, context) => {
|
after: async (session, context) => {
|
||||||
if (context?.path.includes('/sign-in')) {
|
if (context?.path.includes('/sign-in')) {
|
||||||
await createAuditLog({
|
await createAuditLog({
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ import { Route as appIndexRouteImport } from './routes/(app)/index'
|
|||||||
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
|
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
|
||||||
import { Route as appauthRouteRouteImport } from './routes/(app)/(auth)/route'
|
import { Route as appauthRouteRouteImport } from './routes/(app)/(auth)/route'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api.auth.$'
|
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 appauthKanriRouteRouteImport } from './routes/(app)/(auth)/kanri/route'
|
||||||
import { Route as appauthAccountRouteRouteImport } from './routes/(app)/(auth)/account/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 appauthKanriIndexRouteImport } from './routes/(app)/(auth)/kanri/index'
|
||||||
import { Route as appauthAccountIndexRouteImport } from './routes/(app)/(auth)/account/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 appauthKanriUsersRouteImport } from './routes/(app)/(auth)/kanri/users'
|
||||||
import { Route as appauthKanriSettingsRouteImport } from './routes/(app)/(auth)/kanri/settings'
|
import { Route as appauthKanriSettingsRouteImport } from './routes/(app)/(auth)/kanri/settings'
|
||||||
import { Route as appauthKanriLogsRouteImport } from './routes/(app)/(auth)/kanri/logs'
|
import { Route as appauthKanriLogsRouteImport } from './routes/(app)/(auth)/kanri/logs'
|
||||||
@@ -50,9 +53,9 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
|||||||
path: '/api/auth/$',
|
path: '/api/auth/$',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const appauthDashboardRoute = appauthDashboardRouteImport.update({
|
const appauthManagementRouteRoute = appauthManagementRouteRouteImport.update({
|
||||||
id: '/dashboard',
|
id: '/management',
|
||||||
path: '/dashboard',
|
path: '/management',
|
||||||
getParentRoute: () => appauthRouteRoute,
|
getParentRoute: () => appauthRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const appauthKanriRouteRoute = appauthKanriRouteRouteImport.update({
|
const appauthKanriRouteRoute = appauthKanriRouteRouteImport.update({
|
||||||
@@ -65,6 +68,11 @@ const appauthAccountRouteRoute = appauthAccountRouteRouteImport.update({
|
|||||||
path: '/account',
|
path: '/account',
|
||||||
getParentRoute: () => appauthRouteRoute,
|
getParentRoute: () => appauthRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const appauthManagementIndexRoute = appauthManagementIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => appauthManagementRouteRoute,
|
||||||
|
} as any)
|
||||||
const appauthKanriIndexRoute = appauthKanriIndexRouteImport.update({
|
const appauthKanriIndexRoute = appauthKanriIndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -75,6 +83,17 @@ const appauthAccountIndexRoute = appauthAccountIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => appauthAccountRouteRoute,
|
getParentRoute: () => appauthAccountRouteRoute,
|
||||||
} as any)
|
} 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({
|
const appauthKanriUsersRoute = appauthKanriUsersRouteImport.update({
|
||||||
id: '/users',
|
id: '/users',
|
||||||
path: '/users',
|
path: '/users',
|
||||||
@@ -117,7 +136,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof appIndexRoute
|
'/': typeof appIndexRoute
|
||||||
'/account': typeof appauthAccountRouteRouteWithChildren
|
'/account': typeof appauthAccountRouteRouteWithChildren
|
||||||
'/kanri': typeof appauthKanriRouteRouteWithChildren
|
'/kanri': typeof appauthKanriRouteRouteWithChildren
|
||||||
'/dashboard': typeof appauthDashboardRoute
|
'/management': typeof appauthManagementRouteRouteWithChildren
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/account/change-password': typeof appauthAccountChangePasswordRoute
|
'/account/change-password': typeof appauthAccountChangePasswordRoute
|
||||||
'/account/profile': typeof appauthAccountProfileRoute
|
'/account/profile': typeof appauthAccountProfileRoute
|
||||||
@@ -126,13 +145,15 @@ export interface FileRoutesByFullPath {
|
|||||||
'/kanri/logs': typeof appauthKanriLogsRoute
|
'/kanri/logs': typeof appauthKanriLogsRoute
|
||||||
'/kanri/settings': typeof appauthKanriSettingsRoute
|
'/kanri/settings': typeof appauthKanriSettingsRoute
|
||||||
'/kanri/users': typeof appauthKanriUsersRoute
|
'/kanri/users': typeof appauthKanriUsersRoute
|
||||||
|
'/management/dashboard': typeof appauthManagementDashboardRoute
|
||||||
|
'/management/houses': typeof appauthManagementHousesRoute
|
||||||
'/account/': typeof appauthAccountIndexRoute
|
'/account/': typeof appauthAccountIndexRoute
|
||||||
'/kanri/': typeof appauthKanriIndexRoute
|
'/kanri/': typeof appauthKanriIndexRoute
|
||||||
|
'/management/': typeof appauthManagementIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/sign-in': typeof authSignInRoute
|
'/sign-in': typeof authSignInRoute
|
||||||
'/': typeof appIndexRoute
|
'/': typeof appIndexRoute
|
||||||
'/dashboard': typeof appauthDashboardRoute
|
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/account/change-password': typeof appauthAccountChangePasswordRoute
|
'/account/change-password': typeof appauthAccountChangePasswordRoute
|
||||||
'/account/profile': typeof appauthAccountProfileRoute
|
'/account/profile': typeof appauthAccountProfileRoute
|
||||||
@@ -141,8 +162,11 @@ export interface FileRoutesByTo {
|
|||||||
'/kanri/logs': typeof appauthKanriLogsRoute
|
'/kanri/logs': typeof appauthKanriLogsRoute
|
||||||
'/kanri/settings': typeof appauthKanriSettingsRoute
|
'/kanri/settings': typeof appauthKanriSettingsRoute
|
||||||
'/kanri/users': typeof appauthKanriUsersRoute
|
'/kanri/users': typeof appauthKanriUsersRoute
|
||||||
|
'/management/dashboard': typeof appauthManagementDashboardRoute
|
||||||
|
'/management/houses': typeof appauthManagementHousesRoute
|
||||||
'/account': typeof appauthAccountIndexRoute
|
'/account': typeof appauthAccountIndexRoute
|
||||||
'/kanri': typeof appauthKanriIndexRoute
|
'/kanri': typeof appauthKanriIndexRoute
|
||||||
|
'/management': typeof appauthManagementIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -152,7 +176,7 @@ export interface FileRoutesById {
|
|||||||
'/(app)/': typeof appIndexRoute
|
'/(app)/': typeof appIndexRoute
|
||||||
'/(app)/(auth)/account': typeof appauthAccountRouteRouteWithChildren
|
'/(app)/(auth)/account': typeof appauthAccountRouteRouteWithChildren
|
||||||
'/(app)/(auth)/kanri': typeof appauthKanriRouteRouteWithChildren
|
'/(app)/(auth)/kanri': typeof appauthKanriRouteRouteWithChildren
|
||||||
'/(app)/(auth)/dashboard': typeof appauthDashboardRoute
|
'/(app)/(auth)/management': typeof appauthManagementRouteRouteWithChildren
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/(app)/(auth)/account/change-password': typeof appauthAccountChangePasswordRoute
|
'/(app)/(auth)/account/change-password': typeof appauthAccountChangePasswordRoute
|
||||||
'/(app)/(auth)/account/profile': typeof appauthAccountProfileRoute
|
'/(app)/(auth)/account/profile': typeof appauthAccountProfileRoute
|
||||||
@@ -161,8 +185,11 @@ export interface FileRoutesById {
|
|||||||
'/(app)/(auth)/kanri/logs': typeof appauthKanriLogsRoute
|
'/(app)/(auth)/kanri/logs': typeof appauthKanriLogsRoute
|
||||||
'/(app)/(auth)/kanri/settings': typeof appauthKanriSettingsRoute
|
'/(app)/(auth)/kanri/settings': typeof appauthKanriSettingsRoute
|
||||||
'/(app)/(auth)/kanri/users': typeof appauthKanriUsersRoute
|
'/(app)/(auth)/kanri/users': typeof appauthKanriUsersRoute
|
||||||
|
'/(app)/(auth)/management/dashboard': typeof appauthManagementDashboardRoute
|
||||||
|
'/(app)/(auth)/management/houses': typeof appauthManagementHousesRoute
|
||||||
'/(app)/(auth)/account/': typeof appauthAccountIndexRoute
|
'/(app)/(auth)/account/': typeof appauthAccountIndexRoute
|
||||||
'/(app)/(auth)/kanri/': typeof appauthKanriIndexRoute
|
'/(app)/(auth)/kanri/': typeof appauthKanriIndexRoute
|
||||||
|
'/(app)/(auth)/management/': typeof appauthManagementIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -171,7 +198,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/account'
|
| '/account'
|
||||||
| '/kanri'
|
| '/kanri'
|
||||||
| '/dashboard'
|
| '/management'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/account/change-password'
|
| '/account/change-password'
|
||||||
| '/account/profile'
|
| '/account/profile'
|
||||||
@@ -180,13 +207,15 @@ export interface FileRouteTypes {
|
|||||||
| '/kanri/logs'
|
| '/kanri/logs'
|
||||||
| '/kanri/settings'
|
| '/kanri/settings'
|
||||||
| '/kanri/users'
|
| '/kanri/users'
|
||||||
|
| '/management/dashboard'
|
||||||
|
| '/management/houses'
|
||||||
| '/account/'
|
| '/account/'
|
||||||
| '/kanri/'
|
| '/kanri/'
|
||||||
|
| '/management/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
| '/'
|
| '/'
|
||||||
| '/dashboard'
|
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/account/change-password'
|
| '/account/change-password'
|
||||||
| '/account/profile'
|
| '/account/profile'
|
||||||
@@ -195,8 +224,11 @@ export interface FileRouteTypes {
|
|||||||
| '/kanri/logs'
|
| '/kanri/logs'
|
||||||
| '/kanri/settings'
|
| '/kanri/settings'
|
||||||
| '/kanri/users'
|
| '/kanri/users'
|
||||||
|
| '/management/dashboard'
|
||||||
|
| '/management/houses'
|
||||||
| '/account'
|
| '/account'
|
||||||
| '/kanri'
|
| '/kanri'
|
||||||
|
| '/management'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/(app)'
|
| '/(app)'
|
||||||
@@ -205,7 +237,7 @@ export interface FileRouteTypes {
|
|||||||
| '/(app)/'
|
| '/(app)/'
|
||||||
| '/(app)/(auth)/account'
|
| '/(app)/(auth)/account'
|
||||||
| '/(app)/(auth)/kanri'
|
| '/(app)/(auth)/kanri'
|
||||||
| '/(app)/(auth)/dashboard'
|
| '/(app)/(auth)/management'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/(app)/(auth)/account/change-password'
|
| '/(app)/(auth)/account/change-password'
|
||||||
| '/(app)/(auth)/account/profile'
|
| '/(app)/(auth)/account/profile'
|
||||||
@@ -214,8 +246,11 @@ export interface FileRouteTypes {
|
|||||||
| '/(app)/(auth)/kanri/logs'
|
| '/(app)/(auth)/kanri/logs'
|
||||||
| '/(app)/(auth)/kanri/settings'
|
| '/(app)/(auth)/kanri/settings'
|
||||||
| '/(app)/(auth)/kanri/users'
|
| '/(app)/(auth)/kanri/users'
|
||||||
|
| '/(app)/(auth)/management/dashboard'
|
||||||
|
| '/(app)/(auth)/management/houses'
|
||||||
| '/(app)/(auth)/account/'
|
| '/(app)/(auth)/account/'
|
||||||
| '/(app)/(auth)/kanri/'
|
| '/(app)/(auth)/kanri/'
|
||||||
|
| '/(app)/(auth)/management/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -261,11 +296,11 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/(app)/(auth)/dashboard': {
|
'/(app)/(auth)/management': {
|
||||||
id: '/(app)/(auth)/dashboard'
|
id: '/(app)/(auth)/management'
|
||||||
path: '/dashboard'
|
path: '/management'
|
||||||
fullPath: '/dashboard'
|
fullPath: '/management'
|
||||||
preLoaderRoute: typeof appauthDashboardRouteImport
|
preLoaderRoute: typeof appauthManagementRouteRouteImport
|
||||||
parentRoute: typeof appauthRouteRoute
|
parentRoute: typeof appauthRouteRoute
|
||||||
}
|
}
|
||||||
'/(app)/(auth)/kanri': {
|
'/(app)/(auth)/kanri': {
|
||||||
@@ -282,6 +317,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof appauthAccountRouteRouteImport
|
preLoaderRoute: typeof appauthAccountRouteRouteImport
|
||||||
parentRoute: typeof appauthRouteRoute
|
parentRoute: typeof appauthRouteRoute
|
||||||
}
|
}
|
||||||
|
'/(app)/(auth)/management/': {
|
||||||
|
id: '/(app)/(auth)/management/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/management/'
|
||||||
|
preLoaderRoute: typeof appauthManagementIndexRouteImport
|
||||||
|
parentRoute: typeof appauthManagementRouteRoute
|
||||||
|
}
|
||||||
'/(app)/(auth)/kanri/': {
|
'/(app)/(auth)/kanri/': {
|
||||||
id: '/(app)/(auth)/kanri/'
|
id: '/(app)/(auth)/kanri/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -296,6 +338,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof appauthAccountIndexRouteImport
|
preLoaderRoute: typeof appauthAccountIndexRouteImport
|
||||||
parentRoute: typeof appauthAccountRouteRoute
|
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': {
|
'/(app)/(auth)/kanri/users': {
|
||||||
id: '/(app)/(auth)/kanri/users'
|
id: '/(app)/(auth)/kanri/users'
|
||||||
path: '/users'
|
path: '/users'
|
||||||
@@ -384,16 +440,34 @@ const appauthKanriRouteRouteChildren: appauthKanriRouteRouteChildren = {
|
|||||||
const appauthKanriRouteRouteWithChildren =
|
const appauthKanriRouteRouteWithChildren =
|
||||||
appauthKanriRouteRoute._addFileChildren(appauthKanriRouteRouteChildren)
|
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 {
|
interface appauthRouteRouteChildren {
|
||||||
appauthAccountRouteRoute: typeof appauthAccountRouteRouteWithChildren
|
appauthAccountRouteRoute: typeof appauthAccountRouteRouteWithChildren
|
||||||
appauthKanriRouteRoute: typeof appauthKanriRouteRouteWithChildren
|
appauthKanriRouteRoute: typeof appauthKanriRouteRouteWithChildren
|
||||||
appauthDashboardRoute: typeof appauthDashboardRoute
|
appauthManagementRouteRoute: typeof appauthManagementRouteRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
const appauthRouteRouteChildren: appauthRouteRouteChildren = {
|
const appauthRouteRouteChildren: appauthRouteRouteChildren = {
|
||||||
appauthAccountRouteRoute: appauthAccountRouteRouteWithChildren,
|
appauthAccountRouteRoute: appauthAccountRouteRouteWithChildren,
|
||||||
appauthKanriRouteRoute: appauthKanriRouteRouteWithChildren,
|
appauthKanriRouteRoute: appauthKanriRouteRouteWithChildren,
|
||||||
appauthDashboardRoute: appauthDashboardRoute,
|
appauthManagementRouteRoute: appauthManagementRouteRouteWithChildren,
|
||||||
}
|
}
|
||||||
|
|
||||||
const appauthRouteRouteWithChildren = appauthRouteRoute._addFileChildren(
|
const appauthRouteRouteWithChildren = appauthRouteRoute._addFileChildren(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
export const Route = createFileRoute('/(app)/(auth)/kanri/houses')({
|
export const Route = createFileRoute('/(app)/(auth)/kanri/houses')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
staticData: { breadcrumb: () => m.nav_houses() },
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
@@ -54,7 +55,7 @@ function RouteComponent() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
keywords={searchKeyword}
|
keywords={searchKeyword}
|
||||||
setKeyword={setSearchKeyword}
|
setKeyword={setSearchKeyword}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
export const Route = createFileRoute('/(app)/(auth)/dashboard')({
|
export const Route = createFileRoute('/(app)/(auth)/management/dashboard')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
staticData: { breadcrumb: () => m.nav_dashboard() },
|
staticData: { breadcrumb: () => m.nav_dashboard() },
|
||||||
});
|
});
|
||||||
30
src/routes/(app)/(auth)/management/houses.tsx
Normal file
30
src/routes/(app)/(auth)/management/houses.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/routes/(app)/(auth)/management/index.tsx
Normal file
9
src/routes/(app)/(auth)/management/index.tsx
Normal 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>
|
||||||
|
}
|
||||||
6
src/routes/(app)/(auth)/management/route.tsx
Normal file
6
src/routes/(app)/(auth)/management/route.tsx
Normal 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() },
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ function RouteComponent() {
|
|||||||
}, [language]);
|
}, [language]);
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SidebarProvider defaultOpen={false}>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<Header />
|
<Header />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { DB_TABLE, LOG_ACTION } from '@/types/enum';
|
|||||||
import { auth } from '@lib/auth';
|
import { auth } from '@lib/auth';
|
||||||
import { authMiddleware } from '@lib/middleware';
|
import { authMiddleware } from '@lib/middleware';
|
||||||
import { createServerFn } from '@tanstack/react-start';
|
import { createServerFn } from '@tanstack/react-start';
|
||||||
|
import { getRequestHeaders } from '@tanstack/react-start/server';
|
||||||
import { parseError } from '@utils/helper';
|
import { parseError } from '@utils/helper';
|
||||||
import {
|
import {
|
||||||
baseHouse,
|
baseHouse,
|
||||||
@@ -43,6 +44,7 @@ export const getAllHouse = createServerFn({ method: 'GET' })
|
|||||||
role: true,
|
role: true,
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
image: 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' })
|
export const createHouse = createServerFn({ method: 'POST' })
|
||||||
.middleware([authMiddleware])
|
.middleware([authMiddleware])
|
||||||
.inputValidator(houseCreateBESchema)
|
.inputValidator(houseCreateBESchema)
|
||||||
@@ -177,3 +216,40 @@ export const deleteHouse = createServerFn({ method: 'POST' })
|
|||||||
throw { message, code };
|
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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export const houseCreateBESchema = houseCreateSchema.extend({
|
|||||||
slug: z.string().nonempty(m.common_is_required({ field: 'Slug' })),
|
slug: z.string().nonempty(m.common_is_required({ field: 'Slug' })),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const houseCreateByUserBESchema = houseCreateBESchema.omit({
|
||||||
|
userId: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const houseEditSchema = baseHouse.extend({
|
export const houseEditSchema = baseHouse.extend({
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getSession } from '@lib/auth/session';
|
import { getSession } from '@lib/auth/session';
|
||||||
import { queryOptions } from '@tanstack/react-query';
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
import { getAllAudit } from './audit.api';
|
import { getAllAudit } from './audit.api';
|
||||||
import { getAllHouse } from './house.api';
|
import { getAllHouse, getCurrentUserHouses } from './house.api';
|
||||||
import {
|
import {
|
||||||
getAdminSettings,
|
getAdminSettings,
|
||||||
getCurrentUserLanguage,
|
getCurrentUserLanguage,
|
||||||
@@ -69,4 +69,9 @@ export const housesQueries = {
|
|||||||
queryKey: [...housesQueries.all, 'list', params],
|
queryKey: [...housesQueries.all, 'list', params],
|
||||||
queryFn: () => getAllHouse({ data: params }),
|
queryFn: () => getAllHouse({ data: params }),
|
||||||
}),
|
}),
|
||||||
|
currentUser: () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [...housesQueries.all, 'currentUser'],
|
||||||
|
queryFn: () => getCurrentUserHouses(),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,3 +54,16 @@ export const createAuditLog = async (data: Omit<Audit, 'id' | 'createdAt'>) => {
|
|||||||
throw { message, code };
|
throw { message, code };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getInitialOrganization = async (userId: string) => {
|
||||||
|
const organization = await prisma.organization.findFirst({
|
||||||
|
where: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return organization;
|
||||||
|
};
|
||||||
|
|||||||
4
src/types/common.d.ts
vendored
4
src/types/common.d.ts
vendored
@@ -1,4 +0,0 @@
|
|||||||
export interface ReturnError extends Error {
|
|
||||||
message: string;
|
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
|
|||||||
6
src/types/db.d.ts
vendored
6
src/types/db.d.ts
vendored
@@ -19,6 +19,7 @@ declare global {
|
|||||||
role: true;
|
role: true;
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
|
id: true;
|
||||||
name: true;
|
name: true;
|
||||||
email: true;
|
email: true;
|
||||||
image: true;
|
image: true;
|
||||||
@@ -28,4 +29,9 @@ declare global {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
type ReturnError = Error & {
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user