mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Merge branch 'main' into feat/fix-ui
This commit is contained in:
@@ -34,7 +34,7 @@ export function CalculatorDisplay({
|
||||
<div className="mt-auto flex items-end justify-end gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-right font-medium tracking-tight tabular-nums leading-none transition-all",
|
||||
"truncate text-right font-medium tracking-tight leading-none transition-all",
|
||||
isResultView ? "text-2xl" : "text-3xl",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/components/ui/popover";
|
||||
import { Spinner } from "@/shared/components/ui/spinner";
|
||||
import { buildLogoDevUrl, logoQueryKeys, toNameKey } from "@/shared/lib/logo";
|
||||
import {
|
||||
removeEstablishmentLogoAction,
|
||||
saveEstablishmentLogoAction,
|
||||
} from "@/shared/lib/logo/establishment-logo-actions";
|
||||
import {
|
||||
buildInitials,
|
||||
getCategoryBgColorFromName,
|
||||
getCategoryColorFromName,
|
||||
} from "@/shared/utils/category-colors";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
interface LogoResult {
|
||||
name: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
async function fetchLogoResults(query: string): Promise<LogoResult[]> {
|
||||
if (!query.trim()) return [];
|
||||
const res = await fetch(
|
||||
`/api/logo/search?q=${encodeURIComponent(query.trim())}`,
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
interface EstablishmentLogoPickerProps {
|
||||
name: string;
|
||||
resolvedDomain: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (domain: string | null) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EstablishmentLogoPicker({
|
||||
name,
|
||||
resolvedDomain,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
children,
|
||||
}: EstablishmentLogoPickerProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [searchInput, setSearchInput] = useState(name);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(name);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchInput(name);
|
||||
setDebouncedSearch(name);
|
||||
}
|
||||
}, [open, name]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(searchInput), 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
const { data: results = [], isLoading } = useQuery({
|
||||
queryKey: logoQueryKeys.search(debouncedSearch),
|
||||
queryFn: () => fetchLogoResults(debouncedSearch),
|
||||
enabled: open && debouncedSearch.trim().length > 0,
|
||||
staleTime: 1000 * 60 * 60,
|
||||
});
|
||||
|
||||
function handleSelect(domain: string) {
|
||||
startTransition(async () => {
|
||||
await saveEstablishmentLogoAction(name, domain);
|
||||
queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), {
|
||||
domain,
|
||||
});
|
||||
onSelect(domain);
|
||||
});
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
startTransition(async () => {
|
||||
await removeEstablishmentLogoAction(name);
|
||||
queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), {
|
||||
domain: null,
|
||||
});
|
||||
onSelect(null);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-3" align="start" side="bottom">
|
||||
<p className="mb-2 text-muted-foreground text-xs">
|
||||
Escolha um logo para <strong>{name}</strong>
|
||||
</p>
|
||||
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Buscar marca..."
|
||||
className="mb-3 h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={handleReset}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-md p-1.5 text-center transition-colors hover:bg-accent disabled:opacity-50",
|
||||
resolvedDomain === null &&
|
||||
"ring-2 ring-primary ring-offset-1",
|
||||
)}
|
||||
title="Usar iniciais coloridas"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center rounded-md font-medium text-xs"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
backgroundColor: getCategoryBgColorFromName(name),
|
||||
color: getCategoryColorFromName(name),
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{buildInitials(name)}
|
||||
</div>
|
||||
<span className="w-full truncate text-[10px] leading-tight text-muted-foreground">
|
||||
Iniciais
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.domain}
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => handleSelect(r.domain)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-md p-1.5 text-center transition-colors hover:bg-accent disabled:opacity-50",
|
||||
resolvedDomain === r.domain &&
|
||||
"ring-2 ring-primary ring-offset-1",
|
||||
)}
|
||||
title={r.name}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={buildLogoDevUrl(r.domain) ?? ""}
|
||||
alt={r.name}
|
||||
width={36}
|
||||
height={36}
|
||||
className="rounded-md object-contain"
|
||||
style={{ width: 36, height: 36 }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<span className="w-full truncate text-[10px] leading-tight">
|
||||
{r.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
buildLogoDevUrl,
|
||||
LOGO_DEV_TOKEN,
|
||||
logoQueryKeys,
|
||||
toNameKey,
|
||||
} from "@/shared/lib/logo";
|
||||
import {
|
||||
buildInitials,
|
||||
getCategoryBgColorFromName,
|
||||
getCategoryColorFromName,
|
||||
} from "@/shared/utils/category-colors";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import { EstablishmentLogoPicker } from "./establishment-logo-picker";
|
||||
|
||||
async function fetchLogoMapping(
|
||||
name: string,
|
||||
): Promise<{ domain: string | null }> {
|
||||
const res = await fetch(`/api/logo/mapping?name=${encodeURIComponent(name)}`);
|
||||
if (!res.ok) return { domain: null };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
interface EstablishmentLogoProps {
|
||||
name: string;
|
||||
/** Domínio Logo.dev pré-carregado pelo servidor (otimização opcional). */
|
||||
domain?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EstablishmentLogo({
|
||||
name,
|
||||
domain: initialDomain,
|
||||
size = 32,
|
||||
className,
|
||||
}: EstablishmentLogoProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const { data: mappingData } = useQuery({
|
||||
queryKey: logoQueryKeys.mapping(toNameKey(name)),
|
||||
queryFn: () => fetchLogoMapping(name),
|
||||
placeholderData:
|
||||
initialDomain !== undefined
|
||||
? { domain: initialDomain ?? null }
|
||||
: undefined,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
enabled: LOGO_DEV_TOKEN !== undefined && LOGO_DEV_TOKEN !== "",
|
||||
});
|
||||
|
||||
const resolvedDomain = mappingData?.domain ?? null;
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: resetar imgError é o efeito de domain mudar
|
||||
useEffect(() => {
|
||||
setImgError(false);
|
||||
}, [resolvedDomain]);
|
||||
|
||||
const logoUrl = buildLogoDevUrl(resolvedDomain);
|
||||
const showLogo = Boolean(logoUrl) && !imgError;
|
||||
|
||||
const initials = buildInitials(name);
|
||||
const color = getCategoryColorFromName(name);
|
||||
const bgColor = getCategoryBgColorFromName(name);
|
||||
|
||||
return (
|
||||
const initialsAvatar = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full font-medium",
|
||||
className,
|
||||
)}
|
||||
className="flex shrink-0 items-center justify-center rounded-full font-medium"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -38,4 +82,60 @@ export function EstablishmentLogo({
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
|
||||
const logoImage =
|
||||
showLogo && logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={name}
|
||||
width={size}
|
||||
height={size}
|
||||
onError={() => setImgError(true)}
|
||||
className="shrink-0 rounded-full object-cover"
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
) : (
|
||||
initialsAvatar
|
||||
);
|
||||
|
||||
if (!LOGO_DEV_TOKEN) {
|
||||
return (
|
||||
<div className={cn("shrink-0", className)} aria-hidden>
|
||||
{initialsAvatar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EstablishmentLogoPicker
|
||||
name={name}
|
||||
resolvedDomain={resolvedDomain}
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
onSelect={() => setPickerOpen(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("group relative shrink-0 cursor-pointer", className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={`Alterar logo de ${name}`}
|
||||
aria-label={`Alterar logo de ${name}`}
|
||||
>
|
||||
{logoImage}
|
||||
<span
|
||||
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden
|
||||
>
|
||||
<RiPencilLine
|
||||
style={{
|
||||
width: Math.max(10, Math.round(size * 0.38)),
|
||||
height: Math.max(10, Math.round(size * 0.38)),
|
||||
}}
|
||||
className="text-white"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</EstablishmentLogoPicker>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ export type {
|
||||
} from "./category-icon-badge";
|
||||
export { CategoryIconBadge } from "./category-icon-badge";
|
||||
export { EstablishmentLogo } from "./establishment-logo";
|
||||
export { EstablishmentLogoPicker } from "./establishment-logo-picker";
|
||||
|
||||
@@ -49,14 +49,14 @@ export function LogoPickerTrigger({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/40 bg-background shadow-xs">
|
||||
<span className="relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/40 bg-background shadow-xs">
|
||||
{selectedLogoPath ? (
|
||||
<Image
|
||||
src={selectedLogoPath}
|
||||
alt={selectedLogoLabel || "Logo selecionado"}
|
||||
width={28}
|
||||
height={28}
|
||||
className="h-full w-full object-contain"
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-contain p-0.5"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">Logo</span>
|
||||
@@ -161,14 +161,16 @@ export function LogoPickerDialog({
|
||||
"border-primary bg-primary/5 ring-2 ring-primary/40",
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center justify-center overflow-hidden rounded-full">
|
||||
<Image
|
||||
src={resolveLogoSrc(logo, { basePath }) ?? logo}
|
||||
alt={logoLabel || logo}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<span className="flex w-full items-center justify-center">
|
||||
<span className="relative size-10 overflow-hidden rounded-full">
|
||||
<Image
|
||||
src={resolveLogoSrc(logo, { basePath }) ?? logo}
|
||||
alt={logoLabel || logo}
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-contain"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span className="line-clamp-1 text-[10px] leading-tight text-muted-foreground">
|
||||
{logoLabel}
|
||||
|
||||
@@ -20,9 +20,9 @@ function MoneyValues({ amount, className, showPositiveSign = false }: Props) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-baseline transition-all duration-200 tracking-tighter",
|
||||
"inline-flex items-baseline tabular-nums transition-all duration-200 tracking-tighter",
|
||||
privacyMode &&
|
||||
"blur-[6px] select-none hover:blur-none focus-within:blur-none",
|
||||
"blur-sm select-none hover:blur-none focus-within:blur-none",
|
||||
className,
|
||||
)}
|
||||
aria-label={privacyMode ? "Valor oculto" : displayValue}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function MonthNavigation() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="sticky top-16 z-10 flex w-full flex-row p-4 backdrop-blur-xs supports-backdrop-filter:bg-card/80">
|
||||
<Card className="sticky top-16 z-10 flex w-full flex-row p-4 backdrop-blur-xs supports-backdrop-filter:bg-card/80 ">
|
||||
<div className="flex items-center gap-1">
|
||||
<NavigationButton
|
||||
direction="left"
|
||||
|
||||
@@ -96,7 +96,7 @@ export function FeedbackDialogBody({ onClose }: { onClose?: () => void }) {
|
||||
<Icon className={cn("h-5 w-5", item.color)} />
|
||||
</div>
|
||||
<div className="flex-1 text-left space-y-1">
|
||||
<h3 className="font-medium text-sm flex items-center gap-2">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
{item.title}
|
||||
<RiExternalLinkLine className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</h3>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function NavDropdown({ items }: NavDropdownProps) {
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="flex flex-col min-w-0">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="font-semibold">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="text-xs text-muted-foreground truncate lowercase">
|
||||
{item.description}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiFileCopyLine,
|
||||
RiHistoryLine,
|
||||
RiLogoutCircleLine,
|
||||
RiMegaphoneLine,
|
||||
@@ -23,6 +25,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/shared/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { authClient } from "@/shared/lib/auth/client";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import type { UpdateCheckResult } from "@/shared/lib/version/check-update";
|
||||
@@ -50,10 +57,18 @@ export function NavbarUser({
|
||||
const router = useRouter();
|
||||
const [logoutLoading, setLogoutLoading] = useState(false);
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function handleCopyId() {
|
||||
navigator.clipboard.writeText(user.id);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
const avatarSrc = pagadorAvatarUrl
|
||||
? getAvatarSrc(pagadorAvatarUrl)
|
||||
: user.image || getAvatarSrc(null);
|
||||
const isDataUrl = avatarSrc.startsWith("data:");
|
||||
|
||||
async function handleLogout() {
|
||||
await authClient.signOut({
|
||||
@@ -77,6 +92,7 @@ export function NavbarUser({
|
||||
<div className="relative size-10 overflow-hidden rounded-full">
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
unoptimized={isDataUrl}
|
||||
alt={`Avatar de ${user.name}`}
|
||||
fill
|
||||
sizes="40px"
|
||||
@@ -98,6 +114,7 @@ export function NavbarUser({
|
||||
<div className="relative size-9 shrink-0 overflow-hidden rounded-full">
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
unoptimized={isDataUrl}
|
||||
alt={user.name}
|
||||
fill
|
||||
sizes="36px"
|
||||
@@ -105,7 +122,30 @@ export function NavbarUser({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">{user.name}</span>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{user.name}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyId}
|
||||
className="shrink-0 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||
aria-label="Copiar ID do usuário"
|
||||
>
|
||||
{copied ? (
|
||||
<RiCheckLine className="size-3 text-success" />
|
||||
) : (
|
||||
<RiFileCopyLine className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{copied ? "Copiado!" : "Copiar ID do usuário"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{user.email}
|
||||
</span>
|
||||
@@ -126,7 +166,9 @@ export function NavbarUser({
|
||||
>
|
||||
<RiHistoryLine className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1">Changelog</span>
|
||||
<Badge variant="outline">v{version}</Badge>
|
||||
<Badge variant="outline" className="text-xs font-semibold">
|
||||
v{version}
|
||||
</Badge>
|
||||
</Link>
|
||||
|
||||
<DialogTrigger asChild>
|
||||
@@ -147,7 +189,7 @@ export function NavbarUser({
|
||||
className={cn(itemClass, "text-success")}
|
||||
>
|
||||
<RiMegaphoneLine className="size-4 text-success shrink-0" />
|
||||
<span className="flex-1 tracking-wide text-xs font-medium">
|
||||
<span className="flex-1 tracking-wide text-xs font-bold">
|
||||
Atualização {updateCheck.latestVersion} disponível
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function createSidebarNavData(
|
||||
.map((pagador) => ({
|
||||
title: pagador.name?.trim().length
|
||||
? pagador.name.trim()
|
||||
: "Payer sem nome",
|
||||
: "Pagador sem nome",
|
||||
url: `/payers/${pagador.id}`,
|
||||
key: pagador.canEdit ? pagador.id : `${pagador.id}-shared`,
|
||||
isShared: !pagador.canEdit,
|
||||
|
||||
@@ -22,6 +22,7 @@ export function NavUser({ user, pagadorAvatarUrl }: NavUserProps) {
|
||||
const avatarSrc = pagadorAvatarUrl
|
||||
? getAvatarSrc(pagadorAvatarUrl)
|
||||
: user.image || getAvatarSrc(null);
|
||||
const isDataUrl = avatarSrc.startsWith("data:");
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
@@ -33,6 +34,7 @@ export function NavUser({ user, pagadorAvatarUrl }: NavUserProps) {
|
||||
<div className="relative size-8 shrink-0 overflow-hidden rounded-full">
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
unoptimized={isDataUrl}
|
||||
alt={user.name}
|
||||
fill
|
||||
sizes="32px"
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function PageDescription({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-medium flex items-center gap-1 tracking-tight">
|
||||
<h1 className="text-2xl font-semibold flex items-center gap-1 ">
|
||||
<span className="text-primary">{icon}</span>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
@@ -89,7 +89,7 @@ export function PaymentSuccess({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<DialogTitle className="text-xl font-medium">{title}</DialogTitle>
|
||||
<DialogTitle className="text-xl font-semibold">{title}</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-relaxed">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
|
||||
@@ -98,7 +98,7 @@ function AlertDialogTitle({
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-medium", className)}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ function AvatarImage({
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
className={cn("aspect-square size-full object-cover", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -266,7 +266,7 @@ function ChartTooltipContent({
|
||||
</span>
|
||||
</div>
|
||||
{item.value !== undefined && item.value !== null && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { RiCheckLine } from "@remixicon/react";
|
||||
import { RiCheckLine, RiSubtractLine } from "@remixicon/react";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
@@ -13,7 +13,7 @@ function Checkbox({
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -22,7 +22,8 @@ function Checkbox({
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<RiCheckLine className="size-3.5" />
|
||||
<RiCheckLine className="size-3.5 [[data-state=indeterminate]_&]:hidden" />
|
||||
<RiSubtractLine className="size-3.5 hidden [[data-state=indeterminate]_&]:block" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -95,10 +95,7 @@ export const CurrencyInput = React.forwardRef<
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
className={cn(
|
||||
"text-left font-medium tabular-nums tracking-tight",
|
||||
className,
|
||||
)}
|
||||
className={cn("text-left tracking-tight", className)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ function DialogTitle({
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-medium", className)}
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -62,7 +62,7 @@ function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
className={cn("text-lg font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { RiArrowDropDownLine } from "@remixicon/react";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
|
||||
@@ -586,7 +586,7 @@ function SidebarMenuBadge({
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Slider as SliderPrimitive } from "radix-ui";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function WidgetCard({
|
||||
<CardHeader>
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-1 tracking-tight">
|
||||
<CardTitle className="flex items-center gap-1 ">
|
||||
{icon && <span className="size-4">{icon}</span>}
|
||||
{title}
|
||||
</CardTitle>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const CATEGORY_TYPES = ["receita", "despesa"] as const;
|
||||
|
||||
export const INVOICE_PAYMENT_CATEGORY_NAME = "Pagamentos";
|
||||
|
||||
export type CategoryType = (typeof CATEGORY_TYPES)[number];
|
||||
|
||||
export const CATEGORY_TYPE_LABEL: Record<CategoryType, string> = {
|
||||
|
||||
64
src/shared/lib/logo/establishment-logo-actions.ts
Normal file
64
src/shared/lib/logo/establishment-logo-actions.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { establishmentLogos } from "@/db/schema";
|
||||
import type { ActionResult } from "@/shared/lib/actions/helpers";
|
||||
import {
|
||||
handleActionError,
|
||||
revalidateForEntity,
|
||||
} from "@/shared/lib/actions/helpers";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { toNameKey } from "@/shared/lib/logo";
|
||||
|
||||
/**
|
||||
* Salva ou atualiza o domínio Logo.dev preferido para um estabelecimento.
|
||||
*/
|
||||
export async function saveEstablishmentLogoAction(
|
||||
name: string,
|
||||
domain: string,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const userId = await getUserId();
|
||||
const nameKey = toNameKey(name);
|
||||
|
||||
await db
|
||||
.insert(establishmentLogos)
|
||||
.values({ userId, nameKey, domain })
|
||||
.onConflictDoUpdate({
|
||||
target: [establishmentLogos.userId, establishmentLogos.nameKey],
|
||||
set: { domain, updatedAt: new Date() },
|
||||
});
|
||||
|
||||
revalidateForEntity("establishments", userId);
|
||||
return { success: true, message: "Logo salvo." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove o mapeamento salvo, voltando ao comportamento automático do Logo.dev.
|
||||
*/
|
||||
export async function removeEstablishmentLogoAction(
|
||||
name: string,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const userId = await getUserId();
|
||||
const nameKey = toNameKey(name);
|
||||
|
||||
await db
|
||||
.delete(establishmentLogos)
|
||||
.where(
|
||||
and(
|
||||
eq(establishmentLogos.userId, userId),
|
||||
eq(establishmentLogos.nameKey, nameKey),
|
||||
),
|
||||
);
|
||||
|
||||
revalidateForEntity("establishments", userId);
|
||||
return { success: true, message: "Logo restaurado." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
46
src/shared/lib/logo/establishment-logo-queries.ts
Normal file
46
src/shared/lib/logo/establishment-logo-queries.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { establishmentLogos } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { toNameKey } from "@/shared/lib/logo";
|
||||
|
||||
export { toNameKey };
|
||||
|
||||
/**
|
||||
* Busca o domínio salvo para um único estabelecimento.
|
||||
*/
|
||||
export async function fetchEstablishmentLogoDomain(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
const nameKey = toNameKey(name);
|
||||
const row = await db.query.establishmentLogos.findFirst({
|
||||
where: and(
|
||||
eq(establishmentLogos.userId, userId),
|
||||
eq(establishmentLogos.nameKey, nameKey),
|
||||
),
|
||||
columns: { domain: true },
|
||||
});
|
||||
return row?.domain ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca domínios salvos para múltiplos nomes de uma vez (evita N+1).
|
||||
* Retorna um Map de nameKey → domain.
|
||||
*/
|
||||
export async function fetchEstablishmentLogoMap(
|
||||
userId: string,
|
||||
names: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
const nameKeys = [...new Set(names.map(toNameKey))];
|
||||
if (nameKeys.length === 0) return new Map();
|
||||
|
||||
const rows = await db.query.establishmentLogos.findMany({
|
||||
where: and(
|
||||
eq(establishmentLogos.userId, userId),
|
||||
inArray(establishmentLogos.nameKey, nameKeys),
|
||||
),
|
||||
columns: { nameKey: true, domain: true },
|
||||
});
|
||||
|
||||
return new Map(rows.map((r) => [r.nameKey, r.domain]));
|
||||
}
|
||||
@@ -39,6 +39,27 @@ export const deriveNameFromLogo = (logo?: string | null) => {
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Normaliza o nome do estabelecimento para usar como chave de lookup no banco.
|
||||
*/
|
||||
export const toNameKey = (name: string): string => name.trim().toLowerCase();
|
||||
|
||||
// === Logo.dev ===
|
||||
|
||||
export const LOGO_DEV_TOKEN = process.env.NEXT_PUBLIC_LOGO_DEV_TOKEN;
|
||||
|
||||
export function buildLogoDevUrl(domain?: string | null): string | null {
|
||||
if (!LOGO_DEV_TOKEN || !domain) return null;
|
||||
return `https://img.logo.dev/${domain}?token=${LOGO_DEV_TOKEN}&size=64&format=png`;
|
||||
}
|
||||
|
||||
export const logoQueryKeys = {
|
||||
mapping: (nameKey: string) => ["logo-mapping", nameKey] as const,
|
||||
search: (query: string) => ["logo-search", query] as const,
|
||||
};
|
||||
|
||||
// === Local logo resolution ===
|
||||
|
||||
const LOGO_SRC_PATTERN = /^(https?:\/\/|data:)/;
|
||||
|
||||
type ResolveLogoSrcOptions = {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
|
||||
export const s3 = new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT ?? "",
|
||||
region: process.env.S3_REGION ?? "auto",
|
||||
endpoint: process.env.S3_ENDPOINT || "",
|
||||
region: process.env.S3_REGION || "auto",
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID || "",
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "",
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
export const S3_BUCKET = process.env.S3_BUCKET ?? "attachments";
|
||||
export const S3_BUCKET = process.env.S3_BUCKET || "attachments";
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
* Utility functions for string normalization and manipulation
|
||||
*/
|
||||
|
||||
export function slugify(value: string): string {
|
||||
const base = value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return base || "item";
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalizes the first letter of a string
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user