Adiciona o frontend Next.js em frontend/ (setup, design system, autenticação/tema, layout/navegação, área master, área tenant completa: clientes, produtos, vendas, recebimentos, whatsapp, e-mail, alertas, equipe e perfil). Estende o backend de forma aditiva e autorizada: cookie httpOnly para refresh token (Etapa 13) e CRUD de equipe + edição de perfil do tenant (Etapa 18, módulo src/modules/equipe/ novo e PUT /tenant/perfil). Detalhes completos em Etapas/V2/*. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
110 lines
2.9 KiB
TypeScript
110 lines
2.9 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react';
|
|
|
|
import { cn } from '@/lib/utils';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from '@/components/ui/command';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
|
|
export interface ComboboxOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
interface ComboboxProps {
|
|
options: ComboboxOption[];
|
|
value?: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
searchPlaceholder?: string;
|
|
emptyText?: string;
|
|
disabled?: boolean;
|
|
loading?: boolean;
|
|
className?: string;
|
|
id?: string;
|
|
'aria-invalid'?: boolean;
|
|
'aria-describedby'?: string;
|
|
}
|
|
|
|
/**
|
|
* `Select` com busca, usado nas listas longas do sistema (cliente,
|
|
* produto/serviço) — seção 7 do schema de frontend. Composição de
|
|
* Command + Popover, não do `Select` nativo do Radix.
|
|
*/
|
|
function Combobox({
|
|
options,
|
|
value,
|
|
onChange,
|
|
placeholder = 'Selecionar…',
|
|
searchPlaceholder = 'Buscar…',
|
|
emptyText = 'Nenhum resultado encontrado.',
|
|
disabled,
|
|
loading,
|
|
className,
|
|
id,
|
|
...aria
|
|
}: ComboboxProps) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const selected = options.find((option) => option.value === value);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
id={id}
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
disabled={disabled || loading}
|
|
loading={loading}
|
|
className={cn(
|
|
'w-full justify-between font-normal',
|
|
!selected && 'text-muted-foreground',
|
|
className,
|
|
)}
|
|
{...aria}
|
|
>
|
|
{selected ? selected.label : placeholder}
|
|
<ChevronsUpDownIcon className="opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder={searchPlaceholder} />
|
|
<CommandList>
|
|
<CommandEmpty>{emptyText}</CommandEmpty>
|
|
<CommandGroup>
|
|
{options.map((option) => (
|
|
<CommandItem
|
|
key={option.value}
|
|
value={option.label}
|
|
onSelect={() => {
|
|
onChange(option.value);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<CheckIcon
|
|
className={cn('mr-2', option.value === value ? 'opacity-100' : 'opacity-0')}
|
|
/>
|
|
{option.label}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
export { Combobox };
|