diff --git a/app/(dashboard)/ajustes/layout.tsx b/app/(dashboard)/ajustes/layout.tsx index d3ef178..0f2cfd1 100644 --- a/app/(dashboard)/ajustes/layout.tsx +++ b/app/(dashboard)/ajustes/layout.tsx @@ -1,4 +1,4 @@ -import { RiSettingsLine } from "@remixicon/react"; +import { RiSettings2Line } from "@remixicon/react"; import PageDescription from "@/components/page-description"; export const metadata = { @@ -13,7 +13,7 @@ export default function RootLayout({ return (
} + icon={} title="Ajustes" subtitle="Gerencie informações da conta, segurança e outras opções para otimizar sua experiência." /> diff --git a/app/(dashboard)/lancamentos/actions.ts b/app/(dashboard)/lancamentos/actions.ts index 7a35609..4ad9829 100644 --- a/app/(dashboard)/lancamentos/actions.ts +++ b/app/(dashboard)/lancamentos/actions.ts @@ -143,6 +143,8 @@ const baseFields = z.object({ pagadorId: uuidSchema("Pagador").nullable().optional(), secondaryPagadorId: uuidSchema("Pagador secundário").optional(), isSplit: z.boolean().optional().default(false), + primarySplitAmount: z.coerce.number().min(0).optional(), + secondarySplitAmount: z.coerce.number().min(0).optional(), contaId: uuidSchema("Conta").nullable().optional(), cartaoId: uuidSchema("Cartão").nullable().optional(), categoriaId: uuidSchema("Categoria").nullable().optional(), @@ -234,6 +236,23 @@ const refineLancamento = ( message: "Escolha um pagador diferente para dividir o lançamento.", }); } + + // Validate custom split amounts sum to total + if ( + data.primarySplitAmount !== undefined && + data.secondarySplitAmount !== undefined + ) { + const sum = data.primarySplitAmount + data.secondarySplitAmount; + const total = Math.abs(data.amount); + // Allow 1 cent tolerance for rounding differences + if (Math.abs(sum - total) > 0.01) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["primarySplitAmount"], + message: "A soma das divisões deve ser igual ao valor total.", + }); + } + } } }; @@ -354,17 +373,33 @@ const buildShares = ({ pagadorId, isSplit, secondaryPagadorId, + primarySplitAmountCents, + secondarySplitAmountCents, }: { totalCents: number; pagadorId: string | null; isSplit: boolean; secondaryPagadorId?: string; + primarySplitAmountCents?: number; + secondarySplitAmountCents?: number; }): Share[] => { if (isSplit) { if (!pagadorId || !secondaryPagadorId) { throw new Error("Configuração de divisão inválida para o lançamento."); } + // Use custom split amounts if provided + if ( + primarySplitAmountCents !== undefined && + secondarySplitAmountCents !== undefined + ) { + return [ + { pagadorId, amountCents: primarySplitAmountCents }, + { pagadorId: secondaryPagadorId, amountCents: secondarySplitAmountCents }, + ]; + } + + // Fallback to equal split const [primaryAmount, secondaryAmount] = splitAmount(totalCents, 2); return [ { pagadorId, amountCents: primaryAmount }, @@ -598,6 +633,12 @@ export async function createLancamentoAction( pagadorId: data.pagadorId ?? null, isSplit: data.isSplit ?? false, secondaryPagadorId: data.secondaryPagadorId, + primarySplitAmountCents: data.primarySplitAmount + ? Math.round(data.primarySplitAmount * 100) + : undefined, + secondarySplitAmountCents: data.secondarySplitAmount + ? Math.round(data.secondarySplitAmount * 100) + : undefined, }); const isSeriesLancamento = diff --git a/app/(dashboard)/pagadores/page.tsx b/app/(dashboard)/pagadores/page.tsx index 1890cdd..55ef0d5 100644 --- a/app/(dashboard)/pagadores/page.tsx +++ b/app/(dashboard)/pagadores/page.tsx @@ -1,7 +1,10 @@ import { readdir } from "node:fs/promises"; import path from "node:path"; +import { eq } from "drizzle-orm"; import { PagadoresPage } from "@/components/pagadores/pagadores-page"; +import { user } from "@/db/schema"; import { getUserId } from "@/lib/auth/server"; +import { db } from "@/lib/db"; import { fetchPagadoresWithAccess } from "@/lib/pagadores/access"; import type { PagadorStatus } from "@/lib/pagadores/constants"; import { @@ -44,11 +47,21 @@ const resolveStatus = (status: string | null): PagadorStatus => { export default async function Page() { const userId = await getUserId(); - const [pagadorRows, avatarOptions] = await Promise.all([ + const [pagadorRows, localAvatarOptions, userData] = await Promise.all([ fetchPagadoresWithAccess(userId), loadAvatarOptions(), + db.query.user.findFirst({ + columns: { image: true }, + where: eq(user.id, userId), + }), ]); + // Incluir a imagem do Google nas opções se disponível + const userImage = userData?.image; + const avatarOptions = userImage + ? [userImage, ...localAvatarOptions] + : localAvatarOptions; + const pagadoresData = pagadorRows .map((pagador) => ({ id: pagador.id, diff --git a/components/ajustes/companion-tab.tsx b/components/ajustes/companion-tab.tsx index 2d81cf9..308f300 100644 --- a/components/ajustes/companion-tab.tsx +++ b/components/ajustes/companion-tab.tsx @@ -1,6 +1,5 @@ "use client"; -import type { ReactNode } from "react"; import { RiAndroidLine, RiDownload2Line, @@ -9,6 +8,7 @@ import { RiQrCodeLine, RiShieldCheckLine, } from "@remixicon/react"; +import type { ReactNode } from "react"; import { Card } from "@/components/ui/card"; import { ApiTokensForm } from "./api-tokens-form"; diff --git a/components/cartoes/card-select-items.tsx b/components/cartoes/card-select-items.tsx index bab8cab..5be83b4 100644 --- a/components/cartoes/card-select-items.tsx +++ b/components/cartoes/card-select-items.tsx @@ -56,7 +56,7 @@ export function StatusSelectContent({ label }: { label: string }) { return ( { - setHiddenWidgets((prev) => - prev.includes(widgetId) - ? prev.filter((id) => id !== widgetId) - : [...prev, widgetId], - ); - }, []); + const handleToggleWidget = useCallback( + (widgetId: string) => { + const newHidden = hiddenWidgets.includes(widgetId) + ? hiddenWidgets.filter((id) => id !== widgetId) + : [...hiddenWidgets, widgetId]; + + setHiddenWidgets(newHidden); + + // Salvar automaticamente ao toggle + startTransition(async () => { + await updateWidgetPreferences({ + order: widgetOrder, + hidden: newHidden, + }); + }); + }, + [hiddenWidgets, widgetOrder], + ); const handleHideWidget = useCallback((widgetId: string) => { setHiddenWidgets((prev) => [...prev, widgetId]); diff --git a/components/dashboard/pagadores-widget.tsx b/components/dashboard/pagadores-widget.tsx new file mode 100644 index 0000000..609cd15 --- /dev/null +++ b/components/dashboard/pagadores-widget.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { + RiExternalLinkLine, + RiGroupLine, + RiVerifiedBadgeFill, +} from "@remixicon/react"; +import Link from "next/link"; +import MoneyValues from "@/components/money-values"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { CardContent } from "@/components/ui/card"; +import type { DashboardPagador } from "@/lib/dashboard/pagadores"; +import { getAvatarSrc } from "@/lib/pagadores/utils"; +import { WidgetEmptyState } from "../widget-empty-state"; + +type PagadoresWidgetProps = { + pagadores: DashboardPagador[]; +}; + +const buildInitials = (value: string) => { + const parts = value.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) { + return "??"; + } + if (parts.length === 1) { + const firstPart = parts[0]; + return firstPart ? firstPart.slice(0, 2).toUpperCase() : "??"; + } + const firstChar = parts[0]?.[0] ?? ""; + const secondChar = parts[1]?.[0] ?? ""; + return `${firstChar}${secondChar}`.toUpperCase() || "??"; +}; + +export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) { + return ( + + {pagadores.length === 0 ? ( + } + title="Nenhum pagador para o período" + description="Quando houver despesas associadas a pagadores, eles aparecerão aqui." + /> + ) : ( +
    + {pagadores.map((pagador) => { + const initials = buildInitials(pagador.name); + + return ( +
  • +
    + + + {initials} + + +
    + + {pagador.name} + {pagador.isAdmin && ( + + )} + + +

    + {pagador.email ?? "Sem email cadastrado"} +

    +
    +
    + +
    + +
    +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog-types.ts b/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog-types.ts index 8664e8e..a109b06 100644 --- a/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog-types.ts +++ b/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog-types.ts @@ -73,6 +73,7 @@ export interface SplitAndSettlementSectionProps extends BaseFieldSectionProps { export interface PagadorSectionProps extends BaseFieldSectionProps { pagadorOptions: SelectOption[]; secondaryPagadorOptions: SelectOption[]; + totalAmount: number; } export interface PaymentMethodSectionProps extends BaseFieldSectionProps { diff --git a/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog.tsx b/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog.tsx index 5f26997..6c6e0de 100644 --- a/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog.tsx +++ b/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog.tsx @@ -141,6 +141,11 @@ export function LancamentoDialog({ return groupAndSortCategorias(filtered); }, [categoriaOptions, formState.transactionType]); + const totalAmount = useMemo(() => { + const parsed = Number.parseFloat(formState.amount); + return Number.isNaN(parsed) ? 0 : Math.abs(parsed); + }, [formState.amount]); + const handleFieldChange = useCallback( (key: Key, value: FormState[Key]) => { if (key === "period") { @@ -223,6 +228,12 @@ export function LancamentoDialog({ ? formState.secondaryPagadorId : undefined, isSplit: formState.isSplit, + primarySplitAmount: formState.isSplit + ? Number.parseFloat(formState.primarySplitAmount) || undefined + : undefined, + secondarySplitAmount: formState.isSplit + ? Number.parseFloat(formState.secondarySplitAmount) || undefined + : undefined, contaId: formState.contaId, cartaoId: formState.cartaoId, categoriaId: formState.categoriaId, @@ -402,6 +413,7 @@ export function LancamentoDialog({ onFieldChange={handleFieldChange} pagadorOptions={pagadorOptions} secondaryPagadorOptions={secondaryPagadorOptions} + totalAmount={totalAmount} /> { + onFieldChange("primarySplitAmount", value); + const numericValue = Number.parseFloat(value) || 0; + const remaining = Math.max(0, totalAmount - numericValue); + onFieldChange("secondarySplitAmount", remaining.toFixed(2)); + }, + [totalAmount, onFieldChange], + ); + + const handleSecondaryAmountChange = useCallback( + (value: string) => { + onFieldChange("secondarySplitAmount", value); + const numericValue = Number.parseFloat(value) || 0; + const remaining = Math.max(0, totalAmount - numericValue); + onFieldChange("primarySplitAmount", remaining.toFixed(2)); + }, + [totalAmount, onFieldChange], + ); + return (
- -
- - {formState.isSplit ? ( -
- +
+ {formState.isSplit && ( + + )} +
+
+ + {formState.isSplit ? ( +
+ +
+ + +
) : null}
diff --git a/components/pagadores/details/pagador-info-card.tsx b/components/pagadores/details/pagador-info-card.tsx index e422b75..1d033e5 100644 --- a/components/pagadores/details/pagador-info-card.tsx +++ b/components/pagadores/details/pagador-info-card.tsx @@ -148,7 +148,7 @@ export function PagadorInfoCard({ alt={`Avatar de ${pagador.name}`} width={64} height={64} - className="h-full w-full object-cover" + className="h-full w-full object-cover rounded-full" /> @@ -214,7 +214,7 @@ export function PagadorInfoCard({ {pagador.email} ) : ( - "—" + "Sem e-mail cadastrado" ) } /> @@ -260,7 +260,7 @@ export function PagadorInfoCard({ pagador.note ? ( {pagador.note} ) : ( - "—" + "Sem observações" ) } className="sm:col-span-2" diff --git a/components/pagadores/pagador-card.tsx b/components/pagadores/pagador-card.tsx index caa0a97..b8bb061 100644 --- a/components/pagadores/pagador-card.tsx +++ b/components/pagadores/pagador-card.tsx @@ -63,7 +63,7 @@ export function PagadorCard({ pagador, onEdit, onRemove }: PagadorCardProps) {

{pagador.email}

) : (

- Sem Email cadastrado + Sem email cadastrado

)} diff --git a/components/pagadores/pagador-dialog.tsx b/components/pagadores/pagador-dialog.tsx index d97d44b..dcf23df 100644 --- a/components/pagadores/pagador-dialog.tsx +++ b/components/pagadores/pagador-dialog.tsx @@ -1,5 +1,4 @@ "use client"; -import { RiCheckLine, RiCloseCircleLine } from "@remixicon/react"; import Image from "next/image"; import { useCallback, @@ -33,7 +32,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Textarea } from "@/components/ui/textarea"; import { useControlledState } from "@/hooks/use-controlled-state"; import { useFormState } from "@/hooks/use-form-state"; import { @@ -270,16 +268,9 @@ export function PagadorDialog({ -
-
- {availableAvatars.length === 0 ? ( -
- - Nenhum avatar disponível. Adicione imagens em - public/avatares - . -
- ) : null} +
+ +
{availableAvatars.map((avatar) => { const isSelected = avatar === formState.avatarUrl; return ( @@ -287,22 +278,16 @@ export function PagadorDialog({ type="button" key={avatar} onClick={() => updateField("avatarUrl", avatar)} - className="group relative flex items-center justify-center overflow-hidden rounded-xl border border-border/70 p-2 transition-all hover:border-primary/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 data-[selected=true]:border-primary data-[selected=true]:bg-primary/10" + className="group relative flex items-center justify-center rounded-full p-0.5 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 data-[selected=true]:ring-2 data-[selected=true]:ring-primary" data-selected={isSelected} aria-pressed={isSelected} > - - {isSelected ? ( - - - - ) : null} {`Avatar ); @@ -312,12 +297,11 @@ export function PagadorDialog({
-