From 6220c854b70b74e516cb9c94127a81fda287a2bf Mon Sep 17 00:00:00 2001 From: TheRaiwy Date: Wed, 27 May 2026 14:52:19 +0300 Subject: [PATCH] feat: initial commit --- .gitignore | 9 + backend/Dockerfile | 12 ++ backend/app/__init__.py | 0 backend/app/database.py | 27 +++ backend/app/main.py | 24 +++ backend/app/models.py | 42 ++++ backend/app/routers/__init__.py | 0 backend/app/routers/piggy_banks.py | 157 +++++++++++++++ backend/app/schemas.py | 58 ++++++ backend/requirements.txt | 6 + docker-compose.yml | 27 +++ frontend/Dockerfile | 13 ++ frontend/index.html | 12 ++ frontend/nginx.conf | 17 ++ frontend/package.json | 33 +++ frontend/postcss.config.js | 6 + frontend/src/App.tsx | 190 ++++++++++++++++++ frontend/src/components/AddMoneyDialog.tsx | 80 ++++++++ frontend/src/components/PiggyBankCard.tsx | 62 ++++++ frontend/src/components/PiggyBankForm.tsx | 86 ++++++++ .../src/components/TransactionHistory.tsx | 66 ++++++ frontend/src/components/ui/button.tsx | 40 ++++ frontend/src/components/ui/dialog.tsx | 37 ++++ frontend/src/components/ui/input.tsx | 32 +++ frontend/src/components/ui/progress.tsx | 25 +++ frontend/src/components/ui/select.tsx | 54 +++++ frontend/src/index.css | 8 + frontend/src/lib/api.ts | 35 ++++ frontend/src/lib/utils.ts | 20 ++ frontend/src/main.tsx | 15 ++ frontend/src/types.ts | 37 ++++ frontend/tailwind.config.js | 11 + frontend/tsconfig.app.json | 21 ++ frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 19 ++ frontend/vite.config.ts | 13 ++ 36 files changed, 1301 insertions(+) create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/piggy_banks.py create mode 100644 backend/app/schemas.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/AddMoneyDialog.tsx create mode 100644 frontend/src/components/PiggyBankCard.tsx create mode 100644 frontend/src/components/PiggyBankForm.tsx create mode 100644 frontend/src/components/TransactionHistory.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/progress.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6661867 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +__pycache__/ +*.pyc +*.pyo +.DS_Store +*.db +backend/data/ +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..385ccd3 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..3dbef52 --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..8f4a695 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..3dcb740 --- /dev/null +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/piggy_banks.py b/backend/app/routers/piggy_banks.py new file mode 100644 index 0000000..45cfa2d --- /dev/null +++ b/backend/app/routers/piggy_banks.py @@ -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() diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..5740984 --- /dev/null +++ b/backend/app/schemas.py @@ -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} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9a606fd --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1d193f3 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..0a08638 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ca9d29a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Копилка + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..554f682 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..978baee --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2aa9356 --- /dev/null +++ b/frontend/src/App.tsx @@ -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(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 ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Копилка

+

Сервис для накоплений

+
+
+
+ + +
+
+ + {/* Summary */} + {banks.length > 0 && ( +
+
+

Копилок

+

{banks.length}

+
+
+

Накоплено

+

${totalSaved.toFixed(2)}

+
+
+

Цель

+

${totalTarget.toFixed(2)}

+
+
+ )} + + {/* Piggy Banks Grid */} +
+ {banks.map((bank) => ( +
+ removeBank.mutate(id)} + /> + +
+ ))} +
+ + {banks.length === 0 && ( +
+ +

+ Ещё нет ни одной копилки +

+

+ Создайте первую копилку и начните копить! +

+ +
+ )} + + {/* Create Piggy Bank Dialog */} + + createBank.mutateAsync(data)} + onCancel={() => setShowForm(false)} + /> + + + {/* Add Money Dialog */} + { if (!open) setSelectedBank(null) }} + onSubmit={(bankId, amount, description) => + addMoney.mutateAsync({ bankId, amount, description }) + } + /> + + {/* Create Category Dialog */} + +
{ + e.preventDefault() + if (catName.trim()) addCategory.mutate(catName.trim()) + }} + className="space-y-4" + > + setCatName(e.target.value)} + required + /> +
+ + +
+
+
+
+
+ ) +} diff --git a/frontend/src/components/AddMoneyDialog.tsx b/frontend/src/components/AddMoneyDialog.tsx new file mode 100644 index 0000000..17c646d --- /dev/null +++ b/frontend/src/components/AddMoneyDialog.tsx @@ -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 +} + +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 ( + +
+
+
+ Накоплено: + {formatCurrency(bank.current_amount, bank.currency)} +
+
+ Осталось: + {formatCurrency(remaining, bank.currency)} +
+
+ + setAmount(e.target.value)} + required + /> + + setDescription(e.target.value)} + /> + +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/PiggyBankCard.tsx b/frontend/src/components/PiggyBankCard.tsx new file mode 100644 index 0000000..99b7e02 --- /dev/null +++ b/frontend/src/components/PiggyBankCard.tsx @@ -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 ( +
+
+
+
+ +
+
+

{bank.name}

+ {category &&

{category.name}

} +
+
+ +
+ + + +
+ + {formatCurrency(bank.current_amount, bank.currency)} + + + / {formatCurrency(bank.target_amount, bank.currency)} + +
+ +
+ + {isComplete ? 'Цель достигнута! 🎉' : `${bank.progress_percent.toFixed(1)}%`} + + +
+
+ ) +} diff --git a/frontend/src/components/PiggyBankForm.tsx b/frontend/src/components/PiggyBankForm.tsx new file mode 100644 index 0000000..478768e --- /dev/null +++ b/frontend/src/components/PiggyBankForm.tsx @@ -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 + 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 ( +
+ setName(e.target.value)} + required + /> + setTarget(e.target.value)} + required + /> + ({ value: String(c.id), label: c.name })), + ]} + /> +
+ + +
+
+ ) +} diff --git a/frontend/src/components/TransactionHistory.tsx b/frontend/src/components/TransactionHistory.tsx new file mode 100644 index 0000000..07851a4 --- /dev/null +++ b/frontend/src/components/TransactionHistory.tsx @@ -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 ( +
+ +
+ ) + } + + if (!transactions?.length) { + return ( +
+ Пока нет операций +
+ ) + } + + return ( +
+

История

+
+ {transactions.map((tx) => ( +
+
+ {tx.amount > 0 ? ( + + ) : ( + + )} +
+ + {tx.description || (tx.amount > 0 ? 'Пополнение' : 'Снятие')} + +

{formatDate(tx.created_at)}

+
+
+ 0 ? 'text-green-600' : 'text-red-600', + )}> + {tx.amount > 0 ? '+' : ''}{formatCurrency(tx.amount, bank.currency)} + +
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..a2c5ef6 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -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 { + 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( + ({ className, variant = 'primary', size = 'md', asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + }, +) diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..c55d23c --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -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 ( + + + + +
+ + {title} + + + + +
+ {children} +
+
+
+ ) +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..844c280 --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,32 @@ +import { forwardRef, type InputHTMLAttributes } from 'react' +import { cn } from '../../lib/utils' + +interface InputProps extends InputHTMLAttributes { + label?: string + error?: string +} + +export const Input = forwardRef( + ({ className, label, error, id, ...props }, ref) => { + return ( +
+ {label && ( + + )} + + {error &&

{error}

} +
+ ) + }, +) diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000..0f7c434 --- /dev/null +++ b/frontend/src/components/ui/progress.tsx @@ -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 ( + + = 100 ? 'bg-green-500' : 'bg-blue-600', + indicatorClassName, + )} + style={{ transform: `translateX(-${100 - Math.min(value, 100)}%)` }} + /> + + ) +} diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..1936891 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -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 ( +
+ {label && } + + + + + + + + + + + {items.map((item) => ( + + {item.label} + + ))} + + + + +
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..22f52c2 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,8 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f8fafc; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..76e1f2a --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,35 @@ +import type { Category, PiggyBank, PiggyBankCreate, Transaction, TransactionCreate } from '../types' + +const BASE = '/api' + +async function request(url: string, options?: RequestInit): Promise { + 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('/categories') +export const createCategory = (name: string) => + request('/categories', { method: 'POST', body: JSON.stringify({ name }) }) + +// Piggy Banks +export const getPiggyBanks = () => request('/piggy-banks') +export const getPiggyBank = (id: number) => request(`/piggy-banks/${id}`) +export const createPiggyBank = (data: PiggyBankCreate) => + request('/piggy-banks', { method: 'POST', body: JSON.stringify(data) }) +export const deletePiggyBank = (id: number) => + request(`/piggy-banks/${id}`, { method: 'DELETE' }) + +// Transactions +export const createTransaction = (bankId: number, data: TransactionCreate) => + request(`/piggy-banks/${bankId}/transactions`, { method: 'POST', body: JSON.stringify(data) }) +export const getTransactions = (bankId: number) => + request(`/piggy-banks/${bankId}/transactions`) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..bfbc817 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -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)) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..e29f74a --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + , +) diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..97672d7 --- /dev/null +++ b/frontend/src/types.ts @@ -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 +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..dca8ba0 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..75e2ff7 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -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"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..9d1753c --- /dev/null +++ b/frontend/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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..8279d17 --- /dev/null +++ b/frontend/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', + }, + }, +})