Selector de empresas
This commit is contained in:
parent
b1e9f4bd2d
commit
0b07382919
@ -9,32 +9,20 @@ import {
|
|||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
interface Company {
|
import { useAppCompanySwitcherController } from "../lib/hooks";
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
taxName: string;
|
|
||||||
initials: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const companies: Company[] = [
|
|
||||||
{ id: "company-1", name: "Mi Empresa", taxName: "Mi Empresa S.L.", initials: "ME" },
|
|
||||||
{ id: "company-2", name: "Comercial Norte", taxName: "Comercial Norte S.L.", initials: "CN" },
|
|
||||||
{
|
|
||||||
id: "company-3",
|
|
||||||
name: "Servicios Globales",
|
|
||||||
taxName: "Servicios Globales S.L.",
|
|
||||||
initials: "SG",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface AppCompanySwitcherProps {
|
interface AppCompanySwitcherProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => {
|
export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => {
|
||||||
const [selectedCompany, setSelectedCompany] = React.useState<Company>(companies[0]);
|
const { activeCompany, companies, handleSelectCompany, isChangingCompany, isDisabled } =
|
||||||
|
useAppCompanySwitcherController();
|
||||||
|
|
||||||
|
if (!activeCompany) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full", className)}>
|
<div className={cn("w-full", className)}>
|
||||||
@ -42,23 +30,29 @@ export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => {
|
|||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
render={
|
render={
|
||||||
<button
|
<button
|
||||||
|
aria-busy={isChangingCompany}
|
||||||
|
aria-label="Switch active company"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-14 w-full items-center gap-3 rounded-none px-3 text-left hover:bg-muted transition-colors",
|
"flex h-14 w-full items-center gap-3 rounded-none px-3 text-left hover:bg-muted transition-colors",
|
||||||
|
isChangingCompany && "cursor-wait opacity-80",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
)}
|
)}
|
||||||
|
disabled={isDisabled}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-600 text-sm font-semibold text-white">
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-600 text-sm font-semibold text-white">
|
||||||
{selectedCompany.initials}
|
{activeCompany.initials}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
|
<span className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
|
||||||
<span className="block truncate text-sm font-semibold leading-5 text-foreground">
|
<span className="block truncate text-sm font-semibold leading-5 text-foreground">
|
||||||
{selectedCompany.name}
|
{activeCompany.name}
|
||||||
</span>
|
|
||||||
<span className="block truncate text-xs text-muted-foreground">
|
|
||||||
{selectedCompany.taxName}
|
|
||||||
</span>
|
</span>
|
||||||
|
{activeCompany.secondaryLabel ? (
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{activeCompany.secondaryLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
@ -75,13 +69,12 @@ export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
{companies.map((company) => {
|
{companies.map((company) => {
|
||||||
const isSelected = company.id === selectedCompany.id;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="gap-3 py-2"
|
className="gap-3 py-2"
|
||||||
|
disabled={company.isSelected || isChangingCompany}
|
||||||
key={company.id}
|
key={company.id}
|
||||||
onSelect={() => setSelectedCompany(company)}
|
onSelect={() => handleSelectCompany(company.id)}
|
||||||
>
|
>
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-foreground">
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-foreground">
|
||||||
{company.initials}
|
{company.initials}
|
||||||
@ -89,12 +82,14 @@ export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => {
|
|||||||
|
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate font-medium">{company.name}</span>
|
<span className="block truncate font-medium">{company.name}</span>
|
||||||
<span className="block truncate text-xs text-muted-foreground">
|
{company.secondaryLabel ? (
|
||||||
{company.taxName}
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
</span>
|
{company.secondaryLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{isSelected ? (
|
{company.isSelected ? (
|
||||||
<CheckIcon aria-hidden="true" className="size-4 text-blue-600" />
|
<CheckIcon aria-hidden="true" className="size-4 text-blue-600" />
|
||||||
) : null}
|
) : null}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
|
export * from "./use-app-company-switcher-controller";
|
||||||
export * from "./use-theme";
|
export * from "./use-theme";
|
||||||
|
|||||||
110
apps/web/src/lib/hooks/use-app-company-switcher-controller.ts
Normal file
110
apps/web/src/lib/hooks/use-app-company-switcher-controller.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { useIdentityAuthSession } from "@erp/identity/client";
|
||||||
|
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
function resolveCompanyDisplayName(legalName: string, tradeName: string | null) {
|
||||||
|
return tradeName?.trim() || legalName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCompanySecondaryLabel(legalName: string, tradeName: string | null) {
|
||||||
|
if (tradeName?.trim()) {
|
||||||
|
return legalName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCompanyInitials(name: string) {
|
||||||
|
const words = name
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (words.length === 0) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
return words
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((word) => word.charAt(0).toUpperCase())
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveErrorMessage(error: unknown) {
|
||||||
|
if (error instanceof Error && error.message.trim().length > 0) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Please try again in a few seconds.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppCompanySwitcherController() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { activeCompany, availableCompanies, changeActiveCompany } = useIdentityAuthSession();
|
||||||
|
const [isChangingCompany, setIsChangingCompany] = useState(false);
|
||||||
|
|
||||||
|
const activeCompanyView = activeCompany
|
||||||
|
? {
|
||||||
|
id: activeCompany.id,
|
||||||
|
name: resolveCompanyDisplayName(activeCompany.legalName, activeCompany.tradeName),
|
||||||
|
secondaryLabel: resolveCompanySecondaryLabel(
|
||||||
|
activeCompany.legalName,
|
||||||
|
activeCompany.tradeName
|
||||||
|
),
|
||||||
|
initials: resolveCompanyInitials(
|
||||||
|
resolveCompanyDisplayName(activeCompany.legalName, activeCompany.tradeName)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const companyOptions = availableCompanies.map((company) => {
|
||||||
|
const name = resolveCompanyDisplayName(company.legalName, company.tradeName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: company.id,
|
||||||
|
name,
|
||||||
|
secondaryLabel: resolveCompanySecondaryLabel(company.legalName, company.tradeName),
|
||||||
|
initials: resolveCompanyInitials(name),
|
||||||
|
isSelected: company.id === activeCompany?.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSelectCompany = async (companyId: string) => {
|
||||||
|
if (isChangingCompany || companyId === activeCompany?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsChangingCompany(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextActiveCompany = await changeActiveCompany(companyId);
|
||||||
|
|
||||||
|
if (!nextActiveCompany) {
|
||||||
|
showErrorToast("Unable to switch company", "The selected company is not available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryClient.cancelQueries();
|
||||||
|
queryClient.removeQueries({ type: "inactive" });
|
||||||
|
await queryClient.resetQueries({ type: "active" });
|
||||||
|
|
||||||
|
showSuccessToast(
|
||||||
|
"Active company updated",
|
||||||
|
`${resolveCompanyDisplayName(nextActiveCompany.legalName, nextActiveCompany.tradeName)} is now active.`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast("Unable to switch company", resolveErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setIsChangingCompany(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCompany: activeCompanyView,
|
||||||
|
companies: companyOptions,
|
||||||
|
isChangingCompany,
|
||||||
|
isDisabled: isChangingCompany || companyOptions.length <= 1,
|
||||||
|
handleSelectCompany,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -24,14 +24,14 @@ export function useCompanySelectionPageController() {
|
|||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isLoading,
|
isLoading,
|
||||||
logout,
|
logout,
|
||||||
selectActiveCompany,
|
changeActiveCompany,
|
||||||
getAuthenticatedRedirectPath,
|
getAuthenticatedRedirectPath,
|
||||||
} = useIdentityAuthSession();
|
} = useIdentityAuthSession();
|
||||||
|
|
||||||
const returnTo = useMemo(() => resolveSafeReturnTo(searchParams.get("returnTo")), [searchParams]);
|
const returnTo = useMemo(() => resolveSafeReturnTo(searchParams.get("returnTo")), [searchParams]);
|
||||||
|
|
||||||
const handleSelectCompany = (companyId: string) => {
|
const handleSelectCompany = async (companyId: string) => {
|
||||||
const selectedCompany = selectActiveCompany(companyId);
|
const selectedCompany = await changeActiveCompany(companyId);
|
||||||
|
|
||||||
if (!selectedCompany) {
|
if (!selectedCompany) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export interface IdentityAuthSessionContextValue {
|
|||||||
refreshSession: () => Promise<AuthSession | null>;
|
refreshSession: () => Promise<AuthSession | null>;
|
||||||
refreshAccessToken: () => Promise<string | null>;
|
refreshAccessToken: () => Promise<string | null>;
|
||||||
selectActiveCompany: (companyId: string) => ActiveCompany | null;
|
selectActiveCompany: (companyId: string) => ActiveCompany | null;
|
||||||
|
changeActiveCompany: (companyId: string) => Promise<ActiveCompany | null>;
|
||||||
getAuthenticatedRedirectPath: (params?: {
|
getAuthenticatedRedirectPath: (params?: {
|
||||||
returnTo?: string | null;
|
returnTo?: string | null;
|
||||||
fallbackPath?: string;
|
fallbackPath?: string;
|
||||||
@ -175,6 +176,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
|
|||||||
});
|
});
|
||||||
|
|
||||||
authTokenStorage.setTokens(refreshedTokens);
|
authTokenStorage.setTokens(refreshedTokens);
|
||||||
|
await hydrateCompanySession(refreshedTokens.accessToken);
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
const refreshedSession: AuthSession = {
|
const refreshedSession: AuthSession = {
|
||||||
@ -203,7 +205,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
|
|||||||
clearSession();
|
clearSession();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [clearSession, dataSource, refreshSessionMutation, session]);
|
}, [clearSession, dataSource, hydrateCompanySession, refreshSessionMutation, session]);
|
||||||
|
|
||||||
const refreshAccessToken = useCallback(async () => {
|
const refreshAccessToken = useCallback(async () => {
|
||||||
const refreshedSession = await refreshSession();
|
const refreshedSession = await refreshSession();
|
||||||
@ -212,15 +214,14 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
|
|||||||
|
|
||||||
const selectActiveCompany = useCallback(
|
const selectActiveCompany = useCallback(
|
||||||
(companyId: string) => {
|
(companyId: string) => {
|
||||||
|
if (companySession.activeCompany?.id === companyId) {
|
||||||
|
return companySession.activeCompany;
|
||||||
|
}
|
||||||
|
|
||||||
const nextActiveCompany =
|
const nextActiveCompany =
|
||||||
companySession.availableCompanies.find((company) => company.id === companyId) ?? null;
|
companySession.availableCompanies.find((company) => company.id === companyId) ?? null;
|
||||||
|
|
||||||
if (!nextActiveCompany) {
|
if (!nextActiveCompany) {
|
||||||
activeCompanyStorage.clear();
|
|
||||||
setCompanySession((currentSession) => ({
|
|
||||||
...currentSession,
|
|
||||||
activeCompany: null,
|
|
||||||
}));
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,7 +233,14 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
|
|||||||
|
|
||||||
return nextActiveCompany;
|
return nextActiveCompany;
|
||||||
},
|
},
|
||||||
[companySession.availableCompanies]
|
[companySession.activeCompany, companySession.availableCompanies]
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeActiveCompany = useCallback(
|
||||||
|
async (companyId: string) => {
|
||||||
|
return selectActiveCompany(companyId);
|
||||||
|
},
|
||||||
|
[selectActiveCompany]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getAuthenticatedRedirectPath = useCallback(
|
const getAuthenticatedRedirectPath = useCallback(
|
||||||
@ -280,10 +288,12 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
|
|||||||
refreshSession,
|
refreshSession,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
selectActiveCompany,
|
selectActiveCompany,
|
||||||
|
changeActiveCompany,
|
||||||
getAuthenticatedRedirectPath,
|
getAuthenticatedRedirectPath,
|
||||||
clearSession,
|
clearSession,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
changeActiveCompany,
|
||||||
clearSession,
|
clearSession,
|
||||||
companySession,
|
companySession,
|
||||||
getAuthenticatedRedirectPath,
|
getAuthenticatedRedirectPath,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user