feat: initial commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.DS_Store
|
||||
*.db
|
||||
backend/data/
|
||||
.env
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user