initial commit

This commit is contained in:
Jose Selesan
2026-05-28 14:33:16 -03:00
commit 7bc3d9f898
211 changed files with 161253 additions and 0 deletions

13
apps/frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { RouterProvider } from "@tanstack/react-router";
import { router } from "@/router";
import { SseProvider } from "@/lib/sse-context";
function App() {
return (
<SseProvider>
<RouterProvider router={router} />
</SseProvider>
);
}
export default App;

View File

@@ -0,0 +1,41 @@
import { Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useSseStatus } from "@/lib/sse-context";
import { cn } from "@/lib/utils";
interface HeaderProps {
onMenuClick: () => void;
}
export function Header({ onMenuClick }: HeaderProps) {
const sseStatus = useSseStatus();
return (
<header className="flex h-14 items-center gap-4 border-b px-4">
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={onMenuClick}
>
<Menu className="size-5" />
</Button>
<div className="flex-1" />
<div className="flex items-center gap-1.5">
<span
className={cn(
"size-2 rounded-full",
sseStatus === "connected" && "bg-emerald-500",
sseStatus === "connecting" && "bg-amber-500 animate-pulse",
sseStatus === "disconnected" && "bg-red-500",
)}
/>
<span className="text-xs text-muted-foreground">
{sseStatus === "connected" && "Live"}
{sseStatus === "connecting" && "Conectando..."}
{sseStatus === "disconnected" && "Desconectado"}
</span>
</div>
</header>
);
}

View File

@@ -0,0 +1,20 @@
import { useState } from "react";
import { Outlet } from "@tanstack/react-router";
import { Header } from "./Header";
import { Sidebar } from "./Sidebar";
export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen">
<Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex flex-1 flex-col">
<Header onMenuClick={() => setSidebarOpen(true)} />
<main className="flex-1 p-6">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { Link } from "@tanstack/react-router";
import { FileText, LayoutDashboard, TrendingUp, Wallet } from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
{ to: "/", label: "Panel", icon: LayoutDashboard },
{ to: "/expenses", label: "Gastos", icon: Wallet },
{ to: "/quotes", label: "Cotizaciones", icon: FileText },
{ to: "/quotes/belo", label: "BELO", icon: TrendingUp },
];
interface SidebarProps {
open: boolean;
onClose: () => void;
}
export function Sidebar({ open, onClose }: SidebarProps) {
return (
<>
{open && (
<div
className="fixed inset-0 z-40 bg-black/20 md:hidden"
onClick={onClose}
/>
)}
<aside
className={cn(
"fixed inset-y-0 left-0 z-50 flex w-60 flex-col border-r bg-sidebar transition-transform duration-200 md:relative md:translate-x-0",
open ? "translate-x-0" : "-translate-x-full",
)}
>
<div className="flex h-14 items-center border-b px-4">
<span className="text-lg font-semibold text-sidebar-foreground">
Personal Admin
</span>
</div>
<nav className="flex-1 space-y-1 p-3">
{navItems.map((item) => (
<Link
key={item.to}
to={item.to}
activeProps={{ className: "bg-sidebar-accent text-sidebar-accent-foreground font-medium" }}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
onClick={onClose}
>
<item.icon className="size-4" />
{item.label}
</Link>
))}
</nav>
</aside>
</>
);
}

View File

@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,512 @@
"use client";
import { Slot as SlotPrimitive } from "radix-ui";
import * as React from "react";
import { cn } from "@/lib/utils";
const GAUGE_NAME = "Gauge";
const INDICATOR_NAME = "GaugeIndicator";
const TRACK_NAME = "GaugeTrack";
const RANGE_NAME = "GaugeRange";
const VALUE_TEXT_NAME = "GaugeValueText";
const LABEL_NAME = "GaugeLabel";
const DEFAULT_MAX = 100;
const DEFAULT_START_ANGLE = 0;
const DEFAULT_END_ANGLE = 360;
type GaugeState = "indeterminate" | "complete" | "loading";
interface DivProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
interface PathProps extends React.ComponentProps<"path"> {}
function getGaugeState(
value: number | undefined | null,
maxValue: number,
): GaugeState {
return value == null
? "indeterminate"
: value === maxValue
? "complete"
: "loading";
}
function getIsValidNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function getIsValidMaxNumber(max: unknown): max is number {
return getIsValidNumber(max) && max > 0;
}
function getIsValidValueNumber(
value: unknown,
min: number,
max: number,
): value is number {
return getIsValidNumber(value) && value <= max && value >= min;
}
function getDefaultValueText(value: number, min: number, max: number): string {
const percentage = max === min ? 100 : ((value - min) / (max - min)) * 100;
return Math.round(percentage).toString();
}
function getInvalidValueError(
propValue: string,
componentName: string,
): string {
return `Invalid prop \`value\` of value \`${propValue}\` supplied to \`${componentName}\`. The \`value\` prop must be a number between \`min\` and \`max\` (inclusive), or \`null\`/\`undefined\` for indeterminate state. The value will be clamped to the valid range.`;
}
function getInvalidMaxError(propValue: string, componentName: string): string {
return `Invalid prop \`max\` of value \`${propValue}\` supplied to \`${componentName}\`. Only numbers greater than 0 are valid. Defaulting to ${DEFAULT_MAX}.`;
}
function getNormalizedAngle(angle: number) {
return ((angle % 360) + 360) % 360;
}
function polarToCartesian(
centerX: number,
centerY: number,
radius: number,
angleInDegrees: number,
) {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians),
};
}
function describeArc(
x: number,
y: number,
radius: number,
startAngle: number,
endAngle: number,
) {
const angleDiff = endAngle - startAngle;
// For full circles (360 degrees), draw as two semi-circles
if (Math.abs(angleDiff) >= 360) {
const start = polarToCartesian(x, y, radius, startAngle);
const mid = polarToCartesian(x, y, radius, startAngle + 180);
return [
"M",
start.x,
start.y,
"A",
radius,
radius,
0,
0,
1,
mid.x,
mid.y,
"A",
radius,
radius,
0,
0,
1,
start.x,
start.y,
].join(" ");
}
const start = polarToCartesian(x, y, radius, startAngle);
const end = polarToCartesian(x, y, radius, endAngle);
const largeArcFlag = angleDiff <= 180 ? "0" : "1";
return [
"M",
start.x,
start.y,
"A",
radius,
radius,
0,
largeArcFlag,
1,
end.x,
end.y,
].join(" ");
}
interface GaugeContextValue {
value: number | null;
valueText: string | undefined;
max: number;
min: number;
state: GaugeState;
radius: number;
thickness: number;
size: number;
center: number;
percentage: number | null;
startAngle: number;
endAngle: number;
arcLength: number;
arcCenterY: number;
valueTextId?: string;
labelId?: string;
}
const GaugeContext = React.createContext<GaugeContextValue | null>(null);
function useGaugeContext(consumerName: string) {
const context = React.useContext(GaugeContext);
if (!context) {
throw new Error(
`\`${consumerName}\` must be used within \`${GAUGE_NAME}\``,
);
}
return context;
}
interface GaugeProps extends DivProps {
value?: number | null | undefined;
getValueText?(value: number, min: number, max: number): string;
min?: number;
max?: number;
size?: number;
thickness?: number;
startAngle?: number;
endAngle?: number;
}
function Gauge(props: GaugeProps) {
const {
value: valueProp = null,
getValueText = getDefaultValueText,
min: minProp = 0,
max: maxProp,
size = 120,
thickness = 8,
startAngle = DEFAULT_START_ANGLE,
endAngle = DEFAULT_END_ANGLE,
asChild,
className,
...rootProps
} = props;
if ((maxProp || maxProp === 0) && !getIsValidMaxNumber(maxProp)) {
if (process.env.NODE_ENV !== "production") {
console.error(getInvalidMaxError(`${maxProp}`, GAUGE_NAME));
}
}
const rawMax = getIsValidMaxNumber(maxProp) ? maxProp : DEFAULT_MAX;
const min = getIsValidNumber(minProp) ? minProp : 0;
const max = rawMax <= min ? min + 1 : rawMax;
if (process.env.NODE_ENV !== "production" && thickness >= size) {
console.warn(
`Gauge: thickness (${thickness}) should be less than size (${size}) for proper rendering.`,
);
}
if (valueProp !== null && !getIsValidValueNumber(valueProp, min, max)) {
if (process.env.NODE_ENV !== "production") {
console.error(getInvalidValueError(`${valueProp}`, GAUGE_NAME));
}
}
const value = getIsValidValueNumber(valueProp, min, max)
? valueProp
: getIsValidNumber(valueProp) && valueProp > max
? max
: getIsValidNumber(valueProp) && valueProp < min
? min
: null;
const valueText = getIsValidNumber(value)
? getValueText(value, min, max)
: undefined;
const state = getGaugeState(value, max);
const radius = Math.max(0, (size - thickness) / 2);
const center = size / 2;
const angleDiff = Math.abs(endAngle - startAngle);
const arcLength = (Math.min(angleDiff, 360) / 360) * (2 * Math.PI * radius);
const percentage = getIsValidNumber(value)
? max === min
? 1
: (value - min) / (max - min)
: null;
// Calculate the visual center Y of the arc for text positioning
// For full circles, use geometric center. For partial arcs, calculate based on bounding box
const angleDiffDeg = Math.abs(endAngle - startAngle);
const isFullCircle = angleDiffDeg >= 360;
let arcCenterY = center;
if (!isFullCircle) {
const startRad = (startAngle * Math.PI) / 180;
const endRad = (endAngle * Math.PI) / 180;
const startY = center - radius * Math.cos(startRad);
const endY = center - radius * Math.cos(endRad);
let minY = Math.min(startY, endY);
let maxY = Math.max(startY, endY);
const normStart = getNormalizedAngle(startAngle);
const normEnd = getNormalizedAngle(endAngle);
const includesTop =
normStart > normEnd
? normStart <= 270 || normEnd >= 270
: normStart <= 270 && normEnd >= 270;
const includesBottom =
normStart > normEnd
? normStart <= 90 || normEnd >= 90
: normStart <= 90 && normEnd >= 90;
if (includesTop) minY = Math.min(minY, center - radius);
if (includesBottom) maxY = Math.max(maxY, center + radius);
arcCenterY = (minY + maxY) / 2;
}
const labelId = React.useId();
const valueTextId = React.useId();
const contextValue = React.useMemo<GaugeContextValue>(
() => ({
value,
valueText,
max,
min,
state,
radius,
thickness,
size,
center,
percentage,
startAngle,
endAngle,
arcLength,
arcCenterY,
valueTextId,
labelId,
}),
[
value,
valueText,
max,
min,
state,
radius,
thickness,
size,
center,
percentage,
startAngle,
endAngle,
arcLength,
arcCenterY,
valueTextId,
labelId,
],
);
const RootPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<GaugeContext.Provider value={contextValue}>
<RootPrimitive
role="meter"
aria-describedby={valueText ? valueTextId : undefined}
aria-labelledby={labelId}
aria-valuemax={max}
aria-valuemin={min}
aria-valuenow={getIsValidNumber(value) ? value : undefined}
aria-valuetext={valueText}
data-state={state}
data-value={value ?? undefined}
data-max={max}
data-min={min}
data-percentage={percentage}
{...rootProps}
className={cn(
"relative inline-flex w-fit flex-col items-center justify-center",
className,
)}
/>
</GaugeContext.Provider>
);
}
function GaugeIndicator(props: React.ComponentProps<"svg">) {
const { className, ...indicatorProps } = props;
const { size, state, value, max, min, percentage } =
useGaugeContext(INDICATOR_NAME);
return (
<svg
aria-hidden="true"
focusable="false"
viewBox={`0 0 ${size} ${size}`}
data-state={state}
data-value={value ?? undefined}
data-max={max}
data-min={min}
data-percentage={percentage}
width={size}
height={size}
{...indicatorProps}
className={cn("transform", className)}
/>
);
}
function GaugeTrack(props: PathProps) {
const { className, ...trackProps } = props;
const { center, radius, startAngle, endAngle, thickness, state } =
useGaugeContext(TRACK_NAME);
const pathData = describeArc(center, center, radius, startAngle, endAngle);
return (
<path
data-state={state}
d={pathData}
fill="none"
stroke="currentColor"
strokeWidth={thickness}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
{...trackProps}
className={cn("text-muted-foreground/20", className)}
/>
);
}
function GaugeRange(props: PathProps) {
const { className, ...rangeProps } = props;
const {
center,
radius,
startAngle,
endAngle,
value,
max,
min,
state,
thickness,
arcLength,
percentage,
} = useGaugeContext(RANGE_NAME);
const pathData = describeArc(center, center, radius, startAngle, endAngle);
const strokeDasharray = arcLength;
const strokeDashoffset =
state === "indeterminate"
? 0
: percentage !== null
? arcLength - percentage * arcLength
: arcLength;
return (
<path
data-state={state}
data-value={value ?? undefined}
data-max={max}
data-min={min}
d={pathData}
fill="none"
stroke="currentColor"
strokeWidth={thickness}
strokeLinecap="round"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
vectorEffect="non-scaling-stroke"
{...rangeProps}
className={cn(
"text-primary transition-[stroke-dashoffset] duration-700 ease-out",
className,
)}
/>
);
}
function GaugeValueText(props: DivProps) {
const { asChild, className, children, style, ...valueTextProps } = props;
const { valueTextId, state, arcCenterY, valueText } =
useGaugeContext(VALUE_TEXT_NAME);
const ValueTextPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<ValueTextPrimitive
id={valueTextId}
data-state={state}
{...valueTextProps}
style={{
top: `${arcCenterY}px`,
...style,
}}
className={cn(
"absolute right-0 left-0 flex -translate-y-1/2 items-center justify-center font-semibold text-2xl",
className,
)}
>
{children ?? valueText}
</ValueTextPrimitive>
);
}
function GaugeLabel(props: DivProps) {
const { asChild, className, ...labelProps } = props;
const { labelId, state } = useGaugeContext(LABEL_NAME);
const LabelPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<LabelPrimitive
id={labelId}
data-state={state}
{...labelProps}
className={cn(
"mt-2 font-medium text-muted-foreground text-sm",
className,
)}
/>
);
}
function GaugeCombined(props: GaugeProps) {
return (
<Gauge {...props}>
<GaugeIndicator>
<GaugeTrack />
<GaugeRange />
</GaugeIndicator>
<GaugeValueText />
</Gauge>
);
}
export {
Gauge,
GaugeCombined,
GaugeIndicator,
GaugeLabel,
type GaugeProps,
GaugeRange,
GaugeTrack,
GaugeValueText,
};

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 4,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-w-sm rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,423 @@
import { useMemo, useState } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from "recharts";
import {
Gauge,
GaugeIndicator,
GaugeTrack,
GaugeRange,
GaugeValueText,
} from "@/components/ui/gauge";
import { useQuotes, useQuoteHistory, useDailyMinMax, useDailyQuotes, useFetchQuotes } from "@/lib/queries";
import { RelativeTime } from "@/lib/time";
import { cn } from "@/lib/utils";
import { RefreshCw } from "lucide-react";
const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function formatDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
}
function formatShortDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("es-AR", {
day: "2-digit",
month: "2-digit",
});
}
function getGaugeColor(percentage: number | null): string {
if (percentage === null) return "text-muted-foreground";
if (percentage >= 0.8) return "text-emerald-500";
if (percentage >= 0.5) return "text-yellow-500";
return "text-red-500";
}
function StatCard({
label,
buy,
sell,
buyDate,
sellDate,
}: {
label: string;
buy: string | number;
sell: string | number;
buyDate?: string;
sellDate?: string;
}) {
return (
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">{label}</p>
<div className="space-y-1">
<p className="text-sm">
Compra:{" "}
<span className="text-lg font-bold">
${priceFormatter.format(Number(buy))}
</span>
{buyDate && (
<span className="text-[10px] text-muted-foreground ml-1.5">
{formatShortDate(buyDate)}
</span>
)}
</p>
<p className="text-sm">
Venta:{" "}
<span className="text-lg font-bold">
${priceFormatter.format(Number(sell))}
</span>
{sellDate && (
<span className="text-[10px] text-muted-foreground ml-1.5">
{formatShortDate(sellDate)}
</span>
)}
</p>
</div>
</div>
);
}
type Period = "day" | "month";
export function BeloPage() {
const [period, setPeriod] = useState<Period>("day");
const now = new Date();
const todayStr = formatDate(now);
const firstOfMonth = formatDate(new Date(now.getFullYear(), now.getMonth(), 1));
const { data: quotes, isLoading: quotesLoading } = useQuotes();
const { mutate: doFetchQuotes, isPending: fetchPending } = useFetchQuotes();
const { data: dailyMinMax, isLoading: minMaxLoading } = useDailyMinMax(
"BELO",
todayStr,
todayStr,
);
const { data: monthlyMinMax } = useDailyMinMax(
"BELO",
firstOfMonth,
todayStr,
);
const { data: history, isLoading: historyLoading } = useQuoteHistory(
"BELO",
todayStr,
todayStr,
);
const { data: daily, isLoading: dailyLoading } = useDailyQuotes(
"BELO",
firstOfMonth,
todayStr,
);
const belo = quotes?.find((q) => q.type === "BELO");
const maxData = dailyMinMax?.[0];
const currentBuy = belo ? Number(belo.buy) : 0;
const currentSell = belo ? Number(belo.sell) : 0;
const minBuy = maxData ? Number(maxData.minBuy) : 0;
const maxBuy = maxData ? Number(maxData.maxBuy) : 0;
const minSell = maxData ? Number(maxData.minSell) : 0;
const maxSell = maxData ? Number(maxData.maxSell) : 0;
const gaugeValue = currentBuy > 0 && maxBuy > minBuy ? Math.min(currentBuy, maxBuy) : null;
const gaugePercentage = useMemo(() => {
if (gaugeValue !== null && maxBuy > minBuy) {
return (gaugeValue - minBuy) / (maxBuy - minBuy);
}
return null;
}, [gaugeValue, minBuy, maxBuy]);
const chartData = useMemo(() => {
if (period === "day") {
if (!history) return [];
return history.map((h) => ({
time: formatTime(h.timeStamp),
buy: Number(h.buy),
sell: Number(h.sell),
}));
}
if (!daily) return [];
return daily.map((d) => ({
time: formatShortDate(d.date),
buy: d.buy,
sell: d.sell,
}));
}, [history, daily, period]);
const yDomain = useMemo(() => {
if (chartData.length === 0) return [0, 0];
let min = Infinity;
let max = -Infinity;
for (const d of chartData) {
if (d.buy < min) min = d.buy;
if (d.sell < min) min = d.sell;
if (d.buy > max) max = d.buy;
if (d.sell > max) max = d.sell;
}
const range = max - min;
return [min - range * 0.1, max + range * 0.1];
}, [chartData]);
const maxBuyValue = useMemo(() => {
if (chartData.length === 0) return 0;
let max = -Infinity;
for (const d of chartData) {
if (d.buy > max) max = d.buy;
}
return max;
}, [chartData]);
const maxSellValue = useMemo(() => {
if (chartData.length === 0) return 0;
let max = -Infinity;
for (const d of chartData) {
if (d.sell > max) max = d.sell;
}
return max;
}, [chartData]);
const monthlyMax = useMemo(() => {
if (!monthlyMinMax || monthlyMinMax.length === 0) return null;
let maxBuyEntry = monthlyMinMax[0];
let maxSellEntry = monthlyMinMax[0];
for (const d of monthlyMinMax) {
if (d.maxBuy > maxBuyEntry.maxBuy) maxBuyEntry = d;
if (d.maxSell > maxSellEntry.maxSell) maxSellEntry = d;
}
return {
maxBuy: maxBuyEntry.maxBuy,
maxBuyDate: maxBuyEntry.date,
maxSell: maxSellEntry.maxSell,
maxSellDate: maxSellEntry.date,
};
}, [monthlyMinMax]);
const isFetching = quotesLoading || fetchPending;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">BELO</h1>
<div className="flex items-center gap-3">
{belo && (
<span className="text-xs text-muted-foreground">
Últ. actualización: <RelativeTime date={belo.timeStamp} />
</span>
)}
<button
type="button"
disabled={isFetching}
onClick={() => doFetchQuotes()}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
Actualizar
</button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-5">
<StatCard
label="Precio actual"
buy={currentBuy}
sell={currentSell}
/>
<StatCard
label="Mínimo del día"
buy={minBuy}
sell={minSell}
/>
<StatCard
label="Máximo del día"
buy={maxBuy}
sell={maxSell}
/>
<StatCard
label="Máximo del mes"
buy={monthlyMax?.maxBuy ?? 0}
sell={monthlyMax?.maxSell ?? 0}
buyDate={monthlyMax?.maxBuyDate}
sellDate={monthlyMax?.maxSellDate}
/>
<div className="rounded-lg border p-4 flex flex-col items-center justify-center">
<p className="text-xs text-muted-foreground mb-0.5">Avance</p>
<Gauge
value={gaugeValue}
min={minBuy}
max={maxBuy}
size={80}
thickness={6}
startAngle={0}
endAngle={360}
getValueText={(v, m, mx) => {
const pct = mx === m ? 100 : Math.round(((v - m) / (mx - m)) * 100);
return `${pct}%`;
}}
>
<GaugeIndicator>
<GaugeTrack />
<GaugeRange className={getGaugeColor(gaugePercentage)} />
</GaugeIndicator>
<GaugeValueText className={cn(getGaugeColor(gaugePercentage), "text-sm font-semibold")} />
</Gauge>
</div>
</div>
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium">Evolución del precio</h2>
<div className="flex items-center gap-1 rounded-lg border p-0.5">
<button
type="button"
onClick={() => setPeriod("day")}
className={`px-3 py-1 text-xs rounded-md transition-colors cursor-pointer ${
period === "day"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Día
</button>
<button
type="button"
onClick={() => setPeriod("month")}
className={`px-3 py-1 text-xs rounded-md transition-colors cursor-pointer ${
period === "month"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Mes
</button>
</div>
</div>
{(period === "day" ? historyLoading : dailyLoading) ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
Cargando...
</div>
) : chartData.length === 0 ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
Sin datos para este período
</div>
) : (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="time"
tick={{ fontSize: 11 }}
className="text-muted-foreground"
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 11 }}
className="text-muted-foreground"
tickFormatter={(v: number) => `$${priceFormatter.format(v)}`}
width={80}
domain={yDomain}
/>
<Tooltip
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any) => [
`$${priceFormatter.format(Number(value))}`,
]}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
labelFormatter={(label: any) =>
period === "day" ? `Hora: ${label}` : `Fecha: ${label}`
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius)",
fontSize: 13,
}}
/>
<ReferenceLine
y={maxBuyValue}
stroke="var(--chart-1)"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: `Máx compra: $${priceFormatter.format(maxBuyValue)}`,
position: "right",
fontSize: 11,
fill: "var(--chart-1)",
}}
/>
<ReferenceLine
y={maxSellValue}
stroke="var(--chart-2)"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: `Máx venta: $${priceFormatter.format(maxSellValue)}`,
position: "right",
fontSize: 11,
fill: "var(--chart-2)",
}}
/>
<Line
type="monotone"
dataKey="buy"
name="Compra"
stroke="var(--chart-1)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="sell"
name="Venta"
stroke="var(--chart-2)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
<div className="flex items-center justify-center gap-4 mt-3">
<div className="flex items-center gap-1.5">
<span className="size-2.5 rounded-full" style={{ backgroundColor: "var(--chart-1)" }} />
<span className="text-xs text-muted-foreground">Compra</span>
</div>
<div className="flex items-center gap-1.5">
<span className="size-2.5 rounded-full" style={{ backgroundColor: "var(--chart-2)" }} />
<span className="text-xs text-muted-foreground">Venta</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
import { RelativeTime } from "@/lib/time";
import type { Quote } from "@/lib/api";
import { useQuotes, useFetchQuotes } from "@/lib/queries";
import { useNavigate } from "@tanstack/react-router";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; title: string; to?: string; variant?: "default" | "minimal" }) {
const navigate = useNavigate();
const diff = quote ? Number(quote.difference) : 0;
const pct = quote ? Number(quote.percentage) : 0;
const isUp = diff > 0;
const isDown = diff < 0;
return (
<div
className="rounded-lg border p-4 cursor-pointer"
onClick={() => to && navigate({ to })}
onKeyDown={(e) => { if (e.key === "Enter" && to) navigate({ to }); }}
role={to ? "button" : undefined}
tabIndex={to ? 0 : undefined}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<p className="text-sm text-muted-foreground">{title}</p>
{quote && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="cursor-pointer inline-flex">
{isUp ? (
<TrendingUp className="h-4 w-4 text-emerald-500" />
) : isDown ? (
<TrendingDown className="h-4 w-4 text-red-500" />
) : (
<Minus className="h-4 w-4 text-muted-foreground/50" />
)}
</TooltipTrigger>
<TooltipContent>
<p>
{isUp
? `Subió $${priceFormatter.format(diff)} (${pct.toFixed(2)}%)`
: isDown
? `Bajó $${priceFormatter.format(Math.abs(diff))} (${pct.toFixed(2)}%)`
: "Sin cambios"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{quote && (
<span className="text-xs text-muted-foreground">
<RelativeTime date={quote.timeStamp} />
</span>
)}
</div>
{quote ? (
variant === "minimal" ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="cursor-default">
<p className="text-2xl font-bold tabular-nums text-left">${priceFormatter.format(Number(quote.buy))}</p>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Compra: ${priceFormatter.format(Number(quote.buy))}</p>
<p>Venta: ${priceFormatter.format(Number(quote.sell))}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<div className="space-y-1">
<p className="text-sm">
Compra: <span className="text-lg font-bold">${priceFormatter.format(Number(quote.buy))}</span>
</p>
<p className="text-sm">
Venta: <span className="text-lg font-bold">${priceFormatter.format(Number(quote.sell))}</span>
</p>
</div>
)
) : (
<p className="text-sm text-muted-foreground">Sin datos</p>
)}
</div>
);
}
export function DashboardPage() {
const { data: quotes, isLoading } = useQuotes();
const { mutate: doFetchQuotes, isPending } = useFetchQuotes();
const belo = quotes?.find((q) => q.type === "BELO");
const blue = quotes?.find((q) => q.type === "BLUE");
const isFetching = isLoading || isPending;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Panel</h1>
<button
type="button"
disabled={isFetching}
onClick={() => doFetchQuotes()}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
Actualizar
</button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total ingresos</p>
<p className="text-2xl font-bold">$0.00</p>
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Gastos del mes</p>
<p className="text-2xl font-bold">$0.00</p>
</div>
<QuoteCard quote={belo} title="BELO" to="/quotes/belo" variant="minimal" />
<QuoteCard quote={blue} title="BLUE" variant="minimal" />
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
export function ExpensesPage() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Gastos</h1>
<p className="text-muted-foreground">Administrá tus gastos acá.</p>
</div>
);
}

View File

@@ -0,0 +1,8 @@
export function QuotesPage() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Cotizaciones</h1>
<p className="text-muted-foreground">Creá y administrá tus cotizaciones acá.</p>
</div>
);
}

125
apps/frontend/src/index.css Normal file
View File

@@ -0,0 +1,125 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: var(--font-family-sans);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.55 0.2 260);
--chart-2: oklch(0.65 0.22 50);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--font-family-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.65 0.2 260);
--chart-2: oklch(0.75 0.22 50);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-family-sans);
}

View File

@@ -0,0 +1,82 @@
export type Quote = {
id: number;
timeStamp: string;
buy: string;
sell: string;
type: "BELO" | "BLUE" | "BNA";
difference: string;
percentage: string;
previousDayDifference: string;
previousDayPercentage: string;
};
export type FetchResult = {
type: string;
status: string;
error?: string;
};
export type HistoryQuote = {
id: number;
timeStamp: string;
buy: string;
sell: string;
type: "BELO" | "BLUE" | "BNA";
};
export type DailyMinMax = {
date: string;
minBuy: number;
maxBuy: number;
minSell: number;
maxSell: number;
};
export type DailyQuote = {
date: string;
buy: number;
sell: number;
};
async function fetcher<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
}
return res.json();
}
export function getQuotes(): Promise<Quote[]> {
return fetcher<Quote[]>("/api/quotes");
}
export function fetchQuotes(): Promise<FetchResult[]> {
return fetcher<FetchResult[]>("/api/quotes/fetch");
}
export function getQuoteHistory(
type: string,
startDate: string,
endDate: string,
): Promise<HistoryQuote[]> {
const params = new URLSearchParams({ startDate, endDate });
return fetcher<HistoryQuote[]>(`/api/quotes/${type}/history?${params}`);
}
export function getDailyMinMax(
type: string,
startDate: string,
endDate: string,
): Promise<DailyMinMax[]> {
const params = new URLSearchParams({ startDate, endDate });
return fetcher<DailyMinMax[]>(`/api/quotes/${type}/min-max?${params}`);
}
export function getDailyQuotes(
type: string,
startDate: string,
endDate: string,
): Promise<DailyQuote[]> {
const params = new URLSearchParams({ startDate, endDate });
return fetcher<DailyQuote[]>(`/api/quotes/${type}/daily?${params}`);
}

View File

@@ -0,0 +1,62 @@
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };

View File

@@ -0,0 +1,59 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getQuotes, fetchQuotes, getQuoteHistory, getDailyMinMax, getDailyQuotes } from "./api";
export const quoteKeys = {
all: ["quotes"] as const,
history: (type: string, startDate: string, endDate: string) =>
["quotes", type, "history", startDate, endDate] as const,
minMax: (type: string, startDate: string, endDate: string) =>
["quotes", type, "minMax", startDate, endDate] as const,
daily: (type: string, startDate: string, endDate: string) =>
["quotes", type, "daily", startDate, endDate] as const,
};
export function useQuotes() {
return useQuery({
queryKey: quoteKeys.all,
queryFn: getQuotes,
staleTime: 30_000,
refetchInterval: 60_000,
});
}
export function useFetchQuotes() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: fetchQuotes,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: quoteKeys.all });
},
});
}
export function useQuoteHistory(type: string, startDate: string, endDate: string) {
return useQuery({
queryKey: quoteKeys.history(type, startDate, endDate),
queryFn: () => getQuoteHistory(type, startDate, endDate),
staleTime: 30_000,
refetchInterval: 60_000,
});
}
export function useDailyMinMax(type: string, startDate: string, endDate: string) {
return useQuery({
queryKey: quoteKeys.minMax(type, startDate, endDate),
queryFn: () => getDailyMinMax(type, startDate, endDate),
staleTime: 30_000,
refetchInterval: 60_000,
});
}
export function useDailyQuotes(type: string, startDate: string, endDate: string) {
return useQuery({
queryKey: quoteKeys.daily(type, startDate, endDate),
queryFn: () => getDailyQuotes(type, startDate, endDate),
staleTime: 30_000,
refetchInterval: 60_000,
});
}

View File

@@ -0,0 +1,51 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { quoteKeys } from "./queries";
type SseStatus = "connecting" | "connected" | "disconnected";
const SseContext = createContext<SseStatus>("connecting");
export function useSseStatus() {
return useContext(SseContext);
}
export function SseProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [status, setStatus] = useState<SseStatus>("connecting");
useEffect(() => {
const MAX_RETRIES = 10;
let retryCount = 0;
let eventSource = new EventSource("/api/quotes/events");
eventSource.addEventListener("quotes-updated", () => {
queryClient.invalidateQueries({ queryKey: quoteKeys.all });
});
eventSource.onopen = () => {
retryCount = 0;
setStatus("connected");
};
eventSource.onerror = () => {
setStatus("connecting");
retryCount++;
if (retryCount > MAX_RETRIES) {
eventSource.close();
setStatus("disconnected");
}
};
return () => {
eventSource.close();
};
}, [queryClient]);
return (
<SseContext.Provider value={status}>
{children}
</SseContext.Provider>
);
}

View File

@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
export function formatRelativeTime(date: Date | string): string {
const now = new Date();
const then = new Date(date);
const diffMs = now.getTime() - then.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
if (diffMinutes < 1) return "Ahora";
if (diffMinutes < 60) return `Hace ${diffMinutes} min`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `Hace ${diffHours}h`;
const diffDays = Math.floor(diffHours / 24);
return `Hace ${diffDays}d`;
}
export function RelativeTime({ date }: { date: Date | string }) {
const [label, setLabel] = useState(() => formatRelativeTime(date));
useEffect(() => {
setLabel(formatRelativeTime(date));
const interval = setInterval(() => {
setLabel(formatRelativeTime(date));
}, 30_000);
return () => clearInterval(interval);
}, [date]);
return label;
}

View File

@@ -0,0 +1,17 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { quoteKeys } from "./queries";
export function useQuoteEvents() {
const queryClient = useQueryClient();
useEffect(() => {
const eventSource = new EventSource("/api/quotes/events");
eventSource.addEventListener("quotes-updated", () => {
queryClient.invalidateQueries({ queryKey: quoteKeys.all });
});
return () => eventSource.close();
}, [queryClient]);
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,25 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 2,
refetchOnWindowFocus: true,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,12 @@
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
export { router };

View File

@@ -0,0 +1,6 @@
import { createRootRoute } from "@tanstack/react-router";
import { Layout } from "@/components/layout/Layout";
export const Route = createRootRoute({
component: Layout,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { ExpensesPage } from "@/features/expenses/ExpensesPage";
export const Route = createFileRoute("/expenses")({
component: ExpensesPage,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { DashboardPage } from "@/features/dashboard/DashboardPage";
export const Route = createFileRoute("/")({
component: DashboardPage,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { BeloPage } from "@/features/belo/BeloPage";
export const Route = createFileRoute("/quotes/belo")({
component: BeloPage,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { QuotesPage } from "@/features/quotes/QuotesPage";
export const Route = createFileRoute("/quotes/")({
component: QuotesPage,
});

View File

@@ -0,0 +1,9 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/quotes")({
component: QuotesLayout,
});
function QuotesLayout() {
return <Outlet />;
}

1
apps/frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />