Selector de empresas

This commit is contained in:
David Arranz 2026-07-06 17:33:45 +02:00
parent b1e9f4bd2d
commit 0b07382919
5 changed files with 157 additions and 41 deletions

View File

@ -9,32 +9,20 @@ import {
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import * as React from "react";
interface Company {
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",
},
];
import { useAppCompanySwitcherController } from "../lib/hooks";
interface AppCompanySwitcherProps {
className?: string;
}
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 (
<div className={cn("w-full", className)}>
@ -42,23 +30,29 @@ export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => {
<DropdownMenuTrigger
render={
<button
aria-busy={isChangingCompany}
aria-label="Switch active company"
className={cn(
"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"
)}
disabled={isDisabled}
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">
{selectedCompany.initials}
{activeCompany.initials}
</span>
<span className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<span className="block truncate text-sm font-semibold leading-5 text-foreground">
{selectedCompany.name}
{activeCompany.name}
</span>
{activeCompany.secondaryLabel ? (
<span className="block truncate text-xs text-muted-foreground">
{selectedCompany.taxName}
{activeCompany.secondaryLabel}
</span>
) : null}
</span>
<ChevronDownIcon
@ -75,13 +69,12 @@ export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => {
<DropdownMenuSeparator />
{companies.map((company) => {
const isSelected = company.id === selectedCompany.id;
return (
<DropdownMenuItem
className="gap-3 py-2"
disabled={company.isSelected || isChangingCompany}
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">
{company.initials}
@ -89,12 +82,14 @@ export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => {
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{company.name}</span>
{company.secondaryLabel ? (
<span className="block truncate text-xs text-muted-foreground">
{company.taxName}
{company.secondaryLabel}
</span>
) : null}
</span>
{isSelected ? (
{company.isSelected ? (
<CheckIcon aria-hidden="true" className="size-4 text-blue-600" />
) : null}
</DropdownMenuItem>

View File

@ -1 +1,2 @@
export * from "./use-app-company-switcher-controller";
export * from "./use-theme";

View 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,
};
}

View File

@ -24,14 +24,14 @@ export function useCompanySelectionPageController() {
isAuthenticated,
isLoading,
logout,
selectActiveCompany,
changeActiveCompany,
getAuthenticatedRedirectPath,
} = useIdentityAuthSession();
const returnTo = useMemo(() => resolveSafeReturnTo(searchParams.get("returnTo")), [searchParams]);
const handleSelectCompany = (companyId: string) => {
const selectedCompany = selectActiveCompany(companyId);
const handleSelectCompany = async (companyId: string) => {
const selectedCompany = await changeActiveCompany(companyId);
if (!selectedCompany) {
return;

View File

@ -37,6 +37,7 @@ export interface IdentityAuthSessionContextValue {
refreshSession: () => Promise<AuthSession | null>;
refreshAccessToken: () => Promise<string | null>;
selectActiveCompany: (companyId: string) => ActiveCompany | null;
changeActiveCompany: (companyId: string) => Promise<ActiveCompany | null>;
getAuthenticatedRedirectPath: (params?: {
returnTo?: string | null;
fallbackPath?: string;
@ -175,6 +176,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
});
authTokenStorage.setTokens(refreshedTokens);
await hydrateCompanySession(refreshedTokens.accessToken);
if (session) {
const refreshedSession: AuthSession = {
@ -203,7 +205,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
clearSession();
return null;
}
}, [clearSession, dataSource, refreshSessionMutation, session]);
}, [clearSession, dataSource, hydrateCompanySession, refreshSessionMutation, session]);
const refreshAccessToken = useCallback(async () => {
const refreshedSession = await refreshSession();
@ -212,15 +214,14 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
const selectActiveCompany = useCallback(
(companyId: string) => {
if (companySession.activeCompany?.id === companyId) {
return companySession.activeCompany;
}
const nextActiveCompany =
companySession.availableCompanies.find((company) => company.id === companyId) ?? null;
if (!nextActiveCompany) {
activeCompanyStorage.clear();
setCompanySession((currentSession) => ({
...currentSession,
activeCompany: null,
}));
return null;
}
@ -232,7 +233,14 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
return nextActiveCompany;
},
[companySession.availableCompanies]
[companySession.activeCompany, companySession.availableCompanies]
);
const changeActiveCompany = useCallback(
async (companyId: string) => {
return selectActiveCompany(companyId);
},
[selectActiveCompany]
);
const getAuthenticatedRedirectPath = useCallback(
@ -280,10 +288,12 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
refreshSession,
refreshAccessToken,
selectActiveCompany,
changeActiveCompany,
getAuthenticatedRedirectPath,
clearSession,
}),
[
changeActiveCompany,
clearSession,
companySession,
getAuthenticatedRedirectPath,