style(ui): polimento visual — tema, cards, dark mode e landing page
Raio de borda global 0.625rem → 0.7rem; ajustes finos em --card e --border. DotPattern removido do layout, tela de auth e landing page. Account-card redesenhado (cores de saldo, tooltip de flags de exclusão). Budget-card, card-item, calendário (day-cell, event-modal) com layout revisado. Auth-card-shell simplificado (sem glassmorphism/blob). Landing page com mainFeatures + extraFeatures em grid único e dark mode nos botões de CTA. Imagens de preview da landing atualizadas. CSS --data-7..10 removidas. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 176 KiB |
@@ -20,22 +20,13 @@ const getSingleParam = (
|
|||||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const capitalize = (value: string) =>
|
|
||||||
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
|
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
await connection();
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
|
||||||
const {
|
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||||
period: selectedPeriod,
|
|
||||||
monthName: rawMonthName,
|
|
||||||
year,
|
|
||||||
} = parsePeriodParam(periodoParam);
|
|
||||||
|
|
||||||
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
|
|
||||||
|
|
||||||
const { budgets, categoriesOptions } = await fetchBudgetsForUser(
|
const { budgets, categoriesOptions } = await fetchBudgetsForUser(
|
||||||
userId,
|
userId,
|
||||||
@@ -49,7 +40,6 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
budgets={budgets}
|
budgets={budgets}
|
||||||
categories={categoriesOptions}
|
categories={categoriesOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
periodLabel={periodLabel}
|
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
|
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
|
||||||
import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget";
|
import { CategoryHistoryWidget } from "@/features/dashboard/components/widgets/category-history-widget";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
import { getCurrentPeriod } from "@/shared/utils/period";
|
import { getCurrentPeriod } from "@/shared/utils/period";
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
// Extract query params
|
// Extract query params
|
||||||
const inicioParam = getSingleParam(resolvedSearchParams, "inicio");
|
const inicioParam = getSingleParam(resolvedSearchParams, "inicio");
|
||||||
const fimParam = getSingleParam(resolvedSearchParams, "fim");
|
const fimParam = getSingleParam(resolvedSearchParams, "fim");
|
||||||
const categoriasParam = getSingleParam(resolvedSearchParams, "categories");
|
const categoriasParam =
|
||||||
|
getSingleParam(resolvedSearchParams, "categorias") ??
|
||||||
|
getSingleParam(resolvedSearchParams, "categories");
|
||||||
|
|
||||||
// Calculate default period (last 6 months)
|
// Calculate default period (last 6 months)
|
||||||
const currentPeriod = getCurrentPeriod();
|
const currentPeriod = getCurrentPeriod();
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell"
|
|||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
|
||||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
@@ -57,7 +56,7 @@ export default async function Page() {
|
|||||||
<a
|
<a
|
||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors"
|
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors dark:text-white/75 dark:hover:text-white dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
@@ -70,9 +69,9 @@ export default async function Page() {
|
|||||||
(session?.user ? (
|
(session?.user ? (
|
||||||
<Link prefetch href="/dashboard" className="hidden md:block">
|
<Link prefetch href="/dashboard" className="hidden md:block">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="navbar"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none"
|
className="border border-black/20 dark:border-white/20"
|
||||||
>
|
>
|
||||||
Dashboard
|
Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
@@ -83,7 +82,7 @@ export default async function Page() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none"
|
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||||
>
|
>
|
||||||
Entrar
|
Entrar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -91,7 +90,7 @@ export default async function Page() {
|
|||||||
<Link href="/signup">
|
<Link href="/signup">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2"
|
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2 dark:bg-white/10 dark:border-white/20 dark:text-white dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
Começar
|
Começar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -107,18 +106,6 @@ export default async function Page() {
|
|||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
|
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-80 overflow-hidden">
|
|
||||||
<DotPattern
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
cx={1.25}
|
|
||||||
cy={1.25}
|
|
||||||
cr={1.25}
|
|
||||||
className="text-primary/10 mask-[linear-gradient(to_bottom,black_0%,transparent_100%)]"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-linear-to-b from-primary/6 to-transparent" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-8xl mx-auto px-4 relative">
|
<div className="max-w-8xl mx-auto px-4 relative">
|
||||||
<div className="mx-auto flex max-w-4xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14">
|
<div className="mx-auto flex max-w-4xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
@@ -265,72 +252,34 @@ export default async function Page() {
|
|||||||
</AnimateOnScroll>
|
</AnimateOnScroll>
|
||||||
|
|
||||||
<AnimateOnScroll>
|
<AnimateOnScroll>
|
||||||
<div className="grid gap-4 md:gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{mainFeatures.map((feature) => (
|
{[...mainFeatures, ...extraFeatures].map((feature) => (
|
||||||
<Card key={feature.title}>
|
<Card key={feature.title}>
|
||||||
<CardContent className="pt-5 pb-5 md:pt-6">
|
<CardContent>
|
||||||
<div className="flex flex-col gap-3 md:gap-4">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div
|
<div
|
||||||
className="flex h-11 w-11 md:h-12 md:w-12 items-center justify-center rounded-lg"
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 14%, transparent)`,
|
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 20%, transparent)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<feature.icon
|
<feature.icon
|
||||||
className="size-[22px] md:size-6"
|
className="size-5"
|
||||||
style={{ color: feature.colorVar }}
|
style={{ color: "var(--foreground)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<h3 className="font-semibold text-base leading-tight">
|
||||||
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
|
|
||||||
{feature.title}
|
{feature.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
{feature.description}
|
{feature.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AnimateOnScroll>
|
</AnimateOnScroll>
|
||||||
|
|
||||||
<AnimateOnScroll>
|
|
||||||
<div className="mt-8 md:mt-12">
|
|
||||||
<h3 className="text-sm font-semibold text-center mb-4 md:mb-6 text-muted-foreground">
|
|
||||||
Também inclui
|
|
||||||
</h3>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{extraFeatures.map((feature) => (
|
|
||||||
<div
|
|
||||||
key={feature.title}
|
|
||||||
className="flex items-start gap-3 rounded-lg border bg-card p-3 md:p-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 14%, transparent)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<feature.icon
|
|
||||||
className="size-[18px]"
|
|
||||||
style={{ color: feature.colorVar }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h4 className="font-semibold text-sm mb-0.5">
|
|
||||||
{feature.title}
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
||||||
{feature.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AnimateOnScroll>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -396,14 +345,14 @@ export default async function Page() {
|
|||||||
{pwaHighlights.map((item) => (
|
{pwaHighlights.map((item) => (
|
||||||
<li key={item.title} className="flex items-start gap-3">
|
<li key={item.title} className="flex items-start gap-3">
|
||||||
<div
|
<div
|
||||||
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
|
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`,
|
backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon
|
||||||
className="size-[15px]"
|
className="size-[15px]"
|
||||||
style={{ color: item.colorVar }}
|
style={{ color: "var(--foreground)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
@@ -438,17 +387,19 @@ export default async function Page() {
|
|||||||
pré-lançamentos automaticamente para você revisar na inbox.
|
pré-lançamentos automaticamente para você revisar na inbox.
|
||||||
</p>
|
</p>
|
||||||
<ol className="space-y-3 mb-6">
|
<ol className="space-y-3 mb-6">
|
||||||
{companionSteps.map((step, index) => (
|
{companionSteps.map((step) => (
|
||||||
<li key={step.title} className="flex items-start gap-3">
|
<li key={step.title} className="flex items-start gap-3">
|
||||||
<span
|
<div
|
||||||
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium"
|
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
|
backgroundColor: `color-mix(in oklch, ${step.colorVar} 20%, transparent)`,
|
||||||
color: step.colorVar,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{index + 1}
|
<step.icon
|
||||||
</span>
|
className="size-3.5"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<span className="font-medium">{step.title}</span>
|
<span className="font-medium">{step.title}</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
@@ -545,14 +496,14 @@ export default async function Page() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div
|
<div
|
||||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg"
|
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`,
|
backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon
|
||||||
className="size-6"
|
className="size-6"
|
||||||
style={{ color: item.colorVar }}
|
style={{ color: "var(--foreground)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -633,14 +584,14 @@ export default async function Page() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex gap-3 md:gap-4">
|
<div className="flex gap-3 md:gap-4">
|
||||||
<div
|
<div
|
||||||
className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-lg"
|
className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`,
|
backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon
|
||||||
className="size-[18px] md:size-5"
|
className="size-[18px] md:size-5"
|
||||||
style={{ color: item.colorVar }}
|
style={{ color: "var(--foreground)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--background: oklch(97.412% 0.00332 67.032);
|
--background: oklch(97.412% 0.00332 67.032);
|
||||||
--foreground: oklch(27% 0.008 45);
|
--foreground: oklch(27% 0.008 45);
|
||||||
--card: oklch(99% 0.002 67);
|
--card: oklch(100% 0 0);
|
||||||
--card-foreground: var(--foreground);
|
--card-foreground: var(--foreground);
|
||||||
--popover: oklch(100% 0 0);
|
--popover: oklch(100% 0 0);
|
||||||
--popover-foreground: var(--foreground);
|
--popover-foreground: var(--foreground);
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
--destructive: oklch(55% 0.22 27);
|
--destructive: oklch(55% 0.22 27);
|
||||||
--destructive-foreground: oklch(98% 0.005 30);
|
--destructive-foreground: oklch(98% 0.005 30);
|
||||||
|
|
||||||
--border: oklch(90.274% 0.01362 60.342);
|
--border: oklch(92.323% 0.01276 63.703);
|
||||||
--input: var(--border);
|
--input: var(--border);
|
||||||
--ring: var(--primary);
|
--ring: var(--primary);
|
||||||
|
|
||||||
@@ -57,10 +57,6 @@
|
|||||||
--data-4: oklch(74% 0.18 55); /* âmbar */
|
--data-4: oklch(74% 0.18 55); /* âmbar */
|
||||||
--data-5: oklch(78% 0.16 68); /* âmbar-dourado */
|
--data-5: oklch(78% 0.16 68); /* âmbar-dourado */
|
||||||
--data-6: oklch(76% 0.15 82); /* amarelo-quente */
|
--data-6: oklch(76% 0.15 82); /* amarelo-quente */
|
||||||
--data-7: oklch(70% 0.17 95); /* amarelo-lima */
|
|
||||||
--data-8: oklch(65% 0.18 108); /* lima-verde */
|
|
||||||
--data-9: oklch(62% 0.17 120); /* verde-oliva claro */
|
|
||||||
--data-10: oklch(56% 0.15 10); /* terracota escuro */
|
|
||||||
|
|
||||||
--sidebar: oklch(99.3% 0.0015 75);
|
--sidebar: oklch(99.3% 0.0015 75);
|
||||||
--sidebar-foreground: var(--foreground);
|
--sidebar-foreground: var(--foreground);
|
||||||
@@ -71,7 +67,7 @@
|
|||||||
--sidebar-border: oklch(91% 0.004 70);
|
--sidebar-border: oklch(91% 0.004 70);
|
||||||
--sidebar-ring: var(--primary);
|
--sidebar-ring: var(--primary);
|
||||||
|
|
||||||
--radius: 0.625rem;
|
--radius: 0.7rem;
|
||||||
|
|
||||||
--shadow-2xs: 0 1px 2px 0px oklch(35% 0.02 45 / 0.04);
|
--shadow-2xs: 0 1px 2px 0px oklch(35% 0.02 45 / 0.04);
|
||||||
--shadow-xs: 0 1px 3px 0px oklch(35% 0.02 45 / 0.06);
|
--shadow-xs: 0 1px 3px 0px oklch(35% 0.02 45 / 0.06);
|
||||||
@@ -94,7 +90,7 @@
|
|||||||
.dark {
|
.dark {
|
||||||
--background: oklch(18% 0.004 55);
|
--background: oklch(18% 0.004 55);
|
||||||
--foreground: oklch(93% 0.008 80);
|
--foreground: oklch(93% 0.008 80);
|
||||||
--card: oklch(21.5% 0.004 55);
|
--card: oklch(21.531% 0.00369 48.293);
|
||||||
--card-foreground: var(--foreground);
|
--card-foreground: var(--foreground);
|
||||||
--popover: oklch(24% 0.004 55);
|
--popover: oklch(24% 0.004 55);
|
||||||
--popover-foreground: var(--foreground);
|
--popover-foreground: var(--foreground);
|
||||||
@@ -120,7 +116,7 @@
|
|||||||
--destructive: oklch(62% 0.2 28);
|
--destructive: oklch(62% 0.2 28);
|
||||||
--destructive-foreground: oklch(98% 0.005 30);
|
--destructive-foreground: oklch(98% 0.005 30);
|
||||||
|
|
||||||
--border: oklch(31% 0.004 55);
|
--border: oklch(28% 0.0035 55);
|
||||||
--input: var(--border);
|
--input: var(--border);
|
||||||
--ring: var(--primary);
|
--ring: var(--primary);
|
||||||
|
|
||||||
@@ -141,10 +137,6 @@
|
|||||||
--data-4: oklch(81% 0.18 55);
|
--data-4: oklch(81% 0.18 55);
|
||||||
--data-5: oklch(84% 0.16 68);
|
--data-5: oklch(84% 0.16 68);
|
||||||
--data-6: oklch(82% 0.15 82);
|
--data-6: oklch(82% 0.15 82);
|
||||||
--data-7: oklch(77% 0.17 95);
|
|
||||||
--data-8: oklch(72% 0.18 108);
|
|
||||||
--data-9: oklch(69% 0.17 120);
|
|
||||||
--data-10: oklch(63% 0.15 10);
|
|
||||||
|
|
||||||
--sidebar: oklch(15.5% 0.004 55);
|
--sidebar: oklch(15.5% 0.004 55);
|
||||||
--sidebar-foreground: var(--foreground);
|
--sidebar-foreground: var(--foreground);
|
||||||
@@ -155,7 +147,7 @@
|
|||||||
--sidebar-border: oklch(30% 0.004 55);
|
--sidebar-border: oklch(30% 0.004 55);
|
||||||
--sidebar-ring: var(--primary);
|
--sidebar-ring: var(--primary);
|
||||||
|
|
||||||
--radius: 0.625rem;
|
--radius: 0.7rem;
|
||||||
|
|
||||||
--shadow-2xs: 0 1px 2px 0px oklch(0% 0 0 / 0.3);
|
--shadow-2xs: 0 1px 2px 0px oklch(0% 0 0 / 0.3);
|
||||||
--shadow-xs: 0 1px 3px 0px oklch(0% 0 0 / 0.4);
|
--shadow-xs: 0 1px 3px 0px oklch(0% 0 0 / 0.4);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RiArrowLeftRightLine,
|
RiArrowLeftRightLine,
|
||||||
RiDeleteBin5Line,
|
RiDeleteBin5Line,
|
||||||
@@ -47,6 +48,13 @@ export function AccountCard({
|
|||||||
}: AccountCardProps) {
|
}: AccountCardProps) {
|
||||||
const isInactive = status?.toLowerCase() === "inativa";
|
const isInactive = status?.toLowerCase() === "inativa";
|
||||||
|
|
||||||
|
const balanceColor =
|
||||||
|
balance > 0
|
||||||
|
? "text-success"
|
||||||
|
: balance < 0
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-foreground";
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
label: "editar",
|
label: "editar",
|
||||||
@@ -75,36 +83,39 @@ export function AccountCard({
|
|||||||
].filter((action) => typeof action.onClick === "function");
|
].filter((action) => typeof action.onClick === "function");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn("h-full w-full gap-0", className)}>
|
<Card className={cn("flex w-full flex-col p-6", className)}>
|
||||||
<CardContent className="flex flex-1 flex-col gap-4">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{icon ? (
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center",
|
"flex shrink-0 items-center justify-center",
|
||||||
isInactive && "[&_img]:grayscale [&_img]:opacity-40",
|
isInactive && "grayscale opacity-40",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div className="min-w-0">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<div className="flex items-center gap-1">
|
||||||
|
<h3 className="truncate font-semibold text-foreground">
|
||||||
{accountName}
|
{accountName}
|
||||||
</h2>
|
</h3>
|
||||||
|
{excludeFromBalance || excludeInitialBalanceFromIncome ? (
|
||||||
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center">
|
<button
|
||||||
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" />
|
type="button"
|
||||||
</div>
|
className="shrink-0 text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||||
|
aria-label="Informações da conta"
|
||||||
|
>
|
||||||
|
<RiInformationLine className="size-3.5" />
|
||||||
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="max-w-xs">
|
<TooltipContent side="top" align="start" className="max-w-xs">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{excludeFromBalance && (
|
{excludeFromBalance && (
|
||||||
<p className="text-xs">
|
<p className="text-xs">
|
||||||
<strong>Desconsiderado do saldo total:</strong> Esta conta
|
<strong>Desconsiderado do saldo total:</strong> Esta
|
||||||
não é incluída no cálculo do saldo total geral.
|
conta não é incluída no cálculo do saldo total geral.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{excludeInitialBalanceFromIncome && (
|
{excludeInitialBalanceFromIncome && (
|
||||||
@@ -119,17 +130,27 @@ export function AccountCard({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<p className="text-xs text-muted-foreground">{status}</p>
|
||||||
<MoneyValues amount={balance} className="text-3xl" />
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{accountType}</p>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">{accountType}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-1 flex-col gap-2 px-0 pb-2">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">Saldo</span>
|
||||||
|
<MoneyValues
|
||||||
|
amount={balance}
|
||||||
|
className={cn("text-2xl font-semibold", balanceColor)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{actions.length > 0 ? (
|
<CardFooter className="flex flex-wrap gap-4 p-0 text-sm">
|
||||||
<CardFooter className="flex flex-wrap gap-3 px-6 pt-6 text-sm">
|
|
||||||
{actions.map(({ label, icon, onClick, variant }) => (
|
{actions.map(({ label, icon, onClick, variant }) => (
|
||||||
<button
|
<button
|
||||||
key={label}
|
key={label}
|
||||||
@@ -146,7 +167,6 @@ export function AccountCard({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
) : null}
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function AccountStatementCard({
|
|||||||
</p>
|
</p>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={currentBalance}
|
amount={currentBalance}
|
||||||
className="text-3xl leading-none tracking-tighter sm:text-[2rem]"
|
className="text-3xl leading-none tracking-tighter sm:text-2xl"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
|||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
|
|
||||||
export type AccountSummaryData = {
|
type AccountSummaryData = {
|
||||||
openingBalance: number;
|
openingBalance: number;
|
||||||
currentBalance: number;
|
currentBalance: number;
|
||||||
totalIncomes: number;
|
totalIncomes: number;
|
||||||
|
|||||||
@@ -1,28 +1,12 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
|
||||||
import AuthSidebar from "./auth-sidebar";
|
import AuthSidebar from "./auth-sidebar";
|
||||||
|
|
||||||
export function AuthCardShell({ children }: PropsWithChildren) {
|
export function AuthCardShell({ children }: PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<Card className="relative overflow-hidden rounded-2xl md:rounded-[2rem] p-0 shadow-lg border-primary/10">
|
<Card className="overflow-hidden border-primary/10 p-0 shadow-lg">
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
|
<CardContent className="grid p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr]">
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,var(--color-primary)_0%,transparent_70%)] opacity-10 blur-3xl animate-blob mix-blend-multiply" />
|
<div className="flex md:rounded-l-4xl">{children}</div>
|
||||||
<DotPattern
|
|
||||||
width={17}
|
|
||||||
height={17}
|
|
||||||
cx={1.3}
|
|
||||||
cy={1.3}
|
|
||||||
cr={1.3}
|
|
||||||
className="text-primary/8 mask-[linear-gradient(to_bottom,black,transparent_84%)]"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-linear-to-br from-primary/6 via-transparent to-transparent opacity-80" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardContent className="relative z-10 grid gap-0 p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr] overflow-hidden rounded-[inherit]">
|
|
||||||
<div className="flex bg-card/60 backdrop-blur-xl md:rounded-l-[2rem]">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<AuthSidebar />
|
<AuthSidebar />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import type { Budget } from "./types";
|
|||||||
|
|
||||||
interface BudgetCardProps {
|
interface BudgetCardProps {
|
||||||
budget: Budget;
|
budget: Budget;
|
||||||
periodLabel: string;
|
|
||||||
onEdit: (budget: Budget) => void;
|
onEdit: (budget: Budget) => void;
|
||||||
onRemove: (budget: Budget) => void;
|
onRemove: (budget: Budget) => void;
|
||||||
}
|
}
|
||||||
@@ -29,81 +28,88 @@ const buildUsagePercent = (spent: number, limit: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatCategoryName = (budget: Budget) =>
|
const formatCategoryName = (budget: Budget) =>
|
||||||
budget.category?.name ?? "Category removida";
|
budget.category?.name ?? "Categoria removida";
|
||||||
|
|
||||||
export function BudgetCard({
|
export function BudgetCard({ budget, onEdit, onRemove }: BudgetCardProps) {
|
||||||
budget,
|
|
||||||
periodLabel,
|
|
||||||
onEdit,
|
|
||||||
onRemove,
|
|
||||||
}: BudgetCardProps) {
|
|
||||||
const { amount: limit, spent } = budget;
|
const { amount: limit, spent } = budget;
|
||||||
const exceeded = spent > limit && limit >= 0;
|
const exceeded = spent > limit && limit >= 0;
|
||||||
const difference = Math.abs(spent - limit);
|
const difference = Math.abs(spent - limit);
|
||||||
const usagePercent = buildUsagePercent(spent, limit);
|
const usagePercent = buildUsagePercent(spent, limit);
|
||||||
|
const remaining = Math.max(limit - spent, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex h-full flex-col">
|
<Card className="flex w-full flex-col p-6">
|
||||||
<CardContent className="flex h-full flex-col gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<CategoryIconBadge
|
<CategoryIconBadge
|
||||||
icon={budget.category?.icon ?? undefined}
|
icon={budget.category?.icon ?? undefined}
|
||||||
name={formatCategoryName(budget)}
|
name={formatCategoryName(budget)}
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
<div className="min-w-0">
|
||||||
<h3 className="text-base font-semibold leading-tight">
|
<h3 className="truncate font-semibold text-foreground">
|
||||||
{formatCategoryName(budget)}
|
{formatCategoryName(budget)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Orçamento de {periodLabel}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<CardContent className="flex flex-1 flex-col gap-4 p-0">
|
||||||
<div className="flex items-baseline justify-between text-sm">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-muted-foreground">Gasto até agora</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{exceeded ? "Excedido em" : "Disponível"}
|
||||||
|
</span>
|
||||||
|
<MoneyValues
|
||||||
|
amount={exceeded ? difference : remaining}
|
||||||
|
className={cn(
|
||||||
|
"text-xl font-semibold",
|
||||||
|
exceeded ? "text-destructive" : "text-success",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">Orçamento</span>
|
||||||
|
<MoneyValues
|
||||||
|
amount={limit}
|
||||||
|
className="text-sm font-semibold text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">Gasto</span>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={spent}
|
amount={spent}
|
||||||
className={cn(exceeded && "text-destructive")}
|
className={cn(
|
||||||
|
"text-sm font-semibold",
|
||||||
|
exceeded ? "text-destructive" : "text-primary",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
|
||||||
value={usagePercent}
|
|
||||||
className={cn("h-2", exceeded && "bg-destructive/20!")}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-1 text-sm">
|
|
||||||
<span className="text-muted-foreground">Limite</span>
|
|
||||||
<MoneyValues amount={limit} className="text-foreground" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="flex flex-col gap-2">
|
||||||
{exceeded ? (
|
<Progress
|
||||||
<div className="text-xs text-destructive">
|
value={usagePercent}
|
||||||
Excedeu em <MoneyValues amount={difference} />
|
className={cn("h-2.5", exceeded && "bg-destructive/20!")}
|
||||||
</div>
|
aria-label={`${usagePercent.toFixed(1)}% do orçamento utilizado`}
|
||||||
) : (
|
/>
|
||||||
<div className="text-xs text-success">
|
<span className="text-xs text-muted-foreground">
|
||||||
Restam <MoneyValues amount={Math.max(limit - spent, 0)} />{" "}
|
{usagePercent.toFixed(1)}% utilizado
|
||||||
disponíveis.
|
</span>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-wrap gap-3 px-5 text-sm">
|
|
||||||
|
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onEdit(budget)}
|
onClick={() => onEdit(budget)}
|
||||||
className="flex items-center gap-1 text-primary font-medium transition-opacity hover:opacity-80"
|
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<RiPencilLine className="size-4" aria-hidden /> editar
|
<RiPencilLine className="size-4" aria-hidden /> editar
|
||||||
</button>
|
</button>
|
||||||
{budget.category && (
|
{budget.category && (
|
||||||
<Link
|
<Link
|
||||||
href={`/categories/${budget.category.id}`}
|
href={`/categories/${budget.category.id}`}
|
||||||
className="flex items-center gap-1 text-primary font-medium transition-opacity hover:opacity-80"
|
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<RiFileList2Line className="size-4" aria-hidden /> detalhes
|
<RiFileList2Line className="size-4" aria-hidden /> detalhes
|
||||||
</Link>
|
</Link>
|
||||||
@@ -111,7 +117,7 @@ export function BudgetCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemove(budget)}
|
onClick={() => onRemove(budget)}
|
||||||
className="flex items-center gap-1 text-destructive font-medium transition-opacity hover:opacity-80"
|
className="flex items-center gap-1 font-medium text-destructive transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<RiDeleteBin5Line className="size-4" aria-hidden /> remover
|
<RiDeleteBin5Line className="size-4" aria-hidden /> remover
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -19,14 +19,12 @@ interface BudgetsPageProps {
|
|||||||
budgets: Budget[];
|
budgets: Budget[];
|
||||||
categories: BudgetCategory[];
|
categories: BudgetCategory[];
|
||||||
selectedPeriod: string;
|
selectedPeriod: string;
|
||||||
periodLabel: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BudgetsPage({
|
export function BudgetsPage({
|
||||||
budgets,
|
budgets,
|
||||||
categories,
|
categories,
|
||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
periodLabel,
|
|
||||||
}: BudgetsPageProps) {
|
}: BudgetsPageProps) {
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
|
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
|
||||||
@@ -137,7 +135,6 @@ export function BudgetsPage({
|
|||||||
<BudgetCard
|
<BudgetCard
|
||||||
key={budget.id}
|
key={budget.id}
|
||||||
budget={budget}
|
budget={budget}
|
||||||
periodLabel={periodLabel}
|
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onRemove={handleRemoveRequest}
|
onRemove={handleRemoveRequest}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const toNumber = (value: string | number | null | undefined) => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BudgetData = {
|
type BudgetData = {
|
||||||
id: string;
|
id: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
spent: number;
|
spent: number;
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DayCell } from "@/features/calendar/components/day-cell";
|
import { DayCell } from "@/features/calendar/components/day-cell";
|
||||||
|
|
||||||
import type { CalendarDay } from "@/shared/lib/types/calendar";
|
import type { CalendarDay } from "@/shared/lib/types/calendar";
|
||||||
import { WEEK_DAYS_SHORT } from "@/shared/utils/calendar";
|
import { WEEK_DAYS_SHORT } from "@/shared/utils/calendar";
|
||||||
import { cn } from "@/shared/utils/ui";
|
|
||||||
|
|
||||||
type CalendarGridProps = {
|
type CalendarGridProps = {
|
||||||
days: CalendarDay[];
|
days: CalendarDay[];
|
||||||
@@ -18,21 +16,18 @@ export function CalendarGrid({
|
|||||||
onCreateDay,
|
onCreateDay,
|
||||||
}: CalendarGridProps) {
|
}: CalendarGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none">
|
<div className="overflow-hidden rounded-lg border p-2">
|
||||||
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
{WEEK_DAYS_SHORT.map((dayName) => (
|
{WEEK_DAYS_SHORT.map((dayName) => (
|
||||||
<span key={dayName} className="px-3 py-2 text-center">
|
<span key={dayName} className="text-center">
|
||||||
{dayName}
|
{dayName}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-px bg-border/60 px-px pb-px pt-px">
|
<div className="grid grid-cols-7 gap-px px-px pb-px pt-px">
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<div
|
<div key={day.date} className="h-[150px] p-0.5">
|
||||||
key={day.date}
|
|
||||||
className={cn("h-[150px] bg-card p-0.5", !day.isCurrentMonth && "")}
|
|
||||||
>
|
|
||||||
<DayCell day={day} onSelect={onSelectDay} onCreate={onCreateDay} />
|
<DayCell day={day} onSelect={onSelectDay} onCreate={onCreateDay} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,34 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
|
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
|
||||||
import StatusDot from "@/shared/components/status-dot";
|
import { cn } from "@/shared/utils/ui";
|
||||||
import { Card } from "@/shared/components/ui/card";
|
|
||||||
import type { CalendarEvent } from "@/shared/lib/types/calendar";
|
|
||||||
|
|
||||||
const LEGEND_ITEMS: Array<{
|
const LEGEND_ITEMS = [
|
||||||
type?: CalendarEvent["type"];
|
{ label: "Lançamentos", ...EVENT_TYPE_STYLES.transaction },
|
||||||
label: string;
|
{ label: "Boletos", ...EVENT_TYPE_STYLES.boleto },
|
||||||
dotColor?: string;
|
{ label: "Fatura de Cartão", ...EVENT_TYPE_STYLES.card },
|
||||||
}> = [
|
|
||||||
{ type: "transaction", label: "Lançamentos" },
|
|
||||||
{ type: "boleto", label: "Boleto com vencimento" },
|
|
||||||
{ type: "card", label: "Vencimento de cartão" },
|
|
||||||
{ label: "Pagamento fatura", dotColor: "bg-success" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function CalendarLegend() {
|
export function CalendarLegend() {
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-row gap-2 p-2 text-sm">
|
<ul className="flex items-center justify-start gap-2 px-1">
|
||||||
{LEGEND_ITEMS.map((item, index) => {
|
{LEGEND_ITEMS.map((item) => (
|
||||||
const dotColor =
|
<li
|
||||||
item.dotColor || (item.type ? EVENT_TYPE_STYLES[item.type].dot : "");
|
key={item.label}
|
||||||
return (
|
className={cn(
|
||||||
<span key={item.type || index} className="flex items-center gap-2">
|
"flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium",
|
||||||
<StatusDot color={dotColor} />
|
item.wrapper,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn("size-1.5 shrink-0 rounded-full", item.dot)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
</ul>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAddLine } from "@remixicon/react";
|
import { RiAddLine, RiCheckboxCircleFill } from "@remixicon/react";
|
||||||
import type { KeyboardEvent, MouseEvent } from "react";
|
import type { KeyboardEvent, MouseEvent } from "react";
|
||||||
import type { CalendarDay, CalendarEvent } from "@/shared/lib/types/calendar";
|
import type { CalendarDay, CalendarEvent } from "@/shared/lib/types/calendar";
|
||||||
import { currencyFormatter } from "@/shared/utils/currency";
|
import { currencyFormatter } from "@/shared/utils/currency";
|
||||||
@@ -14,44 +14,33 @@ type DayCellProps = {
|
|||||||
|
|
||||||
export const EVENT_TYPE_STYLES: Record<
|
export const EVENT_TYPE_STYLES: Record<
|
||||||
CalendarEvent["type"],
|
CalendarEvent["type"],
|
||||||
{ wrapper: string; dot: string; accent?: string }
|
{ wrapper: string; dot: string }
|
||||||
> = {
|
> = {
|
||||||
transaction: {
|
transaction: {
|
||||||
wrapper:
|
wrapper: "bg-primary/10 text-primary dark:bg-primary/5 dark:text-primary",
|
||||||
"bg-warning/10 text-warning dark:bg-warning/5 dark:text-warning border-l-4 border-warning",
|
dot: "bg-primary",
|
||||||
dot: "bg-warning",
|
|
||||||
},
|
},
|
||||||
boleto: {
|
boleto: {
|
||||||
wrapper:
|
wrapper: "bg-info/10 text-info dark:bg-info/5 dark:text-info",
|
||||||
"bg-info/10 text-info dark:bg-info/5 dark:text-info border-l-4 border-info",
|
|
||||||
dot: "bg-info",
|
dot: "bg-info",
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
wrapper:
|
wrapper:
|
||||||
"bg-violet-100 text-violet-600 dark:bg-violet-900/10 dark:text-violet-50 border-l-4 border-violet-500",
|
"bg-violet-100 text-violet-600 dark:bg-violet-900/10 dark:text-violet-500",
|
||||||
dot: "bg-violet-600",
|
dot: "bg-violet-600 dark:bg-violet-500",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventStyles = EVENT_TYPE_STYLES;
|
|
||||||
|
|
||||||
const formatCurrencyValue = (value: number | null | undefined) =>
|
const formatCurrencyValue = (value: number | null | undefined) =>
|
||||||
currencyFormatter.format(Math.abs(value ?? 0));
|
currencyFormatter.format(Math.abs(value ?? 0));
|
||||||
|
|
||||||
const formatAmount = (event: Extract<CalendarEvent, { type: "transaction" }>) =>
|
|
||||||
formatCurrencyValue(event.transaction.amount);
|
|
||||||
|
|
||||||
const buildEventLabel = (event: CalendarEvent) => {
|
const buildEventLabel = (event: CalendarEvent) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "transaction": {
|
case "transaction":
|
||||||
|
case "boleto":
|
||||||
return event.transaction.name;
|
return event.transaction.name;
|
||||||
}
|
case "card":
|
||||||
case "boleto": {
|
|
||||||
return event.transaction.name;
|
|
||||||
}
|
|
||||||
case "card": {
|
|
||||||
return event.card.name;
|
return event.card.name;
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -59,60 +48,48 @@ const buildEventLabel = (event: CalendarEvent) => {
|
|||||||
|
|
||||||
const buildEventComplement = (event: CalendarEvent) => {
|
const buildEventComplement = (event: CalendarEvent) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "transaction": {
|
case "transaction":
|
||||||
return formatAmount(event);
|
case "boleto":
|
||||||
}
|
|
||||||
case "boleto": {
|
|
||||||
return formatCurrencyValue(event.transaction.amount);
|
return formatCurrencyValue(event.transaction.amount);
|
||||||
}
|
case "card":
|
||||||
case "card": {
|
return event.card.totalDue !== null
|
||||||
if (event.card.totalDue !== null) {
|
? formatCurrencyValue(event.card.totalDue)
|
||||||
return formatCurrencyValue(event.card.totalDue);
|
: null;
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPagamentoFatura = (event: CalendarEvent) => {
|
const isPaid = (event: CalendarEvent) => {
|
||||||
return (
|
if (event.type === "boleto") return Boolean(event.transaction.isSettled);
|
||||||
event.type === "transaction" &&
|
if (event.type === "card") return event.card.isPaid;
|
||||||
event.transaction.name.startsWith("Pagamento fatura -")
|
return false;
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEventStyle = (event: CalendarEvent) => {
|
|
||||||
if (isPagamentoFatura(event)) {
|
|
||||||
return {
|
|
||||||
wrapper:
|
|
||||||
"bg-success/10 text-success dark:bg-success/5 dark:text-success border-l-4 border-success",
|
|
||||||
dot: "bg-success",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return eventStyles[event.type];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
|
const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
|
||||||
const complement = buildEventComplement(event);
|
const complement = buildEventComplement(event);
|
||||||
const label = buildEventLabel(event);
|
const label = buildEventLabel(event);
|
||||||
const style = getEventStyle(event);
|
const style = EVENT_TYPE_STYLES[event.type];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between gap-2 rounded p-1 text-xs",
|
"flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-xs",
|
||||||
style.wrapper,
|
style.wrapper,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<span
|
||||||
|
className={cn("size-1.5 shrink-0 rounded-full", style.dot)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
<span className="truncate">{label}</span>
|
<span className="truncate">{label}</span>
|
||||||
|
{isPaid(event) && (
|
||||||
|
<RiCheckboxCircleFill className="size-3.5 shrink-0 text-success" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{complement ? (
|
{complement ? (
|
||||||
<span className={cn("shrink-0 font-medium", style.accent ?? "text-xs")}>
|
<span className="shrink-0 font-medium">{complement}</span>
|
||||||
{complement}
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -143,8 +120,8 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
|||||||
onClick={() => onSelect(day)}
|
onClick={() => onSelect(day)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent bg-card/80 p-2 text-left transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-accent",
|
"group flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border bg-card/80 p-2 text-left transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-accent",
|
||||||
!day.isCurrentMonth && "opacity-60",
|
!day.isCurrentMonth && "bg-muted/20 opacity-60",
|
||||||
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
|
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -159,6 +136,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
|||||||
>
|
>
|
||||||
{day.label}
|
{day.label}
|
||||||
</span>
|
</span>
|
||||||
|
{day.isCurrentMonth && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreateClick}
|
onClick={handleCreateClick}
|
||||||
@@ -167,6 +145,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
|||||||
>
|
>
|
||||||
<RiAddLine className="size-3.5" />
|
<RiAddLine className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { RiCalendarEventLine } from "@remixicon/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
|
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
@@ -29,17 +30,13 @@ type EventModalProps = {
|
|||||||
const EventCard = ({
|
const EventCard = ({
|
||||||
children,
|
children,
|
||||||
type,
|
type,
|
||||||
isPagamentoFatura = false,
|
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
type: CalendarEvent["type"];
|
type: CalendarEvent["type"];
|
||||||
isPagamentoFatura?: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
const style = isPagamentoFatura
|
const style = EVENT_TYPE_STYLES[type];
|
||||||
? { dot: "bg-success" }
|
|
||||||
: EVENT_TYPE_STYLES[type];
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-row gap-2 p-3 mb-1">
|
<Card className="flex flex-row gap-2 p-3">
|
||||||
<span
|
<span
|
||||||
className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)}
|
className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -49,41 +46,34 @@ const EventCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
};
|
||||||
|
|
||||||
const renderLancamento = (
|
const renderLancamento = (
|
||||||
event: Extract<CalendarEvent, { type: "transaction" }>,
|
event: Extract<CalendarEvent, { type: "transaction" }>,
|
||||||
) => {
|
) => {
|
||||||
const isReceita = event.transaction.transactionType === "Receita";
|
const isReceita = event.transaction.transactionType === "Receita";
|
||||||
const isPagamentoFatura =
|
|
||||||
event.transaction.name.startsWith("Pagamento fatura -");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventCard type="transaction" isPagamentoFatura={isPagamentoFatura}>
|
<EventCard type="transaction">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span
|
<span className="text-sm font-medium leading-tight">
|
||||||
className={`text-sm font-medium leading-tight ${
|
|
||||||
isPagamentoFatura && "text-success"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{event.transaction.name}
|
{event.transaction.name}
|
||||||
</span>
|
</span>
|
||||||
|
<Badge variant="outline">{event.transaction.categoriaName}</Badge>
|
||||||
<div className="flex gap-1">
|
|
||||||
<Badge variant={"outline"}>{event.transaction.categoriaName}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium whitespace-nowrap",
|
|
||||||
isReceita ? "text-success" : "text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
showPositiveSign
|
showPositiveSign
|
||||||
className="text-base"
|
className={cn(
|
||||||
|
"text-base whitespace-nowrap font-medium",
|
||||||
|
isReceita ? "text-success" : "text-foreground",
|
||||||
|
)}
|
||||||
amount={event.transaction.amount}
|
amount={event.transaction.amount}
|
||||||
/>
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</EventCard>
|
</EventCard>
|
||||||
);
|
);
|
||||||
@@ -91,59 +81,80 @@ const renderLancamento = (
|
|||||||
|
|
||||||
const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
||||||
const isPaid = Boolean(event.transaction.isSettled);
|
const isPaid = Boolean(event.transaction.isSettled);
|
||||||
const dueDate = event.transaction.dueDate;
|
const dueDateLabel = formatFinancialDateLabel(
|
||||||
const dueDateLabel = formatFinancialDateLabel(dueDate, "Vence em", {
|
event.transaction.dueDate,
|
||||||
day: "2-digit",
|
"Vence em",
|
||||||
month: "2-digit",
|
DATE_FORMAT,
|
||||||
year: "numeric",
|
);
|
||||||
});
|
const paymentDateLabel = isPaid
|
||||||
|
? formatFinancialDateLabel(
|
||||||
|
event.transaction.boletoPaymentDate,
|
||||||
|
"Pago em",
|
||||||
|
DATE_FORMAT,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventCard type="boleto">
|
<EventCard type="boleto">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex gap-1 items-center">
|
|
||||||
<span className="text-sm font-medium leading-tight">
|
<span className="text-sm font-medium leading-tight">
|
||||||
{event.transaction.name}
|
{event.transaction.name}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs">
|
||||||
{dueDateLabel && (
|
{dueDateLabel && (
|
||||||
<span className="text-xs text-muted-foreground leading-tight">
|
<span className="text-muted-foreground">{dueDateLabel}</span>
|
||||||
{dueDateLabel}
|
)}
|
||||||
</span>
|
{paymentDateLabel && (
|
||||||
|
<span className="text-success">{paymentDateLabel}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Badge variant="outline">{isPaid ? "Pago" : "Pendente"}</Badge>
|
||||||
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium">
|
<MoneyValues
|
||||||
<MoneyValues amount={event.transaction.amount} />
|
className="font-medium whitespace-nowrap"
|
||||||
</span>
|
amount={event.transaction.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</EventCard>
|
</EventCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
|
const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => {
|
||||||
|
const paymentDateLabel = event.card.isPaid
|
||||||
|
? formatFinancialDateLabel(event.card.paymentDate, "Pago em", DATE_FORMAT)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
<EventCard type="card">
|
<EventCard type="card">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex gap-1 items-center">
|
|
||||||
<span className="text-sm font-medium leading-tight">
|
<span className="text-sm font-medium leading-tight">
|
||||||
Vencimento Fatura - {event.card.name}
|
Vencimento Fatura — {event.card.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
{paymentDateLabel && (
|
||||||
|
<span className="text-xs text-success">{paymentDateLabel}</span>
|
||||||
<Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge>
|
)}
|
||||||
|
<Badge variant="outline">
|
||||||
|
{event.card.isPaid ? "Pago" : (event.card.status ?? "Fatura")}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{event.card.totalDue !== null ? (
|
{event.card.totalDue !== null ? (
|
||||||
<span className="font-medium">
|
<MoneyValues
|
||||||
<MoneyValues amount={event.card.totalDue} />
|
className="font-medium whitespace-nowrap"
|
||||||
</span>
|
amount={event.card.totalDue}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</EventCard>
|
</EventCard>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_LABELS: Record<CalendarEvent["type"], string> = {
|
||||||
|
transaction: "Lançamentos",
|
||||||
|
boleto: "Boletos",
|
||||||
|
card: "Faturas",
|
||||||
|
};
|
||||||
|
|
||||||
const renderEvent = (event: CalendarEvent) => {
|
const renderEvent = (event: CalendarEvent) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -169,28 +180,50 @@ export function EventModal({ open, day, onClose, onCreate }: EventModalProps) {
|
|||||||
onCreate(day.date);
|
onCreate(day.date);
|
||||||
};
|
};
|
||||||
|
|
||||||
const description = day?.events.length
|
const hasEvents = Boolean(day?.events.length);
|
||||||
? "Confira os lançamentos e vencimentos cadastrados para este dia."
|
|
||||||
: "Nenhum lançamento encontrado para este dia. Você pode criar um novo lançamento agora.";
|
const grouped = day
|
||||||
|
? {
|
||||||
|
transaction: day.events.filter((e) => e.type === "transaction"),
|
||||||
|
boleto: day.events.filter((e) => e.type === "boleto"),
|
||||||
|
card: day.events.filter((e) => e.type === "card"),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
|
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
|
||||||
<DialogContent className="max-w-xl">
|
<DialogContent className="max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{formattedDate}</DialogTitle>
|
<DialogTitle>{formattedDate}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>
|
||||||
|
{hasEvents
|
||||||
|
? "Lançamentos e vencimentos cadastrados para este dia."
|
||||||
|
: "Nenhum lançamento encontrado para este dia."}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="max-h-[380px] space-y-2 overflow-y-auto pr-2">
|
<div className="max-h-[380px] space-y-3 overflow-y-auto pr-2">
|
||||||
{day?.events.length ? (
|
{hasEvents && grouped ? (
|
||||||
day.events.map((event) => (
|
(["transaction", "boleto", "card"] as const)
|
||||||
|
.filter((type) => grouped[type].length > 0)
|
||||||
|
.map((type) => (
|
||||||
|
<div key={type} className="space-y-1.5">
|
||||||
|
<p className="px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{SECTION_LABELS[type]}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{grouped[type].map((event) => (
|
||||||
<div key={event.id}>{renderEvent(event)}</div>
|
<div key={event.id}>{renderEvent(event)}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 p-6 text-center text-sm text-muted-foreground">
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border/60 bg-muted/30 p-8 text-center">
|
||||||
Nenhum lançamento ou vencimento registrado. Clique em{" "}
|
<RiCalendarEventLine className="size-8 text-muted-foreground/50" />
|
||||||
<span className="font-medium text-primary">Novo lançamento</span>{" "}
|
<p className="text-sm text-muted-foreground">
|
||||||
para começar.
|
Nenhum lançamento registrado para este dia.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { parsePeriod } from "@/shared/utils/period";
|
|||||||
|
|
||||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||||
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
|
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
|
||||||
|
const PAYMENT_PREFIX = "Pagamento fatura - ";
|
||||||
|
|
||||||
const clampDayInMonth = (year: number, monthIndex: number, day: number) => {
|
const clampDayInMonth = (year: number, monthIndex: number, day: number) => {
|
||||||
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
||||||
@@ -88,19 +89,28 @@ export const fetchCalendarData = async ({
|
|||||||
const transactionData = mapTransactionsData(transactionRows);
|
const transactionData = mapTransactionsData(transactionRows);
|
||||||
const events: CalendarEvent[] = [];
|
const events: CalendarEvent[] = [];
|
||||||
|
|
||||||
|
// Totais por cartão para exibir no vencimento
|
||||||
const cardTotals = new Map<string, number>();
|
const cardTotals = new Map<string, number>();
|
||||||
for (const item of transactionData) {
|
for (const item of transactionData) {
|
||||||
if (!item.cardId || item.period !== period) {
|
if (!item.cardId || item.period !== period) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const amount = Math.abs(item.amount ?? 0);
|
const amount = Math.abs(item.amount ?? 0);
|
||||||
cardTotals.set(item.cardId, (cardTotals.get(item.cardId) ?? 0) + amount);
|
cardTotals.set(item.cardId, (cardTotals.get(item.cardId) ?? 0) + amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pagamentos de fatura por nome do cartão → data de pagamento
|
||||||
|
const paymentByCardName = new Map<string, string | null>();
|
||||||
for (const item of transactionData) {
|
for (const item of transactionData) {
|
||||||
|
if (!item.name.startsWith(PAYMENT_PREFIX)) continue;
|
||||||
|
const cardName = item.name.slice(PAYMENT_PREFIX.length);
|
||||||
|
paymentByCardName.set(cardName, item.purchaseDate?.slice(0, 10) ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of transactionData) {
|
||||||
|
// Pagamentos de fatura são consumidos pelos eventos de cartão
|
||||||
|
if (item.name.startsWith(PAYMENT_PREFIX)) continue;
|
||||||
|
|
||||||
const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO;
|
const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO;
|
||||||
|
|
||||||
// Para boletos, exibir apenas na data de vencimento
|
|
||||||
if (isBoleto) {
|
if (isBoleto) {
|
||||||
if (
|
if (
|
||||||
item.dueDate &&
|
item.dueDate &&
|
||||||
@@ -114,7 +124,6 @@ export const fetchCalendarData = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Para outros tipos de lançamento, exibir na data de compra
|
|
||||||
const purchaseDateKey = item.purchaseDate.slice(0, 10);
|
const purchaseDateKey = item.purchaseDate.slice(0, 10);
|
||||||
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
|
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
|
||||||
events.push({
|
events.push({
|
||||||
@@ -127,22 +136,21 @@ export const fetchCalendarData = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exibir vencimentos apenas de cartões com lançamentos do período
|
// Vencimentos de cartões com lançamentos no período
|
||||||
for (const card of cardRows) {
|
for (const card of cardRows) {
|
||||||
if (!cardTotals.has(card.id)) {
|
if (!cardTotals.has(card.id)) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10);
|
const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10);
|
||||||
if (Number.isNaN(dueDayNumber)) {
|
if (Number.isNaN(dueDayNumber)) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
|
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
|
||||||
const dueDateKey = formatDateKey(
|
const dueDateKey = formatDateKey(
|
||||||
new Date(Date.UTC(year, monthIndex, normalizedDay)),
|
new Date(Date.UTC(year, monthIndex, normalizedDay)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isPaid = paymentByCardName.has(card.name);
|
||||||
|
const paymentDate = paymentByCardName.get(card.name) ?? null;
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
id: `${card.id}:cartao`,
|
id: `${card.id}:cartao`,
|
||||||
type: "card",
|
type: "card",
|
||||||
@@ -156,6 +164,8 @@ export const fetchCalendarData = async ({
|
|||||||
status: card.status,
|
status: card.status,
|
||||||
logo: card.logo ?? null,
|
logo: card.logo ?? null,
|
||||||
totalDue: cardTotals.get(card.id) ?? null,
|
totalDue: cardTotals.get(card.id) ?? null,
|
||||||
|
isPaid,
|
||||||
|
paymentDate,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,39 +84,11 @@ export function CardItem({
|
|||||||
const logoPath = resolveLogoSrc(logo);
|
const logoPath = resolveLogoSrc(logo);
|
||||||
const brandAsset = resolveCardBrandAsset(brand);
|
const brandAsset = resolveCardBrandAsset(brand);
|
||||||
const isInactive = status?.toLowerCase() === "inativo";
|
const isInactive = status?.toLowerCase() === "inativo";
|
||||||
const metrics =
|
const hasMetrics = limitTotal !== null && used !== null && available !== null;
|
||||||
limitTotal === null || used === null || available === null
|
|
||||||
? null
|
|
||||||
: [
|
|
||||||
{ label: "Limite Total", value: limitTotal },
|
|
||||||
{ label: "Em uso", value: used },
|
|
||||||
{ label: "Disponível", value: available },
|
|
||||||
];
|
|
||||||
|
|
||||||
const actions = [
|
|
||||||
{
|
|
||||||
label: "editar",
|
|
||||||
icon: <RiPencilLine className="size-4" aria-hidden />,
|
|
||||||
onClick: onEdit,
|
|
||||||
className: "text-primary",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "ver fatura",
|
|
||||||
icon: <RiFileList2Line className="size-4" aria-hidden />,
|
|
||||||
onClick: onInvoice,
|
|
||||||
className: "text-primary",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "remover",
|
|
||||||
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
|
||||||
onClick: onRemove,
|
|
||||||
className: "text-destructive",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col p-6 w-full">
|
<Card className="flex flex-col p-6 w-full">
|
||||||
<CardHeader className="space-y-2 px-0 pb-0">
|
<CardHeader className="space-y-2 p-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
{logoPath ? (
|
{logoPath ? (
|
||||||
@@ -135,8 +107,8 @@ export function CardItem({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
|
<h3 className="truncate font-semibold text-foreground">
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</h3>
|
||||||
{note ? (
|
{note ? (
|
||||||
@@ -166,14 +138,14 @@ export function CardItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{brandAsset ? (
|
{brandAsset ? (
|
||||||
<div className="flex items-center justify-center py-1">
|
<div className="flex items-center justify-center py-2">
|
||||||
<Image
|
<Image
|
||||||
src={brandAsset}
|
src={brandAsset}
|
||||||
alt={`Bandeira ${brand}`}
|
alt={`Bandeira ${brand}`}
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-5 w-auto rounded",
|
"h-4 w-auto rounded",
|
||||||
isInactive && "grayscale opacity-40",
|
isInactive && "grayscale opacity-40",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -185,56 +157,65 @@ export function CardItem({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-y py-3 text-xs font-medium text-muted-foreground sm:text-sm">
|
<div className="flex items-center justify-between border-y py-3 text-sm text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
Fecha dia{" "}
|
Fecha em{" "}
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{formatDay(closingDay)}
|
dia {formatDay(closingDay)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Vence dia{" "}
|
Vence em{" "}
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{formatDay(dueDay)}
|
dia {formatDay(dueDay)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="flex flex-1 flex-col gap-5 px-0">
|
<CardContent className="flex flex-1 flex-col gap-4 px-0">
|
||||||
{metrics ? (
|
{hasMetrics &&
|
||||||
|
available !== null &&
|
||||||
|
used !== null &&
|
||||||
|
limitTotal !== null ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="flex flex-col gap-0.5">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<span className="text-xs text-muted-foreground">Disponível</span>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<MoneyValues
|
||||||
<MoneyValues amount={metrics[0].value} />
|
amount={available}
|
||||||
</p>
|
className="text-xl font-semibold text-success"
|
||||||
<span className="text-xs text-muted-foreground">
|
/>
|
||||||
{metrics[0].label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="size-2 rounded-full bg-primary" />
|
|
||||||
<MoneyValues amount={metrics[1].value} />
|
|
||||||
</p>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{metrics[1].label}
|
Limite total
|
||||||
</span>
|
</span>
|
||||||
|
<MoneyValues
|
||||||
|
amount={limitTotal}
|
||||||
|
className="text-sm font-semibold text-foreground"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
<div className="flex flex-col items-end gap-1">
|
<span className="text-xs text-muted-foreground">Em uso</span>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<MoneyValues
|
||||||
<MoneyValues amount={metrics[2].value} />
|
amount={used}
|
||||||
</p>
|
className="text-sm font-semibold text-primary"
|
||||||
<span className="text-xs text-muted-foreground">
|
/>
|
||||||
{metrics[2].label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Progress value={usagePercent} className="h-3" />
|
<div className="flex flex-col gap-2">
|
||||||
|
<Progress
|
||||||
|
value={usagePercent}
|
||||||
|
className="h-2.5"
|
||||||
|
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{usagePercent.toFixed(1)}% utilizado
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -243,21 +224,31 @@ export function CardItem({
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 text-sm">
|
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">
|
||||||
{actions.map(({ label, icon, onClick, className }) => (
|
|
||||||
<button
|
<button
|
||||||
key={label}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onEdit}
|
||||||
className={cn(
|
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
|
||||||
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{icon}
|
<RiPencilLine className="size-4" aria-hidden />
|
||||||
{label}
|
editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onInvoice}
|
||||||
|
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<RiFileList2Line className="size-4" aria-hidden />
|
||||||
|
ver fatura
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="flex items-center gap-1 font-medium text-destructive transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||||
|
remover
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cards, financialAccounts, transactions } from "@/db/schema";
|
|||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { loadLogoOptions } from "@/shared/lib/logo/options";
|
import { loadLogoOptions } from "@/shared/lib/logo/options";
|
||||||
|
|
||||||
export type CardData = {
|
type CardData = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
CATEGORY_TYPE_LABEL,
|
CATEGORY_TYPE_LABEL,
|
||||||
CATEGORY_TYPES,
|
CATEGORY_TYPES,
|
||||||
|
type CategoryType,
|
||||||
} from "@/shared/lib/categories/constants";
|
} from "@/shared/lib/categories/constants";
|
||||||
|
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
||||||
import { CategoryDialog } from "./category-dialog";
|
import { CategoryDialog } from "./category-dialog";
|
||||||
import { CategoryIconBadge } from "./category-icon-badge";
|
import type { Category } from "./types";
|
||||||
import type { Category, CategoryType } from "./types";
|
|
||||||
|
|
||||||
const CATEGORIAS_PROTEGIDAS = [
|
const CATEGORIAS_PROTEGIDAS = [
|
||||||
"Transferência interna",
|
"Transferência interna",
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react";
|
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||||
|
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
||||||
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
||||||
import { Card } from "@/shared/components/ui/card";
|
import { Card } from "@/shared/components/ui/card";
|
||||||
import type { CategoryType } from "@/shared/lib/categories/constants";
|
import type { CategoryType } from "@/shared/lib/categories/constants";
|
||||||
import { currencyFormatter } from "@/shared/utils/currency";
|
import { currencyFormatter } from "@/shared/utils/currency";
|
||||||
import { formatPercentage } from "@/shared/utils/percentage";
|
import { formatPercentage } from "@/shared/utils/percentage";
|
||||||
import { cn } from "@/shared/utils/ui";
|
|
||||||
import { CategoryIconBadge } from "./category-icon-badge";
|
|
||||||
|
|
||||||
type CategorySummary = {
|
type CategorySummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,33 +32,6 @@ export function CategoryDetailHeader({
|
|||||||
percentageChange,
|
percentageChange,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
}: CategoryDetailHeaderProps) {
|
}: CategoryDetailHeaderProps) {
|
||||||
const isIncrease =
|
|
||||||
typeof percentageChange === "number" && percentageChange > 0;
|
|
||||||
const isDecrease =
|
|
||||||
typeof percentageChange === "number" && percentageChange < 0;
|
|
||||||
|
|
||||||
const variationColor =
|
|
||||||
category.type === "receita"
|
|
||||||
? isIncrease
|
|
||||||
? "text-success"
|
|
||||||
: isDecrease
|
|
||||||
? "text-destructive"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
: isIncrease
|
|
||||||
? "text-destructive"
|
|
||||||
: isDecrease
|
|
||||||
? "text-success"
|
|
||||||
: "text-muted-foreground";
|
|
||||||
|
|
||||||
const variationIcon =
|
|
||||||
isIncrease || isDecrease ? (
|
|
||||||
isIncrease ? (
|
|
||||||
<RiArrowUpSFill className="size-4" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<RiArrowDownSFill className="size-4" aria-hidden />
|
|
||||||
)
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const variationLabel =
|
const variationLabel =
|
||||||
typeof percentageChange === "number"
|
typeof percentageChange === "number"
|
||||||
? formatPercentage(percentageChange, {
|
? formatPercentage(percentageChange, {
|
||||||
@@ -115,15 +87,13 @@ export function CategoryDetailHeader({
|
|||||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Variação vs mês anterior
|
Variação vs mês anterior
|
||||||
</p>
|
</p>
|
||||||
<div
|
<PercentageChangeIndicator
|
||||||
className={cn(
|
value={percentageChange}
|
||||||
"mt-1 flex items-center gap-1 text-lg font-semibold",
|
label={variationLabel}
|
||||||
variationColor,
|
positiveTrend={category.type === "receita" ? "up" : "down"}
|
||||||
)}
|
className="mt-1 gap-1 text-lg font-semibold"
|
||||||
>
|
iconClassName="size-4"
|
||||||
{variationIcon}
|
/>
|
||||||
<span>{variationLabel}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
// Re-export from shared — componente movido para src/shared/components/entity-avatar/
|
|
||||||
export {
|
|
||||||
CategoryIconBadge,
|
|
||||||
type CategoryIconBadgeProps,
|
|
||||||
type CategoryIconBadgeSize,
|
|
||||||
} from "@/shared/components/entity-avatar";
|
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
import type { CategoryType } from "@/shared/lib/categories/constants";
|
import type { CategoryType } from "@/shared/lib/categories/constants";
|
||||||
|
|
||||||
export type { CategoryType } from "@/shared/lib/categories/constants";
|
|
||||||
export {
|
|
||||||
CATEGORY_TYPE_LABEL,
|
|
||||||
CATEGORY_TYPES,
|
|
||||||
} from "@/shared/lib/categories/constants";
|
|
||||||
|
|
||||||
export type Category = {
|
export type Category = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { type Category, categories } from "@/db/schema";
|
import { type Category, categories } from "@/db/schema";
|
||||||
import type { CategoryType } from "@/features/categories/components/types";
|
import type { CategoryType } from "@/shared/lib/categories/constants";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
|
||||||
export type CategoryData = {
|
export type CategoryData = {
|
||||||
|
|||||||
@@ -149,9 +149,9 @@ export const InboxCard = memo(function InboxCard({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="flex-1 py-2">
|
<CardContent className="min-h-0 flex-1 overflow-hidden py-2">
|
||||||
{item.originalTitle && (
|
{item.originalTitle && (
|
||||||
<p className="mb-1 text-sm font-medium">{item.originalTitle}</p>
|
<p className="mb-1 line-clamp-2 text-sm font-medium">{item.originalTitle}</p>
|
||||||
)}
|
)}
|
||||||
<p className="line-clamp-4 whitespace-pre-wrap text-sm text-muted-foreground">
|
<p className="line-clamp-4 whitespace-pre-wrap text-sm text-muted-foreground">
|
||||||
{item.originalText}
|
{item.originalText}
|
||||||
|
|||||||
@@ -102,17 +102,15 @@ export function InboxItemsList({
|
|||||||
const groups = groupItemsByDay(items);
|
const groups = groupItemsByDay(items);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
|
||||||
{groups.map((group) => (
|
|
||||||
<div key={group.label}>
|
|
||||||
<div className="mb-3 flex items-center gap-1 text-muted-foreground">
|
|
||||||
<RiCalendarEventLine className="size-3.5 shrink-0" />
|
|
||||||
<p className="text-sm font-medium">{group.label}</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{group.items.map((item) => (
|
{groups.flatMap((group) =>
|
||||||
|
group.items.map((item) => (
|
||||||
|
<div key={item.id} className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<RiCalendarEventLine className="size-3 shrink-0" />
|
||||||
|
<span className="text-xs font-medium">{group.label}</span>
|
||||||
|
</div>
|
||||||
<InboxCard
|
<InboxCard
|
||||||
key={item.id}
|
|
||||||
item={item}
|
item={item}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
appLogoMap={appLogoMap}
|
appLogoMap={appLogoMap}
|
||||||
@@ -124,10 +122,9 @@ export function InboxItemsList({
|
|||||||
selected={selectedIds.includes(item.id)}
|
selected={selectedIds.includes(item.id)}
|
||||||
onSelectToggle={onSelectToggle}
|
onSelectToggle={onSelectToggle}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)),
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,9 +191,9 @@ export function InvoiceSummaryCard({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">Valor da fatura</p>
|
<p className="text-sm text-muted-foreground">Valor da fatura</p>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={totalAmount}
|
amount={Math.abs(totalAmount)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-3xl leading-none tracking-tighter sm:text-[2rem]",
|
"text-3xl tracking-tighter font-semibold",
|
||||||
isPaid ? "text-success" : "text-foreground",
|
isPaid ? "text-success" : "text-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ export function SetupTabs() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DATA_COLORS = [
|
||||||
|
"var(--data-1)",
|
||||||
|
"var(--data-3)",
|
||||||
|
"var(--data-5)",
|
||||||
|
"var(--data-4)",
|
||||||
|
];
|
||||||
|
|
||||||
function StepCard({
|
function StepCard({
|
||||||
step,
|
step,
|
||||||
title,
|
title,
|
||||||
@@ -95,11 +102,18 @@ function StepCard({
|
|||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const colorVar = DATA_COLORS[(step - 1) % DATA_COLORS.length];
|
||||||
return (
|
return (
|
||||||
<Card className="border">
|
<Card className="border">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex gap-3 md:gap-4">
|
<div className="flex gap-3 md:gap-4">
|
||||||
<div className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground font-medium text-sm md:text-base">
|
<div
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `color-mix(in oklch, ${colorVar} 20%, transparent)`,
|
||||||
|
color: "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{step}
|
{step}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -49,42 +49,42 @@ export const mainFeatures: FeatureItem[] = [
|
|||||||
icon: RiWalletLine,
|
icon: RiWalletLine,
|
||||||
title: "Contas e transações",
|
title: "Contas e transações",
|
||||||
description:
|
description:
|
||||||
"Registre suas contas bancárias, cartões e dinheiro. Adicione receitas, despesas e transferências. Organize por categorias. Extratos detalhados por conta.",
|
"Contas bancárias, cartões e dinheiro em um só lugar, se organize como preferir.",
|
||||||
colorVar: "var(--data-9)",
|
colorVar: "var(--data-5)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiPercentLine,
|
icon: RiPercentLine,
|
||||||
title: "Parcelamentos avançados",
|
title: "Parcelamentos avançados",
|
||||||
description:
|
description:
|
||||||
"Controle completo de compras parceladas. Antecipe parcelas com cálculo automático de desconto. Veja análise consolidada de todas as parcelas em aberto.",
|
"Controle compras parceladas e antecipe parcelas com cálculo automático de desconto.",
|
||||||
colorVar: "var(--data-4)",
|
colorVar: "var(--data-4)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiRobot2Line,
|
icon: RiRobot2Line,
|
||||||
title: "Insights com IA",
|
title: "Insights com IA",
|
||||||
description:
|
description:
|
||||||
"Análises financeiras geradas por IA (Claude, GPT, Gemini). Insights personalizados sobre seus padrões de gastos e recomendações inteligentes.",
|
"Análises por IA com insights sobre padrões de gastos e recomendações personalizadas.",
|
||||||
colorVar: "var(--data-8)",
|
colorVar: "var(--data-6)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiBarChartBoxLine,
|
icon: RiBarChartBoxLine,
|
||||||
title: "Relatórios e gráficos",
|
title: "Relatórios e gráficos",
|
||||||
description:
|
description:
|
||||||
"Dashboard com 20+ widgets interativos. Relatórios detalhados por categoria. Gráficos de evolução e comparativos. Exportação em PDF e Excel.",
|
"20+ widgets interativos, relatórios por categoria e exportação em PDF e Excel.",
|
||||||
colorVar: "var(--data-5)",
|
colorVar: "var(--data-5)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiBankCard2Line,
|
icon: RiBankCard2Line,
|
||||||
title: "Faturas de cartão",
|
title: "Faturas de cartão",
|
||||||
description:
|
description:
|
||||||
"Cadastre seus cartões e acompanhe as faturas por período. Veja o que ainda não foi fechado. Controle limites, vencimentos e fechamentos.",
|
"Acompanhe faturas por período, limites e vencimentos de cada cartão.",
|
||||||
colorVar: "var(--data-1)",
|
colorVar: "var(--data-1)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiTeamLine,
|
icon: RiTeamLine,
|
||||||
title: "Gestão colaborativa",
|
title: "Gestão colaborativa",
|
||||||
description:
|
description:
|
||||||
"Compartilhe pagadores com permissões granulares (admin/viewer). Notificações automáticas por e-mail. Colabore em lançamentos compartilhados.",
|
"Compartilhe acesso com permissões granulares (admin/viewer) e notificações por e-mail.",
|
||||||
colorVar: "var(--data-3)",
|
colorVar: "var(--data-3)",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -94,40 +94,40 @@ export const extraFeatures: FeatureItem[] = [
|
|||||||
icon: RiPieChartLine,
|
icon: RiPieChartLine,
|
||||||
title: "Categorias e orçamentos",
|
title: "Categorias e orçamentos",
|
||||||
description:
|
description:
|
||||||
"Crie categorias personalizadas e defina orçamentos mensais com indicadores visuais.",
|
"Categorias personalizadas com orçamentos mensais e indicadores visuais de progresso.",
|
||||||
colorVar: "var(--data-7)",
|
colorVar: "var(--data-6)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiFileTextLine,
|
icon: RiFileTextLine,
|
||||||
title: "Anotações e tarefas",
|
title: "Anotações e tarefas",
|
||||||
description:
|
description:
|
||||||
"Notas de texto e listas de tarefas com checkboxes. Arquivamento para manter histórico.",
|
"Notas de texto e listas de tarefas com checkboxes e arquivamento.",
|
||||||
colorVar: "var(--data-6)",
|
colorVar: "var(--data-6)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiCalendarLine,
|
icon: RiCalendarLine,
|
||||||
title: "Calendário financeiro",
|
title: "Calendário financeiro",
|
||||||
description:
|
description:
|
||||||
"Visualize transações em calendário mensal. Nunca perca prazos de pagamentos.",
|
"Visualize transações em calendário mensal para não perder prazos.",
|
||||||
colorVar: "var(--data-2)",
|
colorVar: "var(--data-2)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiDownloadCloudLine,
|
icon: RiDownloadCloudLine,
|
||||||
title: "Importação em massa",
|
title: "Importação em massa",
|
||||||
description: "Lance múltiplos lançamentos de uma vez",
|
description: "Importe múltiplos lançamentos de uma só vez.",
|
||||||
colorVar: "var(--data-9)",
|
colorVar: "var(--data-5)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiEyeOffLine,
|
icon: RiEyeOffLine,
|
||||||
title: "Modo privacidade",
|
title: "Modo privacidade",
|
||||||
description:
|
description:
|
||||||
"Oculte valores sensíveis com um clique. Tema dark/light. Calculadora integrada.",
|
"Oculte valores com um clique. Tema dark/light e calculadora integrada.",
|
||||||
colorVar: "var(--data-4)",
|
colorVar: "var(--data-4)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiFlashlightLine,
|
icon: RiFlashlightLine,
|
||||||
title: "Performance otimizada",
|
title: "Performance otimizada",
|
||||||
description: "Sistema rápido e com alta performance",
|
description: "Interface rápida e otimizada para uso diário.",
|
||||||
colorVar: "var(--data-5)",
|
colorVar: "var(--data-5)",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -150,7 +150,7 @@ export const pwaHighlights: FeatureItem[] = [
|
|||||||
icon: RiLayoutGridLine,
|
icon: RiLayoutGridLine,
|
||||||
title: "Acesso rápido ao que importa",
|
title: "Acesso rápido ao que importa",
|
||||||
description: "Dashboard, inbox e lançamentos a um toque.",
|
description: "Dashboard, inbox e lançamentos a um toque.",
|
||||||
colorVar: "var(--data-9)",
|
colorVar: "var(--data-5)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiFlashlightLine,
|
icon: RiFlashlightLine,
|
||||||
@@ -193,7 +193,7 @@ export const companionSteps: FeatureItem[] = [
|
|||||||
icon: RiCheckLine,
|
icon: RiCheckLine,
|
||||||
title: "Revise e confirme no OpenMonetis",
|
title: "Revise e confirme no OpenMonetis",
|
||||||
description: "Pré-lançamentos ficam na inbox para sua aprovação",
|
description: "Pré-lançamentos ficam na inbox para sua aprovação",
|
||||||
colorVar: "var(--data-9)",
|
colorVar: "var(--data-5)",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ export const stackItems = [
|
|||||||
title: "Backend",
|
title: "Backend",
|
||||||
subtitle: "PostgreSQL, Drizzle ORM, Better Auth",
|
subtitle: "PostgreSQL, Drizzle ORM, Better Auth",
|
||||||
description: "Banco relacional robusto com type-safe ORM",
|
description: "Banco relacional robusto com type-safe ORM",
|
||||||
colorVar: "var(--data-9)",
|
colorVar: "var(--data-5)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiShieldCheckLine,
|
icon: RiShieldCheckLine,
|
||||||
@@ -242,7 +242,7 @@ export const whoIsItForItems: FeatureItem[] = [
|
|||||||
title: "Quer controle total sobre seus dados",
|
title: "Quer controle total sobre seus dados",
|
||||||
description:
|
description:
|
||||||
"Prefere hospedar seus próprios dados ao invés de depender de serviços terceiros",
|
"Prefere hospedar seus próprios dados ao invés de depender de serviços terceiros",
|
||||||
colorVar: "var(--data-9)",
|
colorVar: "var(--data-5)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiLineChartLine,
|
icon: RiLineChartLine,
|
||||||
@@ -270,7 +270,7 @@ export const whoIsItForItems: FeatureItem[] = [
|
|||||||
title: "Não sou responsável por nada",
|
title: "Não sou responsável por nada",
|
||||||
description:
|
description:
|
||||||
"Não sou responsável por nada que aconteça com você ou com seus dados.",
|
"Não sou responsável por nada que aconteça com você ou com seus dados.",
|
||||||
colorVar: "var(--data-9)",
|
colorVar: "var(--data-5)",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ export function getMetricsItems(stars: number, forks: number) {
|
|||||||
icon: RiLayoutGridLine,
|
icon: RiLayoutGridLine,
|
||||||
value: "20+",
|
value: "20+",
|
||||||
label: "Widgets no dashboard",
|
label: "Widgets no dashboard",
|
||||||
colorVar: "var(--data-9)",
|
colorVar: "var(--data-5)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: RiShieldCheckLine,
|
icon: RiShieldCheckLine,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const formatNoteCreatedAt = (
|
|||||||
value: string | Date | null | undefined,
|
value: string | Date | null | undefined,
|
||||||
) => {
|
) => {
|
||||||
const parsed = parseNoteDate(value);
|
const parsed = parseNoteDate(value);
|
||||||
return parsed ? NOTE_CREATED_AT_FORMATTER.format(parsed) : null;
|
return parsed ? NOTE_CREATED_AT_FORMATTER.format(parsed) : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatNoteCreatedAtLong = (
|
export const formatNoteCreatedAtLong = (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
RiHourglass2Line,
|
RiHourglass2Line,
|
||||||
RiWallet3Line,
|
RiWallet3Line,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { buildBillStatusLabel } from "@/features/dashboard/bills-helpers";
|
import { buildBillStatusLabel } from "@/features/dashboard/bills/bills-helpers";
|
||||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { CardContent } from "@/shared/components/ui/card";
|
import { CardContent } from "@/shared/components/ui/card";
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function PayerCard({ payer, onEdit, onRemove }: PayerCardProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{isReadOnly ? (
|
{isReadOnly ? (
|
||||||
<Badge variant="outline" className="text-xs text-warning">
|
<Badge variant="outline" className="text-xs text-primary">
|
||||||
Somente leitura
|
Somente leitura
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ export function PayerDialog({
|
|||||||
) : (
|
) : (
|
||||||
<div className="size-12 rounded-full bg-muted border-2 border-dashed border-muted-foreground/20 flex items-center justify-center hover:scale-110 transition-transform duration-200">
|
<div className="size-12 rounded-full bg-muted border-2 border-dashed border-muted-foreground/20 flex items-center justify-center hover:scale-110 transition-transform duration-200">
|
||||||
{isProcessingImage ? (
|
{isProcessingImage ? (
|
||||||
<span className="text-[10px] text-muted-foreground animate-pulse">
|
<span className="text-xs text-muted-foreground animate-pulse">
|
||||||
...
|
...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
PAYER_STATUS_OPTIONS,
|
PAYER_STATUS_OPTIONS,
|
||||||
} from "@/shared/lib/payers/constants";
|
} from "@/shared/lib/payers/constants";
|
||||||
|
|
||||||
export type PayerData = {
|
type PayerData = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react";
|
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||||
import { formatPercentageChange } from "@/features/reports/utils";
|
import { formatPercentageChange } from "@/features/reports/utils";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -30,13 +30,9 @@ export function CategoryCell({
|
|||||||
|
|
||||||
const absoluteChange = !isFirstMonth ? value - previousValue : null;
|
const absoluteChange = !isFirstMonth ? value - previousValue : null;
|
||||||
|
|
||||||
const isIncrease = percentageChange !== null && percentageChange > 0;
|
|
||||||
const isDecrease = percentageChange !== null && percentageChange < 0;
|
|
||||||
|
|
||||||
// Despesa: aumento é ruim (vermelho), diminuição é bom (verde)
|
// Despesa: aumento é ruim (vermelho), diminuição é bom (verde)
|
||||||
// Receita: aumento é bom (verde), diminuição é ruim (vermelho)
|
// Receita: aumento é bom (verde), diminuição é ruim (vermelho)
|
||||||
const isPositive = categoryType === "receita" ? isIncrease : isDecrease;
|
const positiveTrend = categoryType === "receita" ? "up" : "down";
|
||||||
const isNegative = categoryType === "receita" ? isDecrease : isIncrease;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -44,19 +40,12 @@ export function CategoryCell({
|
|||||||
<div className="flex flex-col items-end gap-0.5 min-h-9 justify-center cursor-default px-4 py-2">
|
<div className="flex flex-col items-end gap-0.5 min-h-9 justify-center cursor-default px-4 py-2">
|
||||||
<span className="font-medium">{formatCurrency(value)}</span>
|
<span className="font-medium">{formatCurrency(value)}</span>
|
||||||
{!isFirstMonth && percentageChange !== null && (
|
{!isFirstMonth && percentageChange !== null && (
|
||||||
<div
|
<PercentageChangeIndicator
|
||||||
className={cn(
|
value={percentageChange}
|
||||||
"flex items-center gap-0.5 text-xs",
|
label={formatPercentageChange(percentageChange)}
|
||||||
isNegative && "text-destructive",
|
positiveTrend={positiveTrend}
|
||||||
isPositive && "text-success",
|
iconClassName="h-3 w-3"
|
||||||
)}
|
/>
|
||||||
>
|
|
||||||
{isIncrease && <RiArrowUpSFill className="h-3 w-3" />}
|
|
||||||
{isDecrease && <RiArrowDownSFill className="h-3 w-3" />}
|
|
||||||
<span className="font-medium">
|
|
||||||
{formatPercentageChange(percentageChange)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -71,8 +60,14 @@ export function CategoryCell({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-medium",
|
"font-medium",
|
||||||
isNegative && "text-destructive",
|
(positiveTrend === "up"
|
||||||
isPositive && "text-success",
|
? absoluteChange !== null && absoluteChange < 0
|
||||||
|
: absoluteChange !== null && absoluteChange > 0) &&
|
||||||
|
"text-destructive",
|
||||||
|
(positiveTrend === "up"
|
||||||
|
? absoluteChange !== null && absoluteChange > 0
|
||||||
|
: absoluteChange !== null && absoluteChange < 0) &&
|
||||||
|
"text-success",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Diferença:{" "}
|
Diferença:{" "}
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ export function AnticipateInstallmentsDialog({
|
|||||||
|
|
||||||
{/* Seção 3: Resumo */}
|
{/* Seção 3: Resumo */}
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<div className="rounded-lg border bg-muted/20 p-3">
|
<div className="rounded-lg border p-3">
|
||||||
<h4 className="text-sm font-semibold mb-2">Resumo</h4>
|
<h4 className="text-sm font-semibold mb-2">Resumo</h4>
|
||||||
<dl className="space-y-1.5 text-sm">
|
<dl className="space-y-1.5 text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function TransactionDetailsDialog({
|
|||||||
|
|
||||||
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
||||||
<div className="min-w-0 space-y-4">
|
<div className="min-w-0 space-y-4">
|
||||||
<section className="rounded-lg border bg-muted/20 p-3">
|
<section className="rounded-lg border p-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function PayerSelectContent({
|
|||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Avatar className="size-5 border border-border/60 bg-background">
|
<Avatar className="size-5 border border-border/60 bg-background">
|
||||||
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
|
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
|
||||||
<AvatarFallback className="text-[10px] font-medium uppercase">
|
<AvatarFallback className="text-xs font-medium uppercase">
|
||||||
{initial}
|
{initial}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function AnticipationCard({
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{anticipation.note && (
|
{anticipation.note && (
|
||||||
<div className="rounded-lg border bg-muted/20 p-3">
|
<div className="rounded-lg border p-3">
|
||||||
<dt className="text-xs font-medium text-muted-foreground">
|
<dt className="text-xs font-medium text-muted-foreground">
|
||||||
Observação
|
Observação
|
||||||
</dt>
|
</dt>
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ function buildColumns({
|
|||||||
<>
|
<>
|
||||||
<Avatar className="size-7">
|
<Avatar className="size-7">
|
||||||
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
|
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
|
||||||
<AvatarFallback className="text-[10px] font-medium uppercase">
|
<AvatarFallback className="text-xs font-medium uppercase">
|
||||||
{initial}
|
{initial}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function LogoPickerTrigger({
|
|||||||
className="object-contain p-0.5"
|
className="object-contain p-0.5"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[10px] text-muted-foreground">Logo</span>
|
<span className="text-xs text-muted-foreground">Logo</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ export function LogoPickerDialog({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="line-clamp-1 text-[10px] leading-tight text-muted-foreground">
|
<span className="line-clamp-1 text-xs leading-tight text-muted-foreground">
|
||||||
{logoLabel}
|
{logoLabel}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ interface LogoProps {
|
|||||||
invertTextOnDark?: boolean;
|
invertTextOnDark?: boolean;
|
||||||
/** Exibe o ícone na cor original, sem filtro preto. Apenas nos variants "full" e "compact" */
|
/** Exibe o ícone na cor original, sem filtro preto. Apenas nos variants "full" e "compact" */
|
||||||
colorIcon?: boolean;
|
colorIcon?: boolean;
|
||||||
|
/** Classes extras aplicadas na imagem do ícone */
|
||||||
|
iconClassName?: string;
|
||||||
|
/** Classes extras aplicadas na imagem do texto */
|
||||||
|
textClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconFilterClass = "brightness-0 saturate-0";
|
const iconFilterClass = "brightness-0 saturate-0";
|
||||||
@@ -17,6 +21,8 @@ export function Logo({
|
|||||||
className,
|
className,
|
||||||
invertTextOnDark = true,
|
invertTextOnDark = true,
|
||||||
colorIcon = false,
|
colorIcon = false,
|
||||||
|
iconClassName,
|
||||||
|
textClassName,
|
||||||
}: LogoProps) {
|
}: LogoProps) {
|
||||||
if (variant === "compact") {
|
if (variant === "compact") {
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +33,11 @@ export function Logo({
|
|||||||
alt="OpenMonetis"
|
alt="OpenMonetis"
|
||||||
fill
|
fill
|
||||||
sizes="32px"
|
sizes="32px"
|
||||||
className={cn("object-contain", !colorIcon && iconFilterClass)}
|
className={cn(
|
||||||
|
"object-contain",
|
||||||
|
!colorIcon && iconFilterClass,
|
||||||
|
iconClassName,
|
||||||
|
)}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +47,11 @@ export function Logo({
|
|||||||
alt="OpenMonetis"
|
alt="OpenMonetis"
|
||||||
fill
|
fill
|
||||||
sizes="110px"
|
sizes="110px"
|
||||||
className={cn("object-contain", invertTextOnDark && "dark:invert")}
|
className={cn(
|
||||||
|
"object-contain",
|
||||||
|
invertTextOnDark && "dark:invert",
|
||||||
|
textClassName,
|
||||||
|
)}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function MonthNavigation() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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-18 z-10 flex w-full flex-row p-4 backdrop-blur-md supports-backdrop-filter:bg-card/60">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<NavigationButton
|
<NavigationButton
|
||||||
direction="left"
|
direction="left"
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ export default function PageDescription({
|
|||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<h1 className="text-2xl font-semibold flex items-center gap-1">
|
<h1 className="text-2xl font-semibold flex items-center gap-1">
|
||||||
<span className="text-primary">{icon}</span>
|
<span className="text-primary">{icon}</span>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
<h2 className="text-sm max-w-2xl text-muted-foreground leading-relaxed mt-1.5">
|
<h2 className="text-sm max-w-2xl text-muted-foreground leading-relaxed">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function TransactionTypeBadge({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
data-kind={normalizedKind ?? "custom"}
|
data-kind={normalizedKind ?? "custom"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 gap-1.5 rounded-full border-transparent px-2 py-0 text-xs font-medium shadow-none",
|
"h-6 gap-1 border-none rounded-md px-2 py-0 text-xs shadow-none",
|
||||||
config?.className ??
|
config?.className ??
|
||||||
"bg-muted/30 text-muted-foreground dark:bg-muted/20",
|
"bg-muted/30 text-muted-foreground dark:bg-muted/20",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const buttonVariants = cva(
|
|||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
navbar:
|
navbar:
|
||||||
"bg-transparent text-black/75 shadow-none hover:bg-black/10 hover:text-black focus-visible:ring-black/20",
|
"bg-transparent text-black/75 shadow-none hover:bg-black/10 hover:text-black focus-visible:ring-black/20 dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:ring-white/20",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function Calendar({
|
|||||||
table: "w-full border-collapse",
|
table: "w-full border-collapse",
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
"text-muted-foreground rounded-md flex-1 font-normal text-xs select-none",
|
||||||
defaultClassNames.weekday,
|
defaultClassNames.weekday,
|
||||||
),
|
),
|
||||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||||
@@ -99,7 +99,7 @@ function Calendar({
|
|||||||
defaultClassNames.week_number_header,
|
defaultClassNames.week_number_header,
|
||||||
),
|
),
|
||||||
week_number: cn(
|
week_number: cn(
|
||||||
"text-[0.8rem] select-none text-muted-foreground",
|
"text-xs select-none text-muted-foreground",
|
||||||
defaultClassNames.week_number,
|
defaultClassNames.week_number,
|
||||||
),
|
),
|
||||||
day: cn(
|
day: cn(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground flex flex-col gap-4 border py-6 rounded-lg hover:border-primary/40",
|
"bg-card text-card-foreground flex flex-col gap-4 border border-border/70 dark:border-border/40 py-6 rounded-lg hover:border-primary/60 transition-colors duration-200",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export type CalendarEvent =
|
|||||||
status: string;
|
status: string;
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
totalDue: number | null;
|
totalDue: number | null;
|
||||||
|
isPaid: boolean;
|
||||||
|
paymentDate: string | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Os valores são CSS variables definidas em globals.css,
|
* Os valores são CSS variables definidas em globals.css,
|
||||||
* com variantes light/dark — sem hardcode de hex fora do tema.
|
* com variantes light/dark — sem hardcode de hex fora do tema.
|
||||||
*/
|
*/
|
||||||
const DATA_PALETTE_SIZE = 10;
|
const DATA_PALETTE_SIZE = 6;
|
||||||
|
|
||||||
/** Array de CSS variables da paleta de dados — usado em gráficos e charts. */
|
/** Array de CSS variables da paleta de dados — usado em gráficos e charts. */
|
||||||
export const CATEGORY_COLORS = Array.from(
|
export const CATEGORY_COLORS = Array.from(
|
||||||
@@ -20,19 +20,18 @@ function hashNameToIndex(name: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retorna a CSS variable de cor para um nome (determinístico via hash).
|
* Cor do ícone — sempre primary para consistência visual.
|
||||||
*/
|
*/
|
||||||
export function getCategoryColorFromName(name: string): string {
|
export function getCategoryColorFromName(_name: string): string {
|
||||||
const n = hashNameToIndex(name) + 1;
|
return "var(--foreground)";
|
||||||
return `var(--data-${n})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retorna o background com transparência usando color-mix.
|
* Background distinto por nome (hash), com 20% de opacidade.
|
||||||
*/
|
*/
|
||||||
export function getCategoryBgColorFromName(name: string): string {
|
export function getCategoryBgColorFromName(name: string): string {
|
||||||
const n = hashNameToIndex(name) + 1;
|
const n = hashNameToIndex(name) + 1;
|
||||||
return `color-mix(in oklch, var(--data-${n}) 14%, transparent)`;
|
return `color-mix(in oklch, var(--data-${n}) 20%, transparent)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,20 +48,3 @@ export function buildInitials(name: string): string {
|
|||||||
const b = parts[1]?.[0] ?? "";
|
const b = parts[1]?.[0] ?? "";
|
||||||
return `${a}${b}`.toUpperCase() || "?";
|
return `${a}${b}`.toUpperCase() || "?";
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- compatibilidade retroativa (para não quebrar callers durante migração) ---
|
|
||||||
|
|
||||||
/** @deprecated Use getCategoryColorFromName */
|
|
||||||
export function getCategoryColor(index: number): string {
|
|
||||||
return `var(--data-${(index % DATA_PALETTE_SIZE) + 1})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated Use getCategoryBgColorFromName */
|
|
||||||
export function getCategoryBgColor(index: number): string {
|
|
||||||
return `color-mix(in oklch, var(--data-${(index % DATA_PALETTE_SIZE) + 1}) 14%, transparent)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated Use buildInitials */
|
|
||||||
export function buildCategoryInitials(value: string): string {
|
|
||||||
return buildInitials(value);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const MONTH_NAMES = [
|
|||||||
"dezembro",
|
"dezembro",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const OPENMONETIS_TIME_ZONE = "America/Sao_Paulo";
|
const OPENMONETIS_TIME_ZONE = "America/Sao_Paulo";
|
||||||
|
|
||||||
type DateOnlyParts = {
|
type DateOnlyParts = {
|
||||||
year: number;
|
year: number;
|
||||||
@@ -200,7 +200,7 @@ export function getTodayDateString(date: Date = new Date()): string {
|
|||||||
/**
|
/**
|
||||||
* Gets a date string in YYYY-MM-DD format for a specific timezone
|
* Gets a date string in YYYY-MM-DD format for a specific timezone
|
||||||
*/
|
*/
|
||||||
export function getDateStringInTimeZone(
|
function getDateStringInTimeZone(
|
||||||
timeZone: string,
|
timeZone: string,
|
||||||
date: Date = new Date(),
|
date: Date = new Date(),
|
||||||
): string {
|
): string {
|
||||||
@@ -215,14 +215,6 @@ export function getBusinessDateString(date: Date = new Date()): string {
|
|||||||
return getDateStringInTimeZone(OPENMONETIS_TIME_ZONE, date);
|
return getDateStringInTimeZone(OPENMONETIS_TIME_ZONE, date);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets today's date as Date object
|
|
||||||
* @returns Date object for today
|
|
||||||
*/
|
|
||||||
export function getTodayDate(date: Date = new Date()): Date {
|
|
||||||
return parseLocalDateString(getTodayDateString(date));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets today's date as Date object using the app business timezone
|
* Gets today's date as Date object using the app business timezone
|
||||||
*/
|
*/
|
||||||
@@ -397,19 +389,6 @@ export function formatDateOnlyLabel(
|
|||||||
return prefix ? `${prefix} ${formatted}` : formatted;
|
return prefix ? `${prefix} ${formatted}` : formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateTimeLabel(
|
|
||||||
value: string | Date | null | undefined,
|
|
||||||
prefix?: string,
|
|
||||||
options?: Intl.DateTimeFormatOptions,
|
|
||||||
): string | null {
|
|
||||||
const formatted = formatDateTime(value, options);
|
|
||||||
if (!formatted) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return prefix ? `${prefix} ${formatted}` : formatted;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compareDateOnly(
|
export function compareDateOnly(
|
||||||
left: string | Date | null | undefined,
|
left: string | Date | null | undefined,
|
||||||
right: string | Date | null | undefined,
|
right: string | Date | null | undefined,
|
||||||
@@ -505,19 +484,7 @@ export function friendlyDate(date: Date): string {
|
|||||||
// TIME-BASED UTILITIES
|
// TIME-BASED UTILITIES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
function getGreetingInTimeZone(
|
||||||
* Gets appropriate greeting based on time of day
|
|
||||||
* @param date - Date to get greeting for (defaults to now)
|
|
||||||
* @returns "Bom dia", "Boa tarde", or "Boa noite"
|
|
||||||
*/
|
|
||||||
export function getGreeting(date: Date = new Date()): string {
|
|
||||||
const hour = date.getHours();
|
|
||||||
if (hour >= 5 && hour < 12) return "Bom dia";
|
|
||||||
if (hour >= 12 && hour < 18) return "Boa tarde";
|
|
||||||
return "Boa noite";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getGreetingInTimeZone(
|
|
||||||
timeZone: string,
|
timeZone: string,
|
||||||
date: Date = new Date(),
|
date: Date = new Date(),
|
||||||
): string {
|
): string {
|
||||||
@@ -531,7 +498,7 @@ export function getBusinessGreeting(date: Date = new Date()): string {
|
|||||||
return getGreetingInTimeZone(OPENMONETIS_TIME_ZONE, date);
|
return getGreetingInTimeZone(OPENMONETIS_TIME_ZONE, date);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatCurrentDateInTimeZone(
|
function formatCurrentDateInTimeZone(
|
||||||
timeZone: string,
|
timeZone: string,
|
||||||
date: Date = new Date(),
|
date: Date = new Date(),
|
||||||
): string {
|
): string {
|
||||||
@@ -550,6 +517,3 @@ export function formatCurrentDateInTimeZone(
|
|||||||
export function formatBusinessCurrentDate(date: Date = new Date()): string {
|
export function formatBusinessCurrentDate(date: Date = new Date()): string {
|
||||||
return formatCurrentDateInTimeZone(OPENMONETIS_TIME_ZONE, date);
|
return formatCurrentDateInTimeZone(OPENMONETIS_TIME_ZONE, date);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export MONTH_NAMES for convenience
|
|
||||||
export { MONTH_NAMES };
|
|
||||||
|
|||||||