add function for user

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

View File

@@ -49,6 +49,7 @@
"ui_delete_btn": "Delete", "ui_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"
} }
} }
] ]

View File

@@ -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"
} }
} }
] ]

View File

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

View File

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

View File

@@ -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>
); );
} }

View File

@@ -1,4 +1,4 @@
import { ReturnError } from '@/types/common'; import { useAuth } from '@/components/auth/auth-provider';
import { useAppForm } from '@hooks/use-app-form'; import { 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
import { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages';
import { GearIcon, PenIcon } from '@phosphor-icons/react';
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
import { Button } from '../ui/button';
import DeleteUserHouseAction from './delete-user-house-dialog';
import EditHouseAction from './edit-house-dialog';
type CurrentUserActionGroupProps = {
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
};
const CurrentUserActionGroup = ({
activeHouse,
}: CurrentUserActionGroupProps) => {
return (
<Card className="col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GearIcon />
{m.houses_user_page_block_action_title()}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-2">
<EditHouseAction
data={activeHouse as OrganizationWithMembers}
isPersonal
>
<Button
type="button"
size="icon-lg"
className="rounded-full cursor-pointer bg-blue-500 text-white hover:bg-blue-100 hover:text-blue-600"
>
<PenIcon size={16} />
<span className="sr-only">{m.ui_edit_house_btn()}</span>
</Button>
</EditHouseAction>
<DeleteUserHouseAction activeHouse={activeHouse} />
</CardContent>
</Card>
);
};
export default CurrentUserActionGroup;

View File

@@ -0,0 +1,132 @@
import { authClient } from '@lib/auth-client';
import { cn } from '@lib/utils';
import { m } from '@paraglide/messages';
import { CheckIcon, WarehouseIcon } from '@phosphor-icons/react';
import { housesQueries } from '@service/queries';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
import parse from 'html-react-parser';
import { toast } from 'sonner';
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from '../ui/item';
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
import { Skeleton } from '../ui/skeleton';
import CreateNewHouse from './create-house-dialog';
type CurrentUserHouseListProps = {
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
};
const CurrentUserHouseList = ({ activeHouse }: CurrentUserHouseListProps) => {
const { data: houses } = useQuery(housesQueries.currentUser());
const activeHouseAction = async ({
id,
slug,
}: {
id: string;
slug: string;
}) => {
const { data, error } = await authClient.organization.setActive({
organizationId: id,
organizationSlug: slug,
});
if (error) {
toast.error(error.message, { richColors: true });
}
if (data) {
toast.success(
parse(
m.houses_user_page_message_active_house_success({ house: data.name }),
),
{
richColors: true,
},
);
}
};
if (!activeHouse || !houses) {
return <Skeleton className="col-span-2 h-80 w-full rounded-xl" />;
}
return (
<Card className="col-span-1 lg:col-span-3">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<WarehouseIcon size={24} />
{m.houses_page_ui_title()}
<CreateNewHouse isPersonal className="ml-auto" />
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="w-full rounded-md border whitespace-nowrap bg-gray-50">
<div className="flex w-max p-4 space-x-4">
{houses.map((house) => {
const isActive = house.id === activeHouse.id;
return (
<Item
variant="outline"
className={cn('w-100 bg-white', {
'bg-linear-to-tr from-white/2 to-green-200': isActive,
})}
key={house.id}
>
<ItemContent>
<ItemTitle
className="font-bold text-sm text-(--house-color)"
style={
{ '--house-color': house.color } as React.CSSProperties
}
>
{house.name}
</ItemTitle>
<ItemDescription>
<strong>{m.houses_page_ui_table_header_members()}</strong>
:&nbsp;
{house._count.members}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="outline"
data-active={isActive}
disabled={isActive}
className={cn('rounded-full cursor-pointer', {
'disabled:bg-green-500! disabled:border-green-500!':
isActive,
'bg-amber-50! text-amber-600! border-amber-600!':
!isActive,
})}
size="icon-lg"
onClick={() =>
activeHouseAction({ id: house.id, slug: house.slug })
}
>
<CheckIcon weight="bold" />
<span className="sr-only">
{m.houses_page_house_active_btn()}
</span>
</Button>
</ItemActions>
</Item>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</CardContent>
</Card>
);
};
export default CurrentUserHouseList;

View File

@@ -0,0 +1,66 @@
import { authClient } from '@lib/auth-client';
import { m } from '@paraglide/messages';
import { Skeleton } from '@ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@ui/table';
import RoleBadge from '../avatar/role-badge';
import { Item, ItemContent, ItemDescription, ItemTitle } from '../ui/item';
type CurrentUserMemberListProps = {
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
};
const CurrentUserMemberList = ({ activeHouse }: CurrentUserMemberListProps) => {
if (!activeHouse) {
return <Skeleton className="col-span-2 h-80 w-full rounded-xl" />;
}
return (
<div className="overflow-hidden rounded-md border col-span-1 lg:col-span-2 shadow-xs bg-linear-to-br from-primary/5 to-card">
<Table className="">
<TableHeader>
<TableRow>
<TableHead className="px-4 bg-primary text-white text-sm w-1/3">
{m.houses_page_ui_table_header_name()} &{' '}
{m.houses_page_ui_view_table_header_email()}
</TableHead>
<TableHead className="px-4 bg-primary text-white text-sm w-1/3">
{m.houses_page_ui_view_table_header_role()}
</TableHead>
<TableHead className="px-4 bg-primary" />
</TableRow>
</TableHeader>
<TableBody>
{activeHouse.members.map((member) => (
<TableRow key={member.user.id}>
<TableCell className="px-4">
<Item className="p-0">
<ItemContent>
<ItemTitle>{member.user.name}</ItemTitle>
<ItemDescription>{member.user.email}</ItemDescription>
</ItemContent>
</Item>
</TableCell>
<TableCell className="px-4">
<RoleBadge type={member.role} />
</TableCell>
<TableCell className="px-4">
<div className="flex justify-end gap-2">
{activeHouse.color}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
export default CurrentUserMemberList;

View File

@@ -1,7 +1,6 @@
import { m } from '@/paraglide/messages'; import { 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>

View File

@@ -0,0 +1,163 @@
import { authClient } from '@/lib/auth-client';
import { m } from '@/paraglide/messages';
import { deleteUserHouse } from '@/service/house.api';
import { housesQueries } from '@/service/queries';
import useHasPermission from '@hooks/use-has-permission';
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus';
import { ShieldWarningIcon, TrashIcon } from '@phosphor-icons/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@ui/dialog';
import { Label } from '@ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
import parse from 'html-react-parser';
import { useState } from 'react';
import { toast } from 'sonner';
import RoleBadge from '../avatar/role-badge';
import { Spinner } from '../ui/spinner';
type DeleteUserHouseProps = {
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
};
const DeleteUserHouseAction = ({ activeHouse }: DeleteUserHouseProps) => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
const { hasPermission, isLoading } = useHasPermission('house', 'delete');
const queryClient = useQueryClient();
const { mutate: deleteHouseMutation, isPending } = useMutation({
mutationFn: deleteUserHouse,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...housesQueries.all, 'currentUser'],
});
_setOpen(false);
toast.success(m.houses_page_message_delete_house_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
if (isLoading || !activeHouse) return null;
const onConfirm = async () => {
deleteHouseMutation({ data: { id: activeHouse.id } });
};
if (hasPermission) {
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
size="icon-lg"
className="rounded-full cursor-pointer bg-red-500 text-white hover:bg-red-100 hover:text-red-600"
>
<TrashIcon size={16} />
<span className="sr-only">{m.ui_delete_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-red-500 [&_svg]:bg-red-500 [&_svg]:fill-red-500 text-white">
<Label>{m.ui_delete_btn()}</Label>
</TooltipContent>
</Tooltip>
<DialogContent
className="max-w-100 xl:max-w-xl"
showCloseButton={false}
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-500">
<div className="rounded-full bg-red-100 p-3">
<ShieldWarningIcon size={30} />
</div>
{m.houses_page_ui_dialog_alert_delete_title({
name: activeHouse.name,
})}
</DialogTitle>
<DialogDescription className="text-red-500">
{parse(m.houses_page_ui_dialog_alert_delete_description())}
</DialogDescription>
</DialogHeader>
<div className="overflow-hidden rounded-md border">
<Table className="bg-white">
<TableHeader>
<TableRow>
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
{m.houses_page_ui_view_table_header_email()}
</TableHead>
<TableHead className="px-2 h-7 bg-primary text-white text-xs w-1/2">
{m.houses_page_ui_view_table_header_role()}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeHouse.members.map((member) => (
<TableRow key={member.user.email}>
<TableCell>{member.user.email}</TableCell>
<TableCell>
<RoleBadge type={member.role} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter className="bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4">
<DialogClose asChild>
<Button variant="outline" type="button">
{m.ui_cancel_btn()}
</Button>
</DialogClose>
<Button
variant="destructive"
type="button"
onClick={onConfirm}
disabled={isPending}
>
{isPending && <Spinner data-icon="inline-start" />}
{m.ui_confirm_btn()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return null;
};
export default DeleteUserHouseAction;

View File

@@ -3,7 +3,6 @@ import useHasPermission from '@hooks/use-has-permission';
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus'; import 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>
); );

View File

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

View File

@@ -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>
);
})}
</> </>
); );
}; };

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="rounded-full bg-border relative flex-1"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -135,7 +135,7 @@ export function SelectUser({
<div <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" />

View File

@@ -0,0 +1,10 @@
import { cn } from "@/lib/utils"
import { SpinnerIcon } from "@phosphor-icons/react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<SpinnerIcon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }

View File

@@ -1,4 +1,3 @@
import { ReturnError } from '@/types/common';
import usePreventAutoFocus from '@hooks/use-prevent-auto-focus'; import 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>

View File

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

View File

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

View File

@@ -14,11 +14,14 @@ import { Route as appIndexRouteImport } from './routes/(app)/index'
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in' import { Route as 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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 };
}
});

View File

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

View File

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

View File

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

View File

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

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

@@ -19,6 +19,7 @@ declare global {
role: true; 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;
};
} }