initial commit
This commit is contained in:
13
apps/frontend/src/App.tsx
Normal file
13
apps/frontend/src/App.tsx
Normal 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;
|
||||
41
apps/frontend/src/components/layout/Header.tsx
Normal file
41
apps/frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/frontend/src/components/layout/Layout.tsx
Normal file
20
apps/frontend/src/components/layout/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
apps/frontend/src/components/layout/Sidebar.tsx
Normal file
54
apps/frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
67
apps/frontend/src/components/ui/button.tsx
Normal file
67
apps/frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
512
apps/frontend/src/components/ui/gauge.tsx
Normal file
512
apps/frontend/src/components/ui/gauge.tsx
Normal 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,
|
||||
};
|
||||
26
apps/frontend/src/components/ui/separator.tsx
Normal file
26
apps/frontend/src/components/ui/separator.tsx
Normal 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 }
|
||||
147
apps/frontend/src/components/ui/sheet.tsx
Normal file
147
apps/frontend/src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
56
apps/frontend/src/components/ui/tooltip.tsx
Normal file
56
apps/frontend/src/components/ui/tooltip.tsx
Normal 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 }
|
||||
423
apps/frontend/src/features/belo/BeloPage.tsx
Normal file
423
apps/frontend/src/features/belo/BeloPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
apps/frontend/src/features/dashboard/DashboardPage.tsx
Normal file
134
apps/frontend/src/features/dashboard/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/frontend/src/features/expenses/ExpensesPage.tsx
Normal file
8
apps/frontend/src/features/expenses/ExpensesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/frontend/src/features/quotes/QuotesPage.tsx
Normal file
8
apps/frontend/src/features/quotes/QuotesPage.tsx
Normal 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
125
apps/frontend/src/index.css
Normal 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);
|
||||
}
|
||||
82
apps/frontend/src/lib/api.ts
Normal file
82
apps/frontend/src/lib/api.ts
Normal 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}`);
|
||||
}
|
||||
62
apps/frontend/src/lib/compose-refs.ts
Normal file
62
apps/frontend/src/lib/compose-refs.ts
Normal 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 };
|
||||
59
apps/frontend/src/lib/queries.ts
Normal file
59
apps/frontend/src/lib/queries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
51
apps/frontend/src/lib/sse-context.tsx
Normal file
51
apps/frontend/src/lib/sse-context.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/frontend/src/lib/time.ts
Normal file
31
apps/frontend/src/lib/time.ts
Normal 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;
|
||||
}
|
||||
17
apps/frontend/src/lib/useQuoteEvents.ts
Normal file
17
apps/frontend/src/lib/useQuoteEvents.ts
Normal 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]);
|
||||
}
|
||||
6
apps/frontend/src/lib/utils.ts
Normal file
6
apps/frontend/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
25
apps/frontend/src/main.tsx
Normal file
25
apps/frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
12
apps/frontend/src/router.tsx
Normal file
12
apps/frontend/src/router.tsx
Normal 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 };
|
||||
6
apps/frontend/src/routes/__root.tsx
Normal file
6
apps/frontend/src/routes/__root.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createRootRoute } from "@tanstack/react-router";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Layout,
|
||||
});
|
||||
6
apps/frontend/src/routes/expenses.tsx
Normal file
6
apps/frontend/src/routes/expenses.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ExpensesPage } from "@/features/expenses/ExpensesPage";
|
||||
|
||||
export const Route = createFileRoute("/expenses")({
|
||||
component: ExpensesPage,
|
||||
});
|
||||
6
apps/frontend/src/routes/index.tsx
Normal file
6
apps/frontend/src/routes/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { DashboardPage } from "@/features/dashboard/DashboardPage";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: DashboardPage,
|
||||
});
|
||||
6
apps/frontend/src/routes/quotes.belo.tsx
Normal file
6
apps/frontend/src/routes/quotes.belo.tsx
Normal 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,
|
||||
});
|
||||
6
apps/frontend/src/routes/quotes.index.tsx
Normal file
6
apps/frontend/src/routes/quotes.index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { QuotesPage } from "@/features/quotes/QuotesPage";
|
||||
|
||||
export const Route = createFileRoute("/quotes/")({
|
||||
component: QuotesPage,
|
||||
});
|
||||
9
apps/frontend/src/routes/quotes.tsx
Normal file
9
apps/frontend/src/routes/quotes.tsx
Normal 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
1
apps/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user