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
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
__pycache__/
*.pyc
*.pyo
.DS_Store
*.db
backend/data/
.env
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
+27
View File
@@ -0,0 +1,27 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
os.makedirs(DATA_DIR, exist_ok=True)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{os.path.join(DATA_DIR, 'kopilka.db')}"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+24
View File
@@ -0,0 +1,24 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .database import engine, Base
from .routers import piggy_banks
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Kopilka API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(piggy_banks.router)
@app.get("/api/health")
def health():
return {"status": "ok"}
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime, timezone
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime, Text
from sqlalchemy.orm import relationship
from .database import Base
class Category(Base):
__tablename__ = "categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
piggy_banks = relationship("PiggyBank", back_populates="category")
class PiggyBank(Base):
__tablename__ = "piggy_banks"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
target_amount = Column(Float, nullable=False)
current_amount = Column(Float, default=0.0, nullable=False)
currency = Column(String, default="USD", nullable=False)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
category = relationship("Category", back_populates="piggy_banks")
transactions = relationship("Transaction", back_populates="piggy_bank", cascade="all, delete-orphan")
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=True, index=True)
piggy_bank_id = Column(Integer, ForeignKey("piggy_banks.id"), nullable=False)
amount = Column(Float, nullable=False)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
piggy_bank = relationship("PiggyBank", back_populates="transactions")
View File
+157
View File
@@ -0,0 +1,157 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from .. import models, schemas
router = APIRouter(prefix="/api", tags=["piggy-banks"])
# --- Categories ---
@router.get("/categories", response_model=List[schemas.CategoryResponse])
def list_categories(db: Session = Depends(get_db)):
return db.query(models.Category).order_by(models.Category.name).all()
@router.post("/categories", response_model=schemas.CategoryResponse, status_code=status.HTTP_201_CREATED)
def create_category(payload: schemas.CategoryCreate, db: Session = Depends(get_db)):
existing = db.query(models.Category).filter(models.Category.name == payload.name).first()
if existing:
raise HTTPException(status_code=400, detail="Category already exists")
category = models.Category(name=payload.name)
db.add(category)
db.commit()
db.refresh(category)
return category
# --- Piggy Banks ---
@router.get("/piggy-banks", response_model=List[schemas.PiggyBankResponse])
def list_piggy_banks(db: Session = Depends(get_db)):
banks = db.query(models.PiggyBank).order_by(models.PiggyBank.created_at.desc()).all()
result = []
for bank in banks:
progress = round((bank.current_amount / bank.target_amount) * 100, 2) if bank.target_amount > 0 else 0
result.append(schemas.PiggyBankResponse(
id=bank.id,
name=bank.name,
target_amount=bank.target_amount,
current_amount=bank.current_amount,
currency=bank.currency,
category_id=bank.category_id,
progress_percent=progress,
created_at=bank.created_at,
updated_at=bank.updated_at,
))
return result
@router.get("/piggy-banks/{bank_id}", response_model=schemas.PiggyBankResponse)
def get_piggy_bank(bank_id: int, db: Session = Depends(get_db)):
bank = db.query(models.PiggyBank).filter(models.PiggyBank.id == bank_id).first()
if not bank:
raise HTTPException(status_code=404, detail="Piggy bank not found")
progress = round((bank.current_amount / bank.target_amount) * 100, 2) if bank.target_amount > 0 else 0
return schemas.PiggyBankResponse(
id=bank.id,
name=bank.name,
target_amount=bank.target_amount,
current_amount=bank.current_amount,
currency=bank.currency,
category_id=bank.category_id,
progress_percent=progress,
created_at=bank.created_at,
updated_at=bank.updated_at,
)
@router.post("/piggy-banks", response_model=schemas.PiggyBankResponse, status_code=status.HTTP_201_CREATED)
def create_piggy_bank(payload: schemas.PiggyBankCreate, db: Session = Depends(get_db)):
if payload.category_id:
cat = db.query(models.Category).filter(models.Category.id == payload.category_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
bank = models.PiggyBank(**payload.model_dump())
db.add(bank)
db.commit()
db.refresh(bank)
progress = round((bank.current_amount / bank.target_amount) * 100, 2) if bank.target_amount > 0 else 0
return schemas.PiggyBankResponse(
id=bank.id,
name=bank.name,
target_amount=bank.target_amount,
current_amount=bank.current_amount,
currency=bank.currency,
category_id=bank.category_id,
progress_percent=progress,
created_at=bank.created_at,
updated_at=bank.updated_at,
)
@router.patch("/piggy-banks/{bank_id}", response_model=schemas.PiggyBankResponse)
def update_piggy_bank(bank_id: int, payload: schemas.PiggyBankUpdate, db: Session = Depends(get_db)):
bank = db.query(models.PiggyBank).filter(models.PiggyBank.id == bank_id).first()
if not bank:
raise HTTPException(status_code=404, detail="Piggy bank not found")
update_data = payload.model_dump(exclude_unset=True)
if "category_id" in update_data and update_data["category_id"] is not None:
cat = db.query(models.Category).filter(models.Category.id == update_data["category_id"]).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
for key, value in update_data.items():
setattr(bank, key, value)
db.commit()
db.refresh(bank)
progress = round((bank.current_amount / bank.target_amount) * 100, 2) if bank.target_amount > 0 else 0
return schemas.PiggyBankResponse(
id=bank.id,
name=bank.name,
target_amount=bank.target_amount,
current_amount=bank.current_amount,
currency=bank.currency,
category_id=bank.category_id,
progress_percent=progress,
created_at=bank.created_at,
updated_at=bank.updated_at,
)
@router.delete("/piggy-banks/{bank_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_piggy_bank(bank_id: int, db: Session = Depends(get_db)):
bank = db.query(models.PiggyBank).filter(models.PiggyBank.id == bank_id).first()
if not bank:
raise HTTPException(status_code=404, detail="Piggy bank not found")
db.delete(bank)
db.commit()
# --- Transactions ---
@router.post("/piggy-banks/{bank_id}/transactions", response_model=schemas.TransactionResponse, status_code=status.HTTP_201_CREATED)
def create_transaction(bank_id: int, payload: schemas.TransactionCreate, db: Session = Depends(get_db)):
bank = db.query(models.PiggyBank).filter(models.PiggyBank.id == bank_id).first()
if not bank:
raise HTTPException(status_code=404, detail="Piggy bank not found")
if bank.current_amount + payload.amount < 0:
raise HTTPException(status_code=400, detail="Insufficient funds")
bank.current_amount += payload.amount
transaction = models.Transaction(
piggy_bank_id=bank_id,
amount=payload.amount,
description=payload.description,
)
db.add(transaction)
db.commit()
db.refresh(transaction)
return transaction
@router.get("/piggy-banks/{bank_id}/transactions", response_model=List[schemas.TransactionResponse])
def list_transactions(bank_id: int, db: Session = Depends(get_db)):
bank = db.query(models.PiggyBank).filter(models.PiggyBank.id == bank_id).first()
if not bank:
raise HTTPException(status_code=404, detail="Piggy bank not found")
return db.query(models.Transaction).filter(
models.Transaction.piggy_bank_id == bank_id
).order_by(models.Transaction.created_at.desc()).all()
+58
View File
@@ -0,0 +1,58 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# --- Category ---
class CategoryBase(BaseModel):
name: str
class CategoryCreate(CategoryBase):
pass
class CategoryResponse(CategoryBase):
id: int
created_at: datetime
model_config = {"from_attributes": True}
# --- PiggyBank ---
class PiggyBankBase(BaseModel):
name: str
target_amount: float = Field(gt=0)
currency: str = "USD"
category_id: Optional[int] = None
class PiggyBankCreate(PiggyBankBase):
pass
class PiggyBankUpdate(BaseModel):
name: Optional[str] = None
target_amount: Optional[float] = Field(default=None, gt=0)
currency: Optional[str] = None
category_id: Optional[int] = None
class PiggyBankResponse(PiggyBankBase):
id: int
current_amount: float
progress_percent: float
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# --- Transaction ---
class TransactionCreate(BaseModel):
amount: float = Field(ne=0)
description: Optional[str] = None
class TransactionResponse(BaseModel):
id: int
piggy_bank_id: int
amount: float
description: Optional[str]
created_at: datetime
model_config = {"from_attributes": True}
+6
View File
@@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
pydantic==2.10.3
alembic==1.14.0
python-dotenv==1.0.1
+27
View File
@@ -0,0 +1,27 @@
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
- PYTHONUNBUFFERED=1
volumes:
- ./backend/data:/app/data
- ./backend/app:/app/app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
interval: 10s
timeout: 5s
retries: 5
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
depends_on:
backend:
condition: service_healthy
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Копилка</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "kopilka-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slot": "^1.1.1",
"@tanstack/react-query": "^5.62.0",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"vite": "^6.0.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+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
}
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 3000,
proxy: {
'/api': 'http://backend:8000',
},
},
})