feat: initial commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f8fafc;
|
||||
}
|
||||
@@ -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`)
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user