feat: initial commit

This commit is contained in:
TheRaiwy
2026-05-27 14:52:19 +03:00
commit 6220c854b7
36 changed files with 1301 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Wallet } from 'lucide-react'
import { getPiggyBanks, getCategories, createCategory, createPiggyBank, createTransaction, deletePiggyBank } from './lib/api'
import type { PiggyBank } from './types'
import { Button } from './components/ui/button'
import { Dialog } from './components/ui/dialog'
import { Input } from './components/ui/input'
import { PiggyBankCard } from './components/PiggyBankCard'
import { PiggyBankForm } from './components/PiggyBankForm'
import { AddMoneyDialog } from './components/AddMoneyDialog'
import { TransactionHistory } from './components/TransactionHistory'
export default function App() {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [showCatForm, setShowCatForm] = useState(false)
const [catName, setCatName] = useState('')
const [selectedBank, setSelectedBank] = useState<PiggyBank | null>(null)
const { data: banks = [] } = useQuery({
queryKey: ['piggy-banks'],
queryFn: getPiggyBanks,
})
const { data: categories = [] } = useQuery({
queryKey: ['categories'],
queryFn: getCategories,
})
const createBank = useMutation({
mutationFn: createPiggyBank,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['piggy-banks'] })
setShowForm(false)
},
})
const addCategory = useMutation({
mutationFn: (name: string) => createCategory(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['categories'] })
setShowCatForm(false)
setCatName('')
},
})
const addMoney = useMutation({
mutationFn: ({ bankId, amount, description }: { bankId: number; amount: number; description?: string }) =>
createTransaction(bankId, { amount, description }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['piggy-banks'] })
queryClient.invalidateQueries({ queryKey: ['transactions'] })
},
})
const removeBank = useMutation({
mutationFn: deletePiggyBank,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['piggy-banks'] })
},
})
const totalSaved = banks.reduce((sum, b) => sum + b.current_amount, 0)
const totalTarget = banks.reduce((sum, b) => sum + b.target_amount, 0)
return (
<div className="min-h-screen bg-gray-50">
<div className="mx-auto max-w-4xl px-4 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-600">
<Wallet className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Копилка</h1>
<p className="text-sm text-gray-500">Сервис для накоплений</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowCatForm(true)}>
+ Категория
</Button>
<Button size="sm" onClick={() => setShowForm(true)}>
<Plus className="h-4 w-4 mr-1" />
Копилка
</Button>
</div>
</div>
{/* Summary */}
{banks.length > 0 && (
<div className="mb-6 grid grid-cols-3 gap-4">
<div className="rounded-xl border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500">Копилок</p>
<p className="text-2xl font-bold text-gray-900">{banks.length}</p>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500">Накоплено</p>
<p className="text-2xl font-bold text-green-600">${totalSaved.toFixed(2)}</p>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500">Цель</p>
<p className="text-2xl font-bold text-blue-600">${totalTarget.toFixed(2)}</p>
</div>
</div>
)}
{/* Piggy Banks Grid */}
<div className="grid gap-4 md:grid-cols-2">
{banks.map((bank) => (
<div key={bank.id} className="space-y-3">
<PiggyBankCard
bank={bank}
categories={categories}
onAddMoney={setSelectedBank}
onDelete={(id) => removeBank.mutate(id)}
/>
<TransactionHistory bank={bank} />
</div>
))}
</div>
{banks.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Wallet className="h-16 w-16 text-gray-300 mb-4" />
<h2 className="text-lg font-semibold text-gray-600 mb-2">
Ещё нет ни одной копилки
</h2>
<p className="text-sm text-gray-400 mb-6">
Создайте первую копилку и начните копить!
</p>
<Button onClick={() => setShowForm(true)}>
<Plus className="h-4 w-4 mr-1" />
Создать копилку
</Button>
</div>
)}
{/* Create Piggy Bank Dialog */}
<Dialog open={showForm} onOpenChange={setShowForm} title="Новая копилка">
<PiggyBankForm
categories={categories}
onSubmit={(data) => createBank.mutateAsync(data)}
onCancel={() => setShowForm(false)}
/>
</Dialog>
{/* Add Money Dialog */}
<AddMoneyDialog
bank={selectedBank}
open={!!selectedBank}
onOpenChange={(open) => { if (!open) setSelectedBank(null) }}
onSubmit={(bankId, amount, description) =>
addMoney.mutateAsync({ bankId, amount, description })
}
/>
{/* Create Category Dialog */}
<Dialog open={showCatForm} onOpenChange={setShowCatForm} title="Новая категория">
<form
onSubmit={(e) => {
e.preventDefault()
if (catName.trim()) addCategory.mutate(catName.trim())
}}
className="space-y-4"
>
<Input
label="Название категории"
id="catName"
placeholder="Например: Путешествия"
value={catName}
onChange={(e) => setCatName(e.target.value)}
required
/>
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="secondary" onClick={() => setShowCatForm(false)}>
Отмена
</Button>
<Button type="submit" disabled={!catName.trim()}>
Создать
</Button>
</div>
</form>
</Dialog>
</div>
</div>
)
}
@@ -0,0 +1,80 @@
import { useState } from 'react'
import type { PiggyBank } from '../types'
import { Dialog } from './ui/dialog'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { formatCurrency } from '../lib/utils'
interface AddMoneyDialogProps {
bank: PiggyBank | null
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (bankId: number, amount: number, description?: string) => Promise<void>
}
export function AddMoneyDialog({ bank, open, onOpenChange, onSubmit }: AddMoneyDialogProps) {
const [amount, setAmount] = useState('')
const [description, setDescription] = useState('')
const [loading, setLoading] = useState(false)
if (!bank) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const num = parseFloat(amount)
if (!num || num === 0) return
setLoading(true)
await onSubmit(bank.id, num, description.trim() || undefined)
setAmount('')
setDescription('')
setLoading(false)
onOpenChange(false)
}
const remaining = bank.target_amount - bank.current_amount
return (
<Dialog open={open} onOpenChange={onOpenChange} title={`Пополнить: ${bank.name}`}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="rounded-lg bg-gray-50 p-3 text-sm text-gray-600">
<div className="flex justify-between mb-1">
<span>Накоплено:</span>
<span className="font-medium">{formatCurrency(bank.current_amount, bank.currency)}</span>
</div>
<div className="flex justify-between">
<span>Осталось:</span>
<span className="font-medium">{formatCurrency(remaining, bank.currency)}</span>
</div>
</div>
<Input
label="Сумма"
id="amount"
type="number"
step="0.01"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
<Input
label="Описание (необязательно)"
id="description"
placeholder="Например: Зарплата"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button type="submit" disabled={loading || !amount || parseFloat(amount) === 0}>
{loading ? 'Сохранение...' : 'Добавить'}
</Button>
</div>
</form>
</Dialog>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { PiggyBank, Trash2 } from 'lucide-react'
import type { PiggyBank as PiggyBankType, Category } from '../types'
import { cn, formatCurrency } from '../lib/utils'
import { Progress } from './ui/progress'
import { Button } from './ui/button'
interface PiggyBankCardProps {
bank: PiggyBankType
categories: Category[]
onAddMoney: (bank: PiggyBankType) => void
onDelete: (id: number) => void
}
export function PiggyBankCard({ bank, categories, onAddMoney, onDelete }: PiggyBankCardProps) {
const category = categories.find((c) => c.id === bank.category_id)
const isComplete = bank.progress_percent >= 100
return (
<div className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg',
isComplete ? 'bg-green-100' : 'bg-blue-100',
)}>
<PiggyBank className={cn('h-5 w-5', isComplete ? 'text-green-600' : 'text-blue-600')} />
</div>
<div>
<h3 className="font-semibold text-gray-900">{bank.name}</h3>
{category && <p className="text-xs text-gray-500">{category.name}</p>}
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => onDelete(bank.id)}>
<Trash2 className="h-4 w-4 text-gray-400" />
</Button>
</div>
<Progress value={bank.progress_percent} className="mb-3" />
<div className="flex items-baseline justify-between mb-1">
<span className="text-2xl font-bold text-gray-900">
{formatCurrency(bank.current_amount, bank.currency)}
</span>
<span className="text-sm text-gray-500">
/ {formatCurrency(bank.target_amount, bank.currency)}
</span>
</div>
<div className="flex items-center justify-between">
<span className={cn(
'text-sm font-medium',
isComplete ? 'text-green-600' : 'text-blue-600',
)}>
{isComplete ? 'Цель достигнута! 🎉' : `${bank.progress_percent.toFixed(1)}%`}
</span>
<Button size="sm" onClick={() => onAddMoney(bank)}>
Пополнить
</Button>
</div>
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { useState } from 'react'
import type { Category } from '../types'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { Select } from './ui/select'
interface PiggyBankFormProps {
categories: Category[]
onSubmit: (data: { name: string; target_amount: number; currency: string; category_id: number | null }) => Promise<void>
onCancel: () => void
}
const currencies = [
{ value: 'USD', label: '$ USD' },
{ value: 'EUR', label: '€ EUR' },
{ value: 'RUB', label: '₽ RUB' },
{ value: 'GBP', label: '£ GBP' },
]
export function PiggyBankForm({ categories, onSubmit, onCancel }: PiggyBankFormProps) {
const [name, setName] = useState('')
const [target, setTarget] = useState('')
const [currency, setCurrency] = useState('USD')
const [categoryId, setCategoryId] = useState('none')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !target) return
setLoading(true)
await onSubmit({
name: name.trim(),
target_amount: parseFloat(target),
currency,
category_id: categoryId === 'none' ? null : parseInt(categoryId),
})
setLoading(false)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Название"
id="name"
placeholder="Например: Новая машина"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<Input
label="Цель"
id="target"
type="number"
step="0.01"
min="0.01"
placeholder="1000.00"
value={target}
onChange={(e) => setTarget(e.target.value)}
required
/>
<Select
label="Валюта"
value={currency}
onValueChange={setCurrency}
items={currencies}
/>
<Select
label="Категория"
value={categoryId}
onValueChange={setCategoryId}
items={[
{ value: 'none', label: 'Без категории' },
...categories.map((c) => ({ value: String(c.id), label: c.name })),
]}
/>
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onCancel}>
Отмена
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Создание...' : 'Создать'}
</Button>
</div>
</form>
)
}
@@ -0,0 +1,66 @@
import { useQuery } from '@tanstack/react-query'
import { ArrowDownCircle, ArrowUpCircle, Loader2 } from 'lucide-react'
import { getTransactions } from '../lib/api'
import { cn, formatCurrency, formatDate } from '../lib/utils'
import type { PiggyBank } from '../types'
interface TransactionHistoryProps {
bank: PiggyBank
}
export function TransactionHistory({ bank }: TransactionHistoryProps) {
const { data: transactions, isLoading } = useQuery({
queryKey: ['transactions', bank.id],
queryFn: () => getTransactions(bank.id),
})
if (isLoading) {
return (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
)
}
if (!transactions?.length) {
return (
<div className="py-8 text-center text-sm text-gray-400">
Пока нет операций
</div>
)
}
return (
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-700">История</h4>
<div className="max-h-64 space-y-1 overflow-y-auto">
{transactions.map((tx) => (
<div
key={tx.id}
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm hover:bg-gray-50"
>
<div className="flex items-center gap-2">
{tx.amount > 0 ? (
<ArrowDownCircle className="h-4 w-4 text-green-500" />
) : (
<ArrowUpCircle className="h-4 w-4 text-red-500" />
)}
<div>
<span className="text-gray-900">
{tx.description || (tx.amount > 0 ? 'Пополнение' : 'Снятие')}
</span>
<p className="text-xs text-gray-400">{formatDate(tx.created_at)}</p>
</div>
</div>
<span className={cn(
'font-medium tabular-nums',
tx.amount > 0 ? 'text-green-600' : 'text-red-600',
)}>
{tx.amount > 0 ? '+' : ''}{formatCurrency(tx.amount, bank.currency)}
</span>
</div>
))}
</div>
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cn } from '../../lib/utils'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
}
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-400',
ghost: 'text-gray-600 hover:bg-gray-100 focus-visible:ring-gray-400',
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500',
}
const sizes = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
ref={ref}
className={cn(
'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
variants[variant],
sizes[size],
className,
)}
{...props}
/>
)
},
)
+37
View File
@@ -0,0 +1,37 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '../../lib/utils'
import type { ReactNode } from 'react'
interface DialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
children: ReactNode
}
export function Dialog({ open, onOpenChange, title, children }: DialogProps) {
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-lg',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
)}
>
<div className="flex items-center justify-between mb-4">
<DialogPrimitive.Title className="text-lg font-semibold text-gray-900">
{title}
</DialogPrimitive.Title>
<DialogPrimitive.Close className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600">
<X className="h-5 w-5" />
</DialogPrimitive.Close>
</div>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { forwardRef, type InputHTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, id, ...props }, ref) => {
return (
<div className="space-y-1.5">
{label && (
<label htmlFor={id} className="text-sm font-medium text-gray-700">
{label}
</label>
)}
<input
ref={ref}
id={id}
className={cn(
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50',
error && 'border-red-500 focus:ring-red-500 focus:border-red-500',
className,
)}
{...props}
/>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
)
},
)
+25
View File
@@ -0,0 +1,25 @@
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '../../lib/utils'
interface ProgressProps {
value: number
className?: string
indicatorClassName?: string
}
export function Progress({ value, className, indicatorClassName }: ProgressProps) {
return (
<ProgressPrimitive.Root
className={cn('relative h-3 w-full overflow-hidden rounded-full bg-gray-200', className)}
>
<ProgressPrimitive.Indicator
className={cn(
'h-full w-full flex-1 rounded-full transition-all duration-500',
value >= 100 ? 'bg-green-500' : 'bg-blue-600',
indicatorClassName,
)}
style={{ transform: `translateX(-${100 - Math.min(value, 100)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
+54
View File
@@ -0,0 +1,54 @@
import * as SelectPrimitive from '@radix-ui/react-select'
import { ChevronDown } from 'lucide-react'
import { cn } from '../../lib/utils'
import type { ReactNode } from 'react'
interface SelectItem {
value: string
label: string
}
interface SelectProps {
value: string
onValueChange: (value: string) => void
items: SelectItem[]
placeholder?: string
label?: string
className?: string
}
export function Select({ value, onValueChange, items, placeholder = 'Select...', label, className }: SelectProps) {
return (
<div className="space-y-1.5">
{label && <label className="text-sm font-medium text-gray-700">{label}</label>}
<SelectPrimitive.Root value={value} onValueChange={onValueChange}>
<SelectPrimitive.Trigger
className={cn(
'flex h-10 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
className,
)}
>
<SelectPrimitive.Value placeholder={placeholder} />
<SelectPrimitive.Icon>
<ChevronDown className="h-4 w-4 text-gray-400" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content className="z-50 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
<SelectPrimitive.Viewport className="p-1">
{items.map((item) => (
<SelectPrimitive.Item
key={item.value}
value={item.value}
className="relative flex cursor-default select-none items-center rounded-md px-3 py-2 text-sm text-gray-900 data-[highlighted]:bg-blue-50 data-[highlighted]:text-blue-900 data-[state=checked]:font-medium"
>
<SelectPrimitive.ItemText>{item.label}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8fafc;
}
+35
View File
@@ -0,0 +1,35 @@
import type { Category, PiggyBank, PiggyBankCreate, Transaction, TransactionCreate } from '../types'
const BASE = '/api'
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${url}`, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
})
if (!res.ok) {
const body = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(body.detail || 'Request failed')
}
if (res.status === 204) return undefined as T
return res.json()
}
// Categories
export const getCategories = () => request<Category[]>('/categories')
export const createCategory = (name: string) =>
request<Category>('/categories', { method: 'POST', body: JSON.stringify({ name }) })
// Piggy Banks
export const getPiggyBanks = () => request<PiggyBank[]>('/piggy-banks')
export const getPiggyBank = (id: number) => request<PiggyBank>(`/piggy-banks/${id}`)
export const createPiggyBank = (data: PiggyBankCreate) =>
request<PiggyBank>('/piggy-banks', { method: 'POST', body: JSON.stringify(data) })
export const deletePiggyBank = (id: number) =>
request<void>(`/piggy-banks/${id}`, { method: 'DELETE' })
// Transactions
export const createTransaction = (bankId: number, data: TransactionCreate) =>
request<Transaction>(`/piggy-banks/${bankId}/transactions`, { method: 'POST', body: JSON.stringify(data) })
export const getTransactions = (bankId: number) =>
request<Transaction[]>(`/piggy-banks/${bankId}/transactions`)
+20
View File
@@ -0,0 +1,20 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatCurrency(amount: number, currency: string) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
}
export function formatDate(date: string) {
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(date))
}
+15
View File
@@ -0,0 +1,15 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)
+37
View File
@@ -0,0 +1,37 @@
export interface Category {
id: number
name: string
created_at: string
}
export interface PiggyBank {
id: number
name: string
target_amount: number
current_amount: number
currency: string
category_id: number | null
progress_percent: number
created_at: string
updated_at: string
}
export interface Transaction {
id: number
piggy_bank_id: number
amount: number
description: string | null
created_at: string
}
export interface PiggyBankCreate {
name: string
target_amount: number
currency: string
category_id?: number | null
}
export interface TransactionCreate {
amount: number
description?: string
}