Version : 2025.11.22
This commit is contained in:
182
.gitignore
vendored
Normal file
182
.gitignore
vendored
Normal file
@@ -0,0 +1,182 @@
|
||||
# ==========================================
|
||||
# Node.js / Frontend (React + Vite)
|
||||
# ==========================================
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
dist-ssr/
|
||||
build/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Development
|
||||
*.local
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
.vite/
|
||||
.eslintcache
|
||||
.parcel-cache/
|
||||
|
||||
# ==========================================
|
||||
# Python / Backend (FastAPI)
|
||||
# ==========================================
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
.pytest_cache/
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# Celery
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# ==========================================
|
||||
# Database
|
||||
# ==========================================
|
||||
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.sql
|
||||
|
||||
# ==========================================
|
||||
# Environment Variables
|
||||
# ==========================================
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# ==========================================
|
||||
# IDE / Editors
|
||||
# ==========================================
|
||||
|
||||
# VSCode
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
|
||||
# JetBrains IDEs (PyCharm, WebStorm, IntelliJ)
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
|
||||
# Sublime Text
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# ==========================================
|
||||
# Operating System
|
||||
# ==========================================
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Linux
|
||||
*~
|
||||
.directory
|
||||
.Trash-*
|
||||
|
||||
# ==========================================
|
||||
# Miscellaneous
|
||||
# ==========================================
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Archives
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# IDE specific
|
||||
.history/
|
||||
67
App.tsx
Normal file
67
App.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Association } from './types';
|
||||
import { api } from './api';
|
||||
import LoginScreen from './components/LoginScreen';
|
||||
import Dashboard from './components/Dashboard';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [activeAssociation, setActiveAssociation] = useState<Association | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAssociation = async () => {
|
||||
try {
|
||||
const storedAssociationId = localStorage.getItem('abacus-active-association-id');
|
||||
if (storedAssociationId) {
|
||||
const association = await api.getAssociation(storedAssociationId);
|
||||
setActiveAssociation(association);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load association", error);
|
||||
localStorage.removeItem('abacus-active-association-id');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadAssociation();
|
||||
}, []);
|
||||
|
||||
const handleLogin = (association: Association) => {
|
||||
localStorage.setItem('abacus-active-association-id', association.id);
|
||||
setActiveAssociation(association);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('abacus-active-association-id');
|
||||
setActiveAssociation(null);
|
||||
};
|
||||
|
||||
const updateAssociation = async (updatedAssociation: Association) => {
|
||||
setActiveAssociation(updatedAssociation);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div className="text-2xl font-semibold text-gray-700">Loading Abacus...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 text-gray-800 font-sans">
|
||||
{activeAssociation ? (
|
||||
<Dashboard
|
||||
association={activeAssociation}
|
||||
onLogout={handleLogout}
|
||||
onUpdateAssociation={updateAssociation}
|
||||
/>
|
||||
) : (
|
||||
<LoginScreen onLogin={handleLogin} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
368
README.md
Normal file
368
README.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# 🧮 Abacus
|
||||
|
||||
> **Application de comptabilité simplifiée pour associations**
|
||||
|
||||
Abacus est une application web moderne conçue spécifiquement pour la gestion comptable des associations. Elle offre une interface intuitive et élégante permettant de gérer facilement vos balances financières, d'enregistrer vos opérations et de visualiser vos données comptables en temps réel.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table des matières
|
||||
|
||||
- [Présentation](#-présentation)
|
||||
- [Fonctionnalités](#-fonctionnalités)
|
||||
- [Technologies utilisées](#️-technologies-utilisées)
|
||||
- [Installation](#-installation)
|
||||
- [Lancement de l'application](#-lancement-de-lapplication)
|
||||
- [Utilisation](#-utilisation)
|
||||
- [Commandes CLI](#️-commandes-cli)
|
||||
- [Architecture](#-architecture)
|
||||
- [Licence](#-licence)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Présentation
|
||||
|
||||
**Abacus** est née du besoin de simplifier la comptabilité associative. Au lieu de jongler avec des tableurs complexes, Abacus propose une solution web tout-en-un qui centralise :
|
||||
|
||||
- ✅ **La gestion de vos balances** (compte principal, caisse, épargne, etc.)
|
||||
- ✅ **L'enregistrement de vos opérations** (recettes et dépenses)
|
||||
- ✅ **La visualisation de vos données** avec des graphiques interactifs
|
||||
- ✅ **L'export PDF** de vos rapports financiers
|
||||
- ✅ **La sécurité** avec un système d'authentification utilisateur
|
||||
- ✅ **Le multi-tenant** pour gérer plusieurs associations sur une même instance
|
||||
|
||||
L'application a été pensée pour être **minimaliste**, **rapide** et **accessible**, même pour les utilisateurs non techniques.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Fonctionnalités
|
||||
|
||||
### 🏠 Dashboard interactif
|
||||
- Vue d'ensemble de votre santé financière
|
||||
- Affichage en carrousel de toutes vos balances
|
||||
- Graphiques d'évolution des revenus et dépenses
|
||||
- Tableaux détaillés de toutes les opérations
|
||||
|
||||
### 💰 Gestion des balances
|
||||
- Création et suppression de balances multiples
|
||||
- Modification du nom et du montant initial
|
||||
- Suivi du solde actuel en temps réel
|
||||
- Organisation par cartes visuelles
|
||||
|
||||
### 📊 Gestion des opérations
|
||||
- Enregistrement de recettes et dépenses
|
||||
- Catégorisation des opérations (salaires, achats, dons, etc.)
|
||||
- Ajout de descriptions détaillées
|
||||
- Menu contextuel pour éditer ou supprimer
|
||||
- Modal de confirmation pour les suppressions
|
||||
|
||||
### 📈 Visualisations
|
||||
- **Graphiques** : Évolution temporelle avec Recharts
|
||||
- **Tableaux** : Liste détaillée et filtrable de toutes les opérations
|
||||
- **Carrousel** : Navigation fluide entre vos différentes balances
|
||||
|
||||
### 📄 Export PDF
|
||||
- Génération de rapports PDF professionnels
|
||||
- Consolidation de toutes les opérations par période
|
||||
- Une page par balance avec design soigné
|
||||
- Export direct depuis le dashboard
|
||||
|
||||
### 🔐 Sécurité
|
||||
- Authentification par utilisateur (login/password)
|
||||
- Hachage sécurisé des mots de passe (bcrypt)
|
||||
- Isolation multi-tenant des données
|
||||
- Sessions sécurisées
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Technologies utilisées
|
||||
|
||||
### **Frontend**
|
||||
| Technologie | Version | Description |
|
||||
|-------------|---------|-------------|
|
||||
| [React](https://react.dev/) | 19.2.0 | Framework UI moderne et performant |
|
||||
| [TypeScript](https://www.typescriptlang.org/) | 5.8.2 | JavaScript typé pour plus de robustesse |
|
||||
| [Vite](https://vitejs.dev/) | 6.2.0 | Build tool ultra-rapide |
|
||||
| [Tailwind CSS](https://tailwindcss.com/) | - | Framework CSS utilitaire |
|
||||
| [Recharts](https://recharts.org/) | 3.3.0 | Bibliothèque de graphiques React |
|
||||
| [React PDF](https://react-pdf.org/) | 4.3.1 | Génération de documents PDF |
|
||||
| [date-fns](https://date-fns.org/) | 4.1.0 | Manipulation de dates |
|
||||
|
||||
### **Backend**
|
||||
| Technologie | Description |
|
||||
|-------------|-------------|
|
||||
| [FastAPI](https://fastapi.tiangolo.com/) | Framework Python moderne et performant |
|
||||
| [SQLModel](https://sqlmodel.tiangolo.com/) | ORM basé sur SQLAlchemy et Pydantic |
|
||||
| [MySQL](https://www.mysql.com/) | Base de données relationnelle |
|
||||
| [PyMySQL](https://pymysql.readthedocs.io/) | Connecteur MySQL pour Python |
|
||||
| [Uvicorn](https://www.uvicorn.org/) | Serveur ASGI haute performance |
|
||||
| [Typer](https://typer.tiangolo.com/) | CLI moderne pour Python |
|
||||
| [Rich](https://github.com/Textualize/rich) | Rendu de texte enrichi dans le terminal |
|
||||
| [Passlib](https://passlib.readthedocs.io/) | Hachage sécurisé de mots de passe (bcrypt) |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Prérequis
|
||||
|
||||
Avant de commencer, assurez-vous d'avoir installé :
|
||||
- **Node.js** (v16 ou supérieur) - [Télécharger](https://nodejs.org/)
|
||||
- **Python** (v3.8 ou supérieur) - [Télécharger](https://www.python.org/)
|
||||
- **MySQL** (v5.7 ou supérieur) - [Télécharger](https://www.mysql.com/)
|
||||
|
||||
### 1️⃣ Cloner le projet
|
||||
|
||||
```bash
|
||||
git clone <url-du-repo>
|
||||
cd abacus
|
||||
```
|
||||
|
||||
### 2️⃣ Configuration du Backend
|
||||
|
||||
#### Installer les dépendances Python
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
> **Note** : Il est recommandé d'utiliser un environnement virtuel :
|
||||
> ```bash
|
||||
> python -m venv venv
|
||||
> # Windows
|
||||
> venv\Scripts\activate
|
||||
> # Linux/Mac
|
||||
> source venv/bin/activate
|
||||
> ```
|
||||
|
||||
#### Configurer la base de données
|
||||
|
||||
1. **Créer une base de données MySQL** :
|
||||
```sql
|
||||
CREATE DATABASE abacus CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
2. **Configurer les variables d'environnement** :
|
||||
- Copier le fichier d'exemple :
|
||||
```bash
|
||||
# Linux/Mac
|
||||
cp .env.example .env
|
||||
# Windows
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
- Éditer le fichier `.env` et renseigner vos informations MySQL :
|
||||
```env
|
||||
DATABASE_URL=mysql+pymysql://utilisateur:motdepasse@localhost:3306/abacus
|
||||
```
|
||||
> Remplacez `utilisateur`, `motdepasse` et `localhost:3306` par vos paramètres MySQL.
|
||||
|
||||
3. **Initialiser les tables** :
|
||||
```bash
|
||||
python cli.py setup-db
|
||||
```
|
||||
|
||||
### 3️⃣ Configuration du Frontend
|
||||
|
||||
#### Installer les dépendances Node.js
|
||||
|
||||
Depuis la racine du projet :
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Lancement de l'application
|
||||
|
||||
### Mode développement
|
||||
|
||||
Pour développer avec rechargement automatique, lancez le backend et le frontend dans **deux terminaux séparés** :
|
||||
|
||||
#### Terminal 1 : Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python cli.py start
|
||||
```
|
||||
|
||||
✅ Le backend sera accessible sur **http://localhost:8000**
|
||||
- API REST : `http://localhost:8000/api`
|
||||
- Documentation Swagger : `http://localhost:8000/docs`
|
||||
|
||||
#### Terminal 2 : Frontend
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
✅ L'application sera accessible sur **http://localhost:9873**
|
||||
|
||||
### Mode production
|
||||
|
||||
Pour lancer l'application complète en production :
|
||||
|
||||
```bash
|
||||
python run_prod.py
|
||||
```
|
||||
|
||||
✅ L'application sera accessible sur **http://0.0.0.0:9874**
|
||||
|
||||
Ce script :
|
||||
1. Compile le frontend React en version optimisée
|
||||
2. Copie les fichiers statiques dans le dossier `backend`
|
||||
3. Lance le serveur FastAPI en mode production
|
||||
|
||||
---
|
||||
|
||||
## 📖 Utilisation
|
||||
|
||||
### 1. Première connexion
|
||||
|
||||
1. Ouvrez votre navigateur sur `http://localhost:9873` (dev) ou `http://localhost:9874` (prod)
|
||||
2. **Créez un compte** en cliquant sur "Register"
|
||||
3. Remplissez vos informations (nom d'utilisateur et mot de passe)
|
||||
4. Connectez-vous avec vos identifiants
|
||||
|
||||
### 2. Créer votre première balance
|
||||
|
||||
1. Sur le dashboard, cliquez sur le bouton **"+ Ajouter une balance"**
|
||||
2. Remplissez les informations :
|
||||
- **Nom** : ex. "Compte Principal", "Caisse", "Épargne"
|
||||
- **Montant initial** : le solde de départ (peut être 0)
|
||||
3. Validez
|
||||
|
||||
### 3. Ajouter des opérations
|
||||
|
||||
1. Sélectionnez une balance dans le carrousel
|
||||
2. Cliquez sur **"+ Ajouter une opération"**
|
||||
3. Renseignez les détails :
|
||||
- **Type** : Recette ou Dépense
|
||||
- **Montant** : montant de l'opération
|
||||
- **Date** : date de l'opération
|
||||
- **Catégorie** : type d'opération (salaire, achat, don, etc.)
|
||||
- **Description** : détails complémentaires
|
||||
4. Validez
|
||||
|
||||
### 4. Visualiser vos données
|
||||
|
||||
- **Carrousel** : Naviguez entre vos balances avec les flèches
|
||||
- **Graphiques** : Consultez l'évolution de vos revenus/dépenses au fil du temps
|
||||
- **Tableau** : Visualisez toutes les opérations en détail, triables et filtrables
|
||||
|
||||
### 5. Modifier ou supprimer
|
||||
|
||||
- **Opérations** : Clic droit sur une opération → Modifier ou Supprimer
|
||||
- **Balances** : Menu contextuel sur chaque carte de balance
|
||||
|
||||
### 6. Exporter en PDF
|
||||
|
||||
1. Cliquez sur le bouton **"Exporter PDF"** dans l'en-tête
|
||||
2. Le rapport complet sera généré et téléchargé automatiquement
|
||||
3. Le PDF contient toutes vos balances et opérations avec un design professionnel
|
||||
|
||||
---
|
||||
|
||||
## 🎛️ Commandes CLI
|
||||
|
||||
Le backend dispose d'un outil CLI (`cli.py`) pour faciliter les tâches courantes :
|
||||
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `python cli.py start` | Démarre le serveur de développement FastAPI (avec rechargement automatique) |
|
||||
| `python cli.py setup-db` | Crée toutes les tables nécessaires dans la base de données |
|
||||
| `python cli.py reset-db` | ⚠️ **DANGER** : Supprime et recrée toutes les tables (perte de données) |
|
||||
|
||||
**Exemples** :
|
||||
|
||||
```bash
|
||||
# Démarrer le serveur
|
||||
python cli.py start
|
||||
|
||||
# Créer les tables (première installation)
|
||||
python cli.py setup-db
|
||||
|
||||
# Réinitialiser complètement la base (développement uniquement)
|
||||
python cli.py reset-db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
abacus/
|
||||
├── backend/ # Backend FastAPI
|
||||
│ ├── api/ # Routes API
|
||||
│ ├── models/ # Modèles SQLModel
|
||||
│ ├── database.py # Configuration DB
|
||||
│ ├── cli.py # Outil CLI
|
||||
│ ├── .env # Variables d'environnement
|
||||
│ └── requirements.txt # Dépendances Python
|
||||
│
|
||||
├── components/ # Composants React
|
||||
│ ├── Dashboard.tsx
|
||||
│ ├── BalanceCard.tsx
|
||||
│ ├── OperationsTable.tsx
|
||||
│ ├── OperationsChart.tsx
|
||||
│ ├── AddBalanceModal.tsx
|
||||
│ ├── AddOperationModal.tsx
|
||||
│ ├── ExportButton.tsx
|
||||
│ ├── PDFDocument.tsx
|
||||
│ └── ...
|
||||
│
|
||||
├── public/ # Ressources statiques
|
||||
├── App.tsx # Composant principal
|
||||
├── api.ts # Client API
|
||||
├── types.ts # Types TypeScript
|
||||
├── index.tsx # Point d'entrée React
|
||||
├── vite.config.ts # Configuration Vite
|
||||
├── package.json # Dépendances Node.js
|
||||
└── README.md # Ce fichier
|
||||
```
|
||||
|
||||
### Flux de données
|
||||
|
||||
1. **Frontend** (React) → HTTP Request → **Backend** (FastAPI)
|
||||
2. **Backend** → SQL Query → **Database** (MySQL)
|
||||
3. **Database** → Data → **Backend** → JSON Response → **Frontend**
|
||||
|
||||
### Multi-tenant
|
||||
|
||||
Chaque utilisateur a ses propres données isolées. Le backend filtre automatiquement toutes les requêtes en fonction de l'utilisateur connecté.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licence
|
||||
|
||||
Ce projet est sous licence **CC BY-NC-SA 4.0** (Creative Commons Attribution - Pas d'Utilisation Commerciale - Partage dans les Mêmes Conditions).
|
||||
|
||||
**Auteur** : Coodlab, Mallevaey Lino
|
||||
**Version** : 2025.11.22
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contribution
|
||||
|
||||
Les contributions sont les bienvenues ! N'hésitez pas à :
|
||||
- Signaler des bugs
|
||||
- Proposer de nouvelles fonctionnalités
|
||||
- Soumettre des pull requests
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
Pour toute question ou problème :
|
||||
- Ouvrez une issue sur le dépôt GitHub
|
||||
- Consultez la documentation Swagger : `http://localhost:8000/docs`
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Fait avec ❤️ pour simplifier la comptabilité associative**
|
||||
|
||||
</div>
|
||||
152
api.ts
Normal file
152
api.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Association, Operation, OperationType, Balance } from './types';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
export const api = {
|
||||
async signup(name: string, password: string, balances: { name: string; amount: string }[]): Promise<Association> {
|
||||
const response = await fetch(`${API_URL}/signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, password, balances }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Signup failed');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async login(name: string, password: string): Promise<Association> {
|
||||
const response = await fetch(`${API_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, password }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Login failed');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async getAssociation(id: string): Promise<Association> {
|
||||
const response = await fetch(`${API_URL}/associations/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch association');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
const mappedOperations = data.operations.map((op: any) => ({
|
||||
...op,
|
||||
balanceId: op.balance_id || op.balanceId
|
||||
}));
|
||||
|
||||
const mappedBalances = data.balances.map((balance: any) => ({
|
||||
...balance,
|
||||
operations: balance.operations.map((op: any) => ({
|
||||
...op,
|
||||
balanceId: op.balance_id || op.balanceId
|
||||
}))
|
||||
}));
|
||||
|
||||
return {
|
||||
...data,
|
||||
operations: mappedOperations,
|
||||
balances: mappedBalances
|
||||
};
|
||||
},
|
||||
|
||||
async createOperation(operation: {
|
||||
name: string;
|
||||
description: string;
|
||||
group: string;
|
||||
amount: number;
|
||||
type: OperationType;
|
||||
date: string;
|
||||
balance_id: string;
|
||||
}): Promise<Operation> {
|
||||
const response = await fetch(`${API_URL}/operations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(operation),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create operation');
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
...data,
|
||||
balanceId: data.balance_id,
|
||||
};
|
||||
},
|
||||
|
||||
async updateOperation(operation: Operation): Promise<Operation> {
|
||||
const response = await fetch(`${API_URL}/operations/${operation.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: operation.name,
|
||||
description: operation.description,
|
||||
group: operation.group,
|
||||
amount: operation.amount,
|
||||
type: operation.type,
|
||||
date: operation.date,
|
||||
balance_id: operation.balanceId
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update operation');
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
...data,
|
||||
balanceId: data.balance_id,
|
||||
};
|
||||
},
|
||||
|
||||
async deleteOperation(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/operations/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete operation');
|
||||
}
|
||||
},
|
||||
|
||||
async addBalance(name: string, initialAmount: number, associationId: string): Promise<any> {
|
||||
const response = await fetch(`${API_URL}/balances_add`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, initialAmount, association_id: associationId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add balance');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async updateBalance(balance: Balance): Promise<Balance> {
|
||||
const response = await fetch(`${API_URL}/balances/${balance.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: balance.name,
|
||||
initialAmount: balance.initialAmount,
|
||||
position: balance.position,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update balance');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async deleteBalance(balanceId: string): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/balances/${balanceId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete balance');
|
||||
}
|
||||
}
|
||||
};
|
||||
1
backend/.env.example
Normal file
1
backend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
DATABASE_URL=mysql+pymysql://user:password@host:port/database_name
|
||||
49
backend/cli.py
Normal file
49
backend/cli.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import typer
|
||||
import uvicorn
|
||||
from sqlmodel import SQLModel
|
||||
from database import engine
|
||||
from models import Association, Balance, Operation
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
app = typer.Typer()
|
||||
console = Console()
|
||||
|
||||
@app.command()
|
||||
def start(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8000,
|
||||
reload: bool = True
|
||||
):
|
||||
"""
|
||||
Start the FastAPI server.
|
||||
"""
|
||||
console.print(Panel(f"Starting Abacus Backend on http://{host}:{port}", title="Abacus", style="bold green"))
|
||||
uvicorn.run("main:app", host=host, port=port, reload=reload)
|
||||
|
||||
@app.command()
|
||||
def setup_db():
|
||||
"""
|
||||
Create database tables.
|
||||
"""
|
||||
console.print("[bold yellow]Creating tables...[/bold yellow]")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
console.print("[bold green]Tables created successfully.[/bold green]")
|
||||
|
||||
@app.command()
|
||||
def reset_db():
|
||||
"""
|
||||
Drop and recreate database tables.
|
||||
"""
|
||||
confirm = typer.confirm("Are you sure you want to drop all tables?")
|
||||
if not confirm:
|
||||
console.print("[bold red]Aborted.[/bold red]")
|
||||
raise typer.Abort()
|
||||
|
||||
console.print("[bold red]Dropping all tables...[/bold red]")
|
||||
SQLModel.metadata.drop_all(engine)
|
||||
console.print("[bold green]Tables dropped.[/bold green]")
|
||||
setup_db()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
16
backend/database.py
Normal file
16
backend/database.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sqlmodel import SQLModel, create_engine, Session
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
if not DATABASE_URL:
|
||||
raise ValueError("DATABASE_URL environment variable is not set")
|
||||
|
||||
engine = create_engine(DATABASE_URL, echo=True)
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
250
backend/main.py
Normal file
250
backend/main.py
Normal file
@@ -0,0 +1,250 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlmodel import Session, select
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
import os
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from database import get_session
|
||||
from models import Association, Balance, Operation, OperationType, AssociationRead, BalanceRead
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
origins = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:9873",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class BalanceCreate(BaseModel):
|
||||
name: str
|
||||
amount: str
|
||||
|
||||
class SignupRequest(BaseModel):
|
||||
name: str
|
||||
password: str
|
||||
balances: List[BalanceCreate]
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
name: str
|
||||
password: str
|
||||
|
||||
class OperationCreate(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
group: str
|
||||
amount: float
|
||||
type: OperationType
|
||||
date: datetime
|
||||
balance_id: str
|
||||
|
||||
def association_to_read(association: Association) -> AssociationRead:
|
||||
all_operations = []
|
||||
balance_reads = []
|
||||
for balance in association.balances:
|
||||
ops = balance.operations
|
||||
all_operations.extend(ops)
|
||||
balance_reads.append(BalanceRead(
|
||||
id=balance.id,
|
||||
name=balance.name,
|
||||
initialAmount=balance.initialAmount,
|
||||
position=balance.position,
|
||||
operations=ops
|
||||
))
|
||||
|
||||
return AssociationRead(
|
||||
id=association.id,
|
||||
name=association.name,
|
||||
balances=balance_reads,
|
||||
operations=all_operations
|
||||
)
|
||||
|
||||
@app.post("/api/signup", response_model=AssociationRead)
|
||||
def signup(request: SignupRequest, session: Session = Depends(get_session)):
|
||||
statement = select(Association).where(Association.name == request.name)
|
||||
existing = session.exec(statement).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Association already exists")
|
||||
|
||||
hashed_password = get_password_hash(request.password)
|
||||
association = Association(name=request.name, password=hashed_password)
|
||||
session.add(association)
|
||||
session.commit()
|
||||
session.refresh(association)
|
||||
|
||||
for b in request.balances:
|
||||
balance = Balance(
|
||||
name=b.name,
|
||||
initialAmount=float(b.amount),
|
||||
association_id=association.id,
|
||||
position=0
|
||||
)
|
||||
session.add(balance)
|
||||
|
||||
session.commit()
|
||||
session.refresh(association)
|
||||
return association_to_read(association)
|
||||
|
||||
@app.post("/api/login", response_model=AssociationRead)
|
||||
def login(request: LoginRequest, session: Session = Depends(get_session)):
|
||||
statement = select(Association).where(Association.name == request.name)
|
||||
association = session.exec(statement).first()
|
||||
if not association or not verify_password(request.password, association.password):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
return association_to_read(association)
|
||||
|
||||
@app.get("/api/associations/{association_id}", response_model=AssociationRead)
|
||||
def get_association(association_id: str, session: Session = Depends(get_session)):
|
||||
from sqlalchemy.orm import selectinload
|
||||
statement = select(Association).where(Association.id == association_id).options(
|
||||
selectinload(Association.balances).selectinload(Balance.operations)
|
||||
)
|
||||
association = session.exec(statement).first()
|
||||
|
||||
if not association:
|
||||
raise HTTPException(status_code=404, detail="Association not found")
|
||||
|
||||
return association_to_read(association)
|
||||
|
||||
@app.post("/api/operations")
|
||||
def create_operation(op: OperationCreate, session: Session = Depends(get_session)):
|
||||
balance = session.get(Balance, op.balance_id)
|
||||
if not balance:
|
||||
raise HTTPException(status_code=404, detail="Balance not found")
|
||||
|
||||
operation = Operation(
|
||||
name=op.name,
|
||||
description=op.description,
|
||||
group=op.group,
|
||||
amount=op.amount,
|
||||
type=op.type,
|
||||
date=op.date,
|
||||
balance_id=op.balance_id
|
||||
)
|
||||
session.add(operation)
|
||||
session.commit()
|
||||
session.refresh(operation)
|
||||
return operation
|
||||
|
||||
@app.delete("/api/operations/{operation_id}")
|
||||
def delete_operation(operation_id: str, session: Session = Depends(get_session)):
|
||||
operation = session.get(Operation, operation_id)
|
||||
if not operation:
|
||||
raise HTTPException(status_code=404, detail="Operation not found")
|
||||
session.delete(operation)
|
||||
session.commit()
|
||||
session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
class OperationUpdate(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
group: str
|
||||
amount: float
|
||||
type: OperationType
|
||||
date: datetime
|
||||
balance_id: str
|
||||
|
||||
@app.put("/api/operations/{operation_id}")
|
||||
def update_operation(operation_id: str, op: OperationUpdate, session: Session = Depends(get_session)):
|
||||
operation = session.get(Operation, operation_id)
|
||||
if not operation:
|
||||
raise HTTPException(status_code=404, detail="Operation not found")
|
||||
|
||||
operation.name = op.name
|
||||
operation.description = op.description
|
||||
operation.group = op.group
|
||||
operation.amount = op.amount
|
||||
operation.type = op.type
|
||||
operation.date = op.date
|
||||
operation.balance_id = op.balance_id
|
||||
|
||||
session.add(operation)
|
||||
session.commit()
|
||||
session.refresh(operation)
|
||||
return operation
|
||||
|
||||
@app.post("/api/balances")
|
||||
def create_balance(balance: BalanceCreate, association_id: str, session: Session = Depends(get_session)):
|
||||
pass
|
||||
|
||||
class BalanceAddRequest(BaseModel):
|
||||
name: str
|
||||
initialAmount: float
|
||||
association_id: str
|
||||
|
||||
@app.post("/api/balances_add")
|
||||
def add_balance(request: BalanceAddRequest, session: Session = Depends(get_session)):
|
||||
statement = select(Balance).where(Balance.association_id == request.association_id).order_by(Balance.position.desc())
|
||||
last_balance = session.exec(statement).first()
|
||||
new_position = (last_balance.position + 1) if last_balance else 0
|
||||
|
||||
balance = Balance(
|
||||
name=request.name,
|
||||
initialAmount=request.initialAmount,
|
||||
association_id=request.association_id,
|
||||
position=new_position
|
||||
)
|
||||
session.add(balance)
|
||||
session.commit()
|
||||
session.refresh(balance)
|
||||
return balance
|
||||
|
||||
@app.delete("/api/balances/{balance_id}")
|
||||
def delete_balance(balance_id: str, session: Session = Depends(get_session)):
|
||||
balance = session.get(Balance, balance_id)
|
||||
if not balance:
|
||||
raise HTTPException(status_code=404, detail="Balance not found")
|
||||
|
||||
session.delete(balance)
|
||||
session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
class BalanceUpdate(BaseModel):
|
||||
name: str
|
||||
initialAmount: float
|
||||
position: int
|
||||
|
||||
@app.put("/api/balances/{balance_id}")
|
||||
def update_balance(balance_id: str, data: BalanceUpdate, session: Session = Depends(get_session)):
|
||||
balance = session.get(Balance, balance_id)
|
||||
if not balance:
|
||||
raise HTTPException(status_code=404, detail="Balance not found")
|
||||
|
||||
balance.name = data.name
|
||||
balance.initialAmount = data.initialAmount
|
||||
balance.position = data.position
|
||||
|
||||
session.add(balance)
|
||||
session.commit()
|
||||
session.refresh(balance)
|
||||
return balance
|
||||
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||
if os.path.exists(static_dir):
|
||||
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
52
backend/models.py
Normal file
52
backend/models.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from typing import List, Optional
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
from enum import Enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
class OperationType(str, Enum):
|
||||
INCOME = 'income'
|
||||
EXPENSE = 'expense'
|
||||
|
||||
class Association(SQLModel, table=True):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||
name: str
|
||||
password: str
|
||||
|
||||
balances: List["Balance"] = Relationship(back_populates="association")
|
||||
|
||||
class Balance(SQLModel, table=True):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||
name: str
|
||||
initialAmount: float
|
||||
association_id: Optional[str] = Field(default=None, foreign_key="association.id")
|
||||
|
||||
association: Optional[Association] = Relationship(back_populates="balances")
|
||||
operations: List["Operation"] = Relationship(back_populates="balance")
|
||||
position: int = Field(default=0)
|
||||
|
||||
class Operation(SQLModel, table=True):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||
name: str
|
||||
description: str
|
||||
group: str
|
||||
amount: float
|
||||
type: OperationType
|
||||
date: datetime
|
||||
invoice: Optional[str] = None
|
||||
balance_id: Optional[str] = Field(default=None, foreign_key="balance.id")
|
||||
|
||||
balance: Optional[Balance] = Relationship(back_populates="operations")
|
||||
|
||||
class BalanceRead(SQLModel):
|
||||
id: str
|
||||
name: str
|
||||
initialAmount: float
|
||||
position: int = 0
|
||||
operations: List[Operation] = []
|
||||
|
||||
class AssociationRead(SQLModel):
|
||||
id: str
|
||||
name: str
|
||||
balances: List[BalanceRead] = []
|
||||
operations: List[Operation] = []
|
||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlmodel
|
||||
pymysql
|
||||
python-dotenv
|
||||
typer
|
||||
rich
|
||||
passlib[bcrypt]
|
||||
102
components/AddBalanceModal.tsx
Normal file
102
components/AddBalanceModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Balance } from '../types';
|
||||
|
||||
interface AddBalanceModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAddBalance: (balance: Omit<Balance, 'id'>) => void;
|
||||
onUpdateBalance?: (balance: Balance) => void;
|
||||
balanceToEdit?: Balance | null;
|
||||
}
|
||||
|
||||
const AddBalanceModal: React.FC<AddBalanceModalProps> = ({ isOpen, onClose, onAddBalance, onUpdateBalance, balanceToEdit }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [initialAmount, setInitialAmount] = useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (balanceToEdit) {
|
||||
setName(balanceToEdit.name);
|
||||
setInitialAmount(balanceToEdit.initialAmount.toString());
|
||||
} else {
|
||||
setName('');
|
||||
setInitialAmount('');
|
||||
}
|
||||
}, [balanceToEdit, isOpen]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name || !initialAmount) {
|
||||
alert('Please fill all required fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (balanceToEdit && onUpdateBalance) {
|
||||
onUpdateBalance({
|
||||
...balanceToEdit,
|
||||
name,
|
||||
initialAmount: parseFloat(initialAmount),
|
||||
});
|
||||
} else {
|
||||
onAddBalance({
|
||||
name,
|
||||
initialAmount: parseFloat(initialAmount),
|
||||
});
|
||||
}
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setInitialAmount('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex justify-center items-center p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div className="flex justify-between items-center p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-800">{balanceToEdit ? 'Edit Balance' : 'Add New Balance'}</h2>
|
||||
<button onClick={resetForm} className="text-gray-400 hover:text-gray-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Balance Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-800 transition"
|
||||
placeholder="e.g., Main Account, Savings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Initial Amount (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={initialAmount}
|
||||
onChange={e => setInitialAmount(e.target.value)}
|
||||
className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-800 transition"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button type="submit" className="bg-gray-800 text-white px-6 py-2 rounded-lg font-semibold hover:bg-gray-900 transition">
|
||||
{balanceToEdit ? 'Save Changes' : 'Add Balance'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddBalanceModal;
|
||||
161
components/AddOperationModal.tsx
Normal file
161
components/AddOperationModal.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Balance, Operation, OperationType } from '../types';
|
||||
|
||||
interface AddOperationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAddOperation: (operation: Omit<Operation, 'id'>) => void;
|
||||
onUpdateOperation?: (operation: Operation) => void;
|
||||
operationToEdit?: Operation | null;
|
||||
balances: Balance[];
|
||||
}
|
||||
|
||||
const AddOperationModal: React.FC<AddOperationModalProps> = ({ isOpen, onClose, onAddOperation, onUpdateOperation, operationToEdit, balances }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [group, setGroup] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [type, setType] = useState<OperationType>(OperationType.EXPENSE);
|
||||
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [balanceId, setBalanceId] = useState<string>('');
|
||||
const [invoice, setInvoice] = useState<File | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (operationToEdit) {
|
||||
setName(operationToEdit.name);
|
||||
setDescription(operationToEdit.description);
|
||||
setGroup(operationToEdit.group);
|
||||
setAmount(operationToEdit.amount.toString());
|
||||
setType(operationToEdit.type);
|
||||
setDate(new Date(operationToEdit.date).toISOString().split('T')[0]);
|
||||
setBalanceId(operationToEdit.balanceId);
|
||||
} else if (balances.length > 0) {
|
||||
setBalanceId(balances[0].id);
|
||||
resetFormFields();
|
||||
}
|
||||
}, [balances, operationToEdit, isOpen]);
|
||||
|
||||
const resetFormFields = () => {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setGroup('');
|
||||
setAmount('');
|
||||
setType(OperationType.EXPENSE);
|
||||
setDate(new Date().toISOString().split('T')[0]);
|
||||
if (balances.length > 0) setBalanceId(balances[0].id);
|
||||
setInvoice(null);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name || !amount || !balanceId || !group) {
|
||||
alert('Please fill all required fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
const operationData = {
|
||||
balanceId,
|
||||
name,
|
||||
description,
|
||||
group,
|
||||
amount: parseFloat(amount),
|
||||
type,
|
||||
date: new Date(date).toISOString(),
|
||||
invoice: invoice ? invoice.name : (operationToEdit?.invoice)
|
||||
};
|
||||
|
||||
if (operationToEdit && onUpdateOperation) {
|
||||
onUpdateOperation({
|
||||
...operationData,
|
||||
id: operationToEdit.id
|
||||
});
|
||||
} else {
|
||||
onAddOperation(operationData);
|
||||
}
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
resetFormFields();
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex justify-center items-center p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg">
|
||||
<div className="flex justify-between items-center p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-800">{operationToEdit ? 'Edit Operation' : 'Add New Operation'}</h2>
|
||||
<button onClick={resetForm} className="text-gray-400 hover:text-gray-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button type="button" onClick={() => setType(OperationType.EXPENSE)} className={`w-full py-3 rounded-lg font-semibold transition ${type === OperationType.EXPENSE ? 'bg-red-500 text-white' : 'bg-gray-200 text-gray-700'}`}>
|
||||
Expense
|
||||
</button>
|
||||
<button type="button" onClick={() => setType(OperationType.INCOME)} className={`w-full py-3 rounded-lg font-semibold transition ${type === OperationType.INCOME ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-700'}`}>
|
||||
Income
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Balance</label>
|
||||
<select value={balanceId} onChange={(e) => setBalanceId(e.target.value)} className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-800 transition">
|
||||
{balances.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Title</label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-800 transition" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Group</label>
|
||||
<input type="text" placeholder="e.g., Donations, Office Supplies" value={group} onChange={e => setGroup(e.target.value)} className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-800 transition" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Amount (€)</label>
|
||||
<input type="number" step="0.01" value={amount} onChange={e => setAmount(e.target.value)} className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-800 transition" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Date</label>
|
||||
<input type="date" value={date} onChange={e => setDate(e.target.value)} className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-800 transition" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} rows={2} className="w-full mt-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-800 transition"></textarea>
|
||||
</div>
|
||||
|
||||
{type === OperationType.EXPENSE && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Invoice</label>
|
||||
<input type="file" onChange={e => setInvoice(e.target.files ? e.target.files[0] : null)} className="w-full mt-1 text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button type="button" onClick={onClose} className="mr-3 px-6 py-2 rounded-lg font-semibold text-gray-600 hover:bg-gray-100 transition">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="bg-gray-800 text-white px-6 py-2 rounded-lg font-semibold hover:bg-gray-900 transition">
|
||||
{operationToEdit ? 'Save Changes' : 'Add Operation'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddOperationModal;
|
||||
131
components/BalanceCard.tsx
Normal file
131
components/BalanceCard.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Balance, Operation, OperationType } from '../types';
|
||||
|
||||
interface BalanceCardProps {
|
||||
balance: Balance;
|
||||
operations: Operation[];
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onEdit: (balance: Balance) => void;
|
||||
onDelete: (balanceId: string) => void;
|
||||
}
|
||||
|
||||
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, operations, isSelected, onClick, onEdit, onDelete }) => {
|
||||
const { totalIncome, totalExpenses, currentBalance } = useMemo(() => {
|
||||
const totalIncome = operations
|
||||
.filter(op => op.type === OperationType.INCOME)
|
||||
.reduce((sum, op) => sum + op.amount, 0);
|
||||
|
||||
const totalExpenses = operations
|
||||
.filter(op => op.type === OperationType.EXPENSE)
|
||||
.reduce((sum, op) => sum + op.amount, 0);
|
||||
|
||||
const currentBalance = balance.initialAmount + totalIncome - totalExpenses;
|
||||
|
||||
return { totalIncome, totalExpenses, currentBalance };
|
||||
}, [balance, operations]);
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount);
|
||||
};
|
||||
|
||||
const [openMenu, setOpenMenu] = React.useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const [menuPosition, setMenuPosition] = React.useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpenMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleMenu = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (openMenu) {
|
||||
setOpenMenu(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMenuPosition({
|
||||
top: rect.bottom,
|
||||
left: rect.right - 128,
|
||||
});
|
||||
setOpenMenu(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`relative p-6 rounded-xl border transition-all duration-200 cursor-pointer group ${isSelected
|
||||
? 'bg-gray-800 text-white shadow-lg'
|
||||
: 'bg-white text-gray-800 shadow-md border-gray-200 hover:shadow-lg hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<p className={`text-lg font-semibold ${isSelected ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
{balance.name}
|
||||
</p>
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className={`p-1 rounded-full hover:bg-gray-200 transition-colors ${isSelected ? 'text-gray-400 hover:bg-gray-700' : 'text-gray-400'}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-4xl font-bold mt-2 truncate">{formatCurrency(currentBalance)}</p>
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div className="text-center">
|
||||
<p className={`text-sm ${isSelected ? 'text-gray-400' : 'text-gray-500'}`}>Income</p>
|
||||
<p className={`font-semibold text-green-500 ${isSelected ? 'text-green-400' : 'text-green-600'}`}>{formatCurrency(totalIncome)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className={`text-sm ${isSelected ? 'text-gray-400' : 'text-gray-500'}`}>Expenses</p>
|
||||
<p className={`font-semibold text-red-500 ${isSelected ? 'text-red-400' : 'text-red-600'}`}>{formatCurrency(totalExpenses)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{openMenu && menuPosition && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 w-32 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
style={{ top: menuPosition.top, left: menuPosition.left }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
onEdit(balance);
|
||||
setOpenMenu(false);
|
||||
}}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onDelete(balance.id);
|
||||
setOpenMenu(false);
|
||||
}}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-gray-100"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BalanceCard;
|
||||
81
components/ConfirmationModal.tsx
Normal file
81
components/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isDanger?: boolean;
|
||||
}
|
||||
|
||||
const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
isDanger = false,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" onClick={onClose}></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className={`mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full ${isDanger ? 'bg-red-100' : 'bg-blue-100'} sm:mx-0 sm:h-10 sm:w-10`}>
|
||||
{isDanger ? (
|
||||
<svg className="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-6 w-6 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm ${isDanger ? 'bg-red-600 hover:bg-red-700 focus:ring-red-500' : 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500'}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationModal;
|
||||
362
components/Dashboard.tsx
Normal file
362
components/Dashboard.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Association, Balance, Operation, OperationType } from '../types';
|
||||
import { api } from '../api';
|
||||
import Header from './Header';
|
||||
import BalanceCard from './BalanceCard';
|
||||
import OperationsChart from './OperationsChart';
|
||||
import OperationsTable from './OperationsTable';
|
||||
import AddOperationModal from './AddOperationModal';
|
||||
import AddBalanceModal from './AddBalanceModal';
|
||||
import { format, subDays, startOfMonth, endOfMonth } from 'date-fns';
|
||||
|
||||
interface DashboardProps {
|
||||
association: Association;
|
||||
onLogout: () => void;
|
||||
onUpdateAssociation: (association: Association) => void;
|
||||
}
|
||||
|
||||
const Dashboard: React.FC<DashboardProps> = ({ association, onLogout, onUpdateAssociation }) => {
|
||||
const today = new Date();
|
||||
const [dateRange, setDateRange] = useState<{ start: Date, end: Date }>({ start: startOfMonth(today), end: endOfMonth(today) });
|
||||
const [selectedBalanceId, setSelectedBalanceId] = useState<string | null>(association.balances[0]?.id ?? null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isAddBalanceModalOpen, setIsAddBalanceModalOpen] = useState(false);
|
||||
|
||||
const [balanceToEdit, setBalanceToEdit] = useState<Balance | null>(null);
|
||||
const [draggedBalanceIndex, setDraggedBalanceIndex] = useState<number | null>(null);
|
||||
|
||||
const [operationToEdit, setOperationToEdit] = useState<Operation | null>(null);
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const scroll = (direction: 'left' | 'right') => {
|
||||
if (scrollContainerRef.current) {
|
||||
const scrollAmount = 320;
|
||||
const newScrollLeft = scrollContainerRef.current.scrollLeft + (direction === 'right' ? scrollAmount : -scrollAmount);
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: newScrollLeft,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredOperations = useMemo(() => {
|
||||
return association.operations.filter(op => {
|
||||
const opDate = new Date(op.date);
|
||||
return opDate >= dateRange.start && opDate <= dateRange.end;
|
||||
});
|
||||
}, [association.operations, dateRange]);
|
||||
|
||||
const selectedBalance = useMemo(() => {
|
||||
return association.balances.find(b => b.id === selectedBalanceId) ?? null;
|
||||
}, [association.balances, selectedBalanceId]);
|
||||
|
||||
const operationsForSelectedBalance = useMemo(() => {
|
||||
return filteredOperations.filter(op => op.balanceId === selectedBalanceId);
|
||||
}, [filteredOperations, selectedBalanceId]);
|
||||
|
||||
const incomesForSelectedBalance = operationsForSelectedBalance.filter(op => op.type === OperationType.INCOME);
|
||||
const expensesForSelectedBalance = operationsForSelectedBalance.filter(op => op.type === OperationType.EXPENSE);
|
||||
|
||||
const handleAddOperation = async (newOperationData: Omit<Operation, 'id'>) => {
|
||||
try {
|
||||
const newOperation = await api.createOperation({
|
||||
...newOperationData,
|
||||
balance_id: newOperationData.balanceId,
|
||||
date: newOperationData.date,
|
||||
});
|
||||
|
||||
const updatedAssociation = {
|
||||
...association,
|
||||
operations: [...association.operations, newOperation]
|
||||
};
|
||||
onUpdateAssociation(updatedAssociation);
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to add operation", error);
|
||||
alert("Failed to add operation");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateOperation = async (updatedOperation: Operation) => {
|
||||
try {
|
||||
const result = await api.updateOperation({
|
||||
...updatedOperation,
|
||||
balanceId: updatedOperation.balanceId,
|
||||
});
|
||||
|
||||
const updatedAssociation = {
|
||||
...association,
|
||||
operations: association.operations.map(op => op.id === result.id ? result : op)
|
||||
};
|
||||
onUpdateAssociation(updatedAssociation);
|
||||
setIsModalOpen(false);
|
||||
setOperationToEdit(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to update operation", error);
|
||||
alert("Failed to update operation");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOperation = async (operationId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this operation?')) {
|
||||
try {
|
||||
await api.deleteOperation(operationId);
|
||||
const updatedAssociation = {
|
||||
...association,
|
||||
operations: association.operations.filter(op => op.id !== operationId)
|
||||
};
|
||||
onUpdateAssociation(updatedAssociation);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete operation", error);
|
||||
alert("Failed to delete operation");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditOperation = (operation: Operation) => {
|
||||
setOperationToEdit(operation);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setOperationToEdit(null);
|
||||
};
|
||||
|
||||
const handleAddBalance = async (newBalanceData: Omit<Balance, 'id'>) => {
|
||||
try {
|
||||
const newBalance = await api.addBalance(newBalanceData.name, newBalanceData.initialAmount, association.id);
|
||||
const updatedAssociation = {
|
||||
...association,
|
||||
balances: [...association.balances, newBalance]
|
||||
};
|
||||
onUpdateAssociation(updatedAssociation);
|
||||
setIsAddBalanceModalOpen(false);
|
||||
setSelectedBalanceId(newBalance.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to add balance", error);
|
||||
alert("Failed to add balance");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditBalance = (balance: Balance) => {
|
||||
setBalanceToEdit(balance);
|
||||
setIsAddBalanceModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteBalance = async (balanceId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this balance? All associated operations will be lost.')) {
|
||||
try {
|
||||
await api.deleteBalance(balanceId);
|
||||
const updatedAssociation = {
|
||||
...association,
|
||||
balances: association.balances.filter(b => b.id !== balanceId)
|
||||
};
|
||||
onUpdateAssociation(updatedAssociation);
|
||||
if (selectedBalanceId === balanceId) {
|
||||
setSelectedBalanceId(updatedAssociation.balances[0]?.id ?? null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete balance", error);
|
||||
alert("Failed to delete balance");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateBalance = async (updatedBalance: Balance) => {
|
||||
try {
|
||||
const result = await api.updateBalance(updatedBalance);
|
||||
const updatedAssociation = {
|
||||
...association,
|
||||
balances: association.balances.map(b => b.id === result.id ? result : b)
|
||||
};
|
||||
onUpdateAssociation(updatedAssociation);
|
||||
setIsAddBalanceModalOpen(false);
|
||||
setBalanceToEdit(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to update balance", error);
|
||||
alert("Failed to update balance");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggedBalanceIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = async (dropIndex: number) => {
|
||||
if (draggedBalanceIndex === null || draggedBalanceIndex === dropIndex) return;
|
||||
|
||||
const newBalances = [...association.balances];
|
||||
const [draggedBalance] = newBalances.splice(draggedBalanceIndex, 1);
|
||||
newBalances.splice(dropIndex, 0, draggedBalance);
|
||||
|
||||
const updatedBalances = newBalances.map((b, index) => ({
|
||||
...b,
|
||||
position: index
|
||||
}));
|
||||
|
||||
onUpdateAssociation({
|
||||
...association,
|
||||
balances: updatedBalances
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(updatedBalances.map(b => api.updateBalance(b)));
|
||||
} catch (error) {
|
||||
console.error("Failed to update balance positions", error);
|
||||
alert("Failed to save new order");
|
||||
}
|
||||
setDraggedBalanceIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* ... Header ... */}
|
||||
<Header
|
||||
associationName={association.name}
|
||||
onLogout={onLogout}
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
operations={association.operations}
|
||||
balances={association.balances}
|
||||
/>
|
||||
<main className="p-4 sm:p-6 lg:p-8 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">Balances</h2>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setBalanceToEdit(null);
|
||||
setIsAddBalanceModalOpen(true);
|
||||
}}
|
||||
className="bg-white text-gray-800 border border-gray-300 px-4 py-2 rounded-lg font-semibold hover:bg-gray-50 transition flex items-center space-x-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Add Balance</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-gray-800 text-white px-4 py-2 rounded-lg font-semibold hover:bg-gray-900 transition flex items-center space-x-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" /></svg>
|
||||
<span>Add Operation</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={() => scroll('left')}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 -ml-4 z-10 bg-white rounded-full p-2 shadow-lg border border-gray-200 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="p-2 flex space-x-6 pb-6 overflow-x-auto snap-x snap-mandatory scrollbar-none"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{association.balances
|
||||
.sort((a, b) => (a.position - b.position))
|
||||
.map((balance, index) => (
|
||||
<div
|
||||
key={balance.id}
|
||||
className="snap-start flex-shrink-0 w-80"
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={() => handleDrop(index)}
|
||||
>
|
||||
<BalanceCard
|
||||
balance={balance}
|
||||
operations={filteredOperations.filter(op => op.balanceId === balance.id)}
|
||||
isSelected={selectedBalanceId === balance.id}
|
||||
onClick={() => setSelectedBalanceId(balance.id)}
|
||||
onEdit={handleEditBalance}
|
||||
onDelete={handleDeleteBalance}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => scroll('right')}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 -mr-4 z-10 bg-white rounded-full p-2 shadow-lg border border-gray-200 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-8">
|
||||
<div className="lg:col-span-3 bg-white p-6 rounded-xl shadow-md border border-gray-200">
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-800">Balances Variation</h3>
|
||||
<OperationsChart
|
||||
balances={association.balances}
|
||||
allOperations={association.operations}
|
||||
dateRange={dateRange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedBalance && (
|
||||
<>
|
||||
<div className="lg:col-span-3 bg-white p-6 rounded-xl shadow-md border border-gray-200">
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-800">
|
||||
Operations for <span className="text-gray-900 font-bold">{selectedBalance.name}</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<OperationsTable
|
||||
title="Income"
|
||||
operations={incomesForSelectedBalance}
|
||||
onEdit={handleEditOperation}
|
||||
onDelete={handleDeleteOperation}
|
||||
/>
|
||||
<OperationsTable
|
||||
title="Expenses"
|
||||
operations={expensesForSelectedBalance}
|
||||
onEdit={handleEditOperation}
|
||||
onDelete={handleDeleteOperation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
<AddOperationModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onAddOperation={handleAddOperation}
|
||||
onUpdateOperation={handleUpdateOperation}
|
||||
operationToEdit={operationToEdit}
|
||||
balances={association.balances}
|
||||
/>
|
||||
<AddBalanceModal
|
||||
isOpen={isAddBalanceModalOpen}
|
||||
onClose={() => {
|
||||
setIsAddBalanceModalOpen(false);
|
||||
setBalanceToEdit(null);
|
||||
}}
|
||||
onAddBalance={handleAddBalance}
|
||||
onUpdateBalance={handleUpdateBalance}
|
||||
balanceToEdit={balanceToEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
40
components/ExportButton.tsx
Normal file
40
components/ExportButton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { PDFDownloadLink } from '@react-pdf/renderer';
|
||||
import PDFDocument from './PDFDocument';
|
||||
import { Operation, Balance } from '../types';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface ExportButtonProps {
|
||||
operations: Operation[];
|
||||
balances: Balance[];
|
||||
dateRange: { start: Date; end: Date };
|
||||
associationName: string;
|
||||
}
|
||||
|
||||
const ExportButton: React.FC<ExportButtonProps> = ({ operations, balances, dateRange, associationName }) => {
|
||||
return (
|
||||
<PDFDownloadLink
|
||||
document={
|
||||
<PDFDocument
|
||||
operations={operations}
|
||||
balances={balances}
|
||||
dateRange={dateRange}
|
||||
associationName={associationName}
|
||||
/>
|
||||
}
|
||||
fileName={`abacus_export_${format(dateRange.start, 'yyyy-MM-dd')}_${format(dateRange.end, 'yyyy-MM-dd')}.pdf`}
|
||||
className="text-sm font-medium text-gray-600 hover:text-gray-900 transition flex items-center space-x-1"
|
||||
>
|
||||
{({ blob, url, loading, error }) => (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
<span>{loading ? 'Loading...' : 'Export PDF'}</span>
|
||||
</>
|
||||
)}
|
||||
</PDFDownloadLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportButton;
|
||||
70
components/Header.tsx
Normal file
70
components/Header.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
|
||||
import React from 'react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
import { Operation, Balance } from '../types';
|
||||
import ExportButton from './ExportButton';
|
||||
|
||||
interface HeaderProps {
|
||||
associationName: string;
|
||||
onLogout: () => void;
|
||||
dateRange: { start: Date, end: Date };
|
||||
setDateRange: (range: { start: Date, end: Date }) => void;
|
||||
operations: Operation[];
|
||||
balances: Balance[];
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ associationName, onLogout, dateRange, setDateRange, operations, balances }) => {
|
||||
|
||||
const handleDateChange = (field: 'start' | 'end', value: string) => {
|
||||
const newDate = parseISO(value);
|
||||
if (field === 'start' && newDate < dateRange.end) {
|
||||
setDateRange({ ...dateRange, start: newDate });
|
||||
}
|
||||
if (field === 'end' && newDate > dateRange.start) {
|
||||
setDateRange({ ...dateRange, end: newDate });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<img src="/abacus.svg" alt="Abacus" className="h-12" />
|
||||
<span className="hidden sm:block text-gray-400">|</span>
|
||||
<p className="hidden sm:block text-3xl font-medium text-gray-600">{associationName}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="date"
|
||||
value={format(dateRange.start, 'yyyy-MM-dd')}
|
||||
onChange={(e) => handleDateChange('start', e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-gray-800"
|
||||
/>
|
||||
<span className="text-gray-500">-</span>
|
||||
<input
|
||||
type="date"
|
||||
value={format(dateRange.end, 'yyyy-MM-dd')}
|
||||
onChange={(e) => handleDateChange('end', e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<ExportButton
|
||||
operations={operations}
|
||||
balances={balances}
|
||||
dateRange={dateRange}
|
||||
associationName={associationName}
|
||||
/>
|
||||
<button onClick={onLogout} className="text-sm font-medium text-gray-600 hover:text-gray-900 transition">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
157
components/LoginScreen.tsx
Normal file
157
components/LoginScreen.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Association, Balance } from '../types';
|
||||
import { api } from '../api';
|
||||
|
||||
interface LoginScreenProps {
|
||||
onLogin: (association: Association) => void;
|
||||
}
|
||||
|
||||
const LoginScreen: React.FC<LoginScreenProps> = ({ onLogin }) => {
|
||||
const [isLoginView, setIsLoginView] = useState(true);
|
||||
const [associationName, setAssociationName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [initialBalances, setInitialBalances] = useState<{ name: string; amount: string }[]>([{ name: '', amount: '' }]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleAddBalance = () => {
|
||||
setInitialBalances([...initialBalances, { name: '', amount: '' }]);
|
||||
};
|
||||
|
||||
const handleRemoveBalance = (index: number) => {
|
||||
setInitialBalances(initialBalances.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleBalanceChange = (index: number, field: 'name' | 'amount', value: string) => {
|
||||
const newBalances = [...initialBalances];
|
||||
newBalances[index][field] = value;
|
||||
setInitialBalances(newBalances);
|
||||
};
|
||||
|
||||
const validateSignup = () => {
|
||||
if (!associationName.trim() || !password.trim()) {
|
||||
setError('Association name and password are required.');
|
||||
return false;
|
||||
}
|
||||
if (initialBalances.some(b => !b.name.trim() || !b.amount.trim() || isNaN(parseFloat(b.amount)))) {
|
||||
setError('All balance fields must be filled correctly.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSignup = async () => {
|
||||
setError('');
|
||||
if (!validateSignup()) return;
|
||||
|
||||
try {
|
||||
const newAssociation = await api.signup(associationName.trim(), password, initialBalances.map(b => ({ name: b.name.trim(), amount: b.amount })));
|
||||
onLogin(newAssociation);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Signup failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError('');
|
||||
if (!associationName.trim() || !password.trim()) {
|
||||
setError('Please enter association name and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const association = await api.login(associationName.trim(), password);
|
||||
onLogin(association);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isLoginView) {
|
||||
handleLogin();
|
||||
} else {
|
||||
handleSignup();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<h1 className="text-4xl font-bold text-center text-gray-800 mb-2">Abacus</h1>
|
||||
<p className="text-center text-gray-500 mb-8">Simplified accounting for your association.</p>
|
||||
<div className="bg-white p-8 rounded-xl shadow-md border border-gray-200">
|
||||
<h2 className="text-2xl font-semibold text-center mb-6">{isLoginView ? 'Login' : 'Create Account'}</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Association Name"
|
||||
value={associationName}
|
||||
onChange={e => setAssociationName(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-800 transition"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-800 transition"
|
||||
/>
|
||||
|
||||
{!isLoginView && (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3">Initial Balances</h3>
|
||||
<div className="space-y-4 max-h-48 overflow-y-auto pr-2">
|
||||
{initialBalances.map((balance, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Balance Name (e.g., Main Account)"
|
||||
value={balance.name}
|
||||
onChange={e => handleBalanceChange(index, 'name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Amount (€)"
|
||||
value={balance.amount}
|
||||
onChange={e => handleBalanceChange(index, 'amount', e.target.value)}
|
||||
className="w-40 px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
{initialBalances.length > 1 && (
|
||||
<button type="button" onClick={() => handleRemoveBalance(index)} className="p-2 text-gray-400 hover:text-red-500 transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM7 9a1 1 0 000 2h6a1 1 0 100-2H7z" clipRule="evenodd" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={handleAddBalance} className="mt-3 text-sm font-semibold text-gray-700 hover:text-gray-900">+ Add another balance</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-gray-800 text-white py-3 rounded-lg font-semibold hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-800 transition"
|
||||
>
|
||||
{isLoginView ? 'Login' : 'Sign Up'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
{isLoginView ? "Don't have an account?" : 'Already have an account?'}
|
||||
<button onClick={() => { setIsLoginView(!isLoginView); setError(''); }} className="font-semibold text-gray-700 hover:underline ml-1">
|
||||
{isLoginView ? 'Sign Up' : 'Login'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginScreen;
|
||||
85
components/OperationsChart.tsx
Normal file
85
components/OperationsChart.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { Balance, Operation } from '../types';
|
||||
import { format, eachDayOfInterval, isBefore, isEqual } from 'date-fns';
|
||||
|
||||
interface OperationsChartProps {
|
||||
balances: Balance[];
|
||||
allOperations: Operation[];
|
||||
dateRange: { start: Date, end: Date };
|
||||
}
|
||||
|
||||
const OperationsChart: React.FC<OperationsChartProps> = ({ balances, allOperations, dateRange }) => {
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const days = eachDayOfInterval(dateRange);
|
||||
|
||||
const balanceColors = ['#1f2937', '#6b7280', '#ef4444', '#10b981', '#3b82f6', '#a855f7'];
|
||||
|
||||
const data = days.map(day => {
|
||||
const entry: { date: string, [key: string]: number | string } = {
|
||||
date: format(day, 'MMM dd'),
|
||||
};
|
||||
|
||||
balances.forEach(balance => {
|
||||
const balanceAtStartOfRange = balance.initialAmount + allOperations
|
||||
.filter(op => op.balanceId === balance.id && isBefore(new Date(op.date), dateRange.start))
|
||||
.reduce((acc, op) => acc + (op.type === 'income' ? op.amount : -op.amount), 0);
|
||||
|
||||
const dailyTotal = allOperations
|
||||
.filter(op => op.balanceId === balance.id && isBefore(new Date(op.date), day) && isEqual(new Date(op.date), day))
|
||||
.reduce((acc, op) => acc + (op.type === 'income' ? op.amount : -op.amount), balanceAtStartOfRange);
|
||||
|
||||
const operationsInPeriodUntilDay = allOperations
|
||||
.filter(op => {
|
||||
const opDate = new Date(op.date);
|
||||
return op.balanceId === balance.id &&
|
||||
opDate >= dateRange.start &&
|
||||
opDate <= day;
|
||||
})
|
||||
.reduce((acc, op) => acc + (op.type === 'income' ? op.amount : -op.amount), 0);
|
||||
|
||||
entry[balance.name] = balanceAtStartOfRange + operationsInPeriodUntilDay;
|
||||
});
|
||||
|
||||
return entry;
|
||||
});
|
||||
|
||||
return { data, colors: balanceColors };
|
||||
}, [balances, allOperations, dateRange]);
|
||||
|
||||
|
||||
if (!balances.length) {
|
||||
return <p>No balances to display.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: 300 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={chartData.data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} stroke="#6b7280" />
|
||||
<YAxis tick={{ fontSize: 12 }} stroke="#6b7280" tickFormatter={(value) => new Intl.NumberFormat('fr-FR', { notation: 'compact', compactDisplay: 'short' }).format(value as number)}/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#ffffff', border: '1px solid #e5e7eb', borderRadius: '0.5rem' }}
|
||||
formatter={(value) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value as number)}
|
||||
/>
|
||||
<Legend wrapperStyle={{fontSize: "14px"}}/>
|
||||
{balances.map((balance, index) => (
|
||||
<Line
|
||||
key={balance.id}
|
||||
type="monotone"
|
||||
dataKey={balance.name}
|
||||
stroke={chartData.colors[index % chartData.colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OperationsChart;
|
||||
207
components/OperationsTable.tsx
Normal file
207
components/OperationsTable.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { Operation } from '../types';
|
||||
import { format } from 'date-fns';
|
||||
import ConfirmationModal from './ConfirmationModal';
|
||||
|
||||
interface OperationsTableProps {
|
||||
title: 'Income' | 'Expenses';
|
||||
operations: Operation[];
|
||||
onEdit: (operation: Operation) => void;
|
||||
onDelete: (operationId: string) => void;
|
||||
}
|
||||
|
||||
const OperationsTable: React.FC<OperationsTableProps> = ({ title, operations, onEdit, onDelete }) => {
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const [operationToDelete, setOperationToDelete] = useState<Operation | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleGroup = (group: string) => {
|
||||
setCollapsedGroups(prev => ({
|
||||
...prev,
|
||||
[group]: !prev[group]
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
}
|
||||
};
|
||||
const handleScroll = () => {
|
||||
if (openMenuId) {
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
};
|
||||
}, [openMenuId]);
|
||||
|
||||
const toggleMenu = (id: string, e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (openMenuId === id) {
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
} else {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const menuHeight = 100;
|
||||
|
||||
let top = rect.bottom;
|
||||
if (spaceBelow < menuHeight) {
|
||||
top = rect.top - menuHeight;
|
||||
}
|
||||
|
||||
setMenuPosition({
|
||||
top: top,
|
||||
left: rect.right - 128,
|
||||
});
|
||||
setOpenMenuId(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (op: Operation) => {
|
||||
setOperationToDelete(op);
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (operationToDelete) {
|
||||
onDelete(operationToDelete.id);
|
||||
setOperationToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const groupedOperations = useMemo(() => {
|
||||
return operations.reduce((acc, op) => {
|
||||
(acc[op.group] = acc[op.group] || []).push(op);
|
||||
return acc;
|
||||
}, {} as Record<string, Operation[]>);
|
||||
}, [operations]);
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount);
|
||||
};
|
||||
|
||||
const titleColor = title === 'Income' ? 'text-green-600' : 'text-red-600';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className={`text-lg font-semibold mb-3 ${titleColor}`}>{title}</h4>
|
||||
<div className="space-y-4">
|
||||
{Object.keys(groupedOperations).length > 0 ? (
|
||||
Object.keys(groupedOperations).map((group) => {
|
||||
const ops = groupedOperations[group];
|
||||
return (
|
||||
<div key={group} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div
|
||||
className="bg-gray-50 px-6 py-3 border-b border-gray-200 flex justify-between items-center cursor-pointer select-none hover:bg-gray-100 transition-colors"
|
||||
onClick={() => toggleGroup(group)}
|
||||
>
|
||||
<h5 className="font-semibold text-gray-700 text-sm uppercase tracking-wider">{group}</h5>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`h-5 w-5 text-gray-400 transform transition-transform duration-200 ${collapsedGroups[group] ? '-rotate-90' : 'rotate-0'}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
{!collapsedGroups[group] && (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{ops.map(op => (
|
||||
<div key={op.id} className="group relative flex items-center justify-between p-5 hover:bg-gray-50 transition-colors duration-150">
|
||||
<div className="flex-1 min-w-0 pr-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-base font-semibold text-gray-900 truncate">{op.name}</p>
|
||||
<p className="text-sm text-gray-500 whitespace-nowrap ml-4">{format(new Date(op.date), 'MMM dd, yyyy')}</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate">{op.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center pl-4 border-l border-gray-100 ml-4">
|
||||
<div className={`text-base font-bold ${titleColor} mr-6 whitespace-nowrap`}>
|
||||
{formatCurrency(op.amount)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => toggleMenu(op.id, e)}
|
||||
className="p-2 rounded-full text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-10 bg-gray-50 rounded-lg border border-dashed border-gray-300">
|
||||
<p className="text-gray-500">No {title.toLowerCase()} for this period.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed Menu Portal */}
|
||||
{openMenuId && menuPosition && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 w-32 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
style={{ top: menuPosition.top, left: menuPosition.left }}
|
||||
>
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
const op = operations.find(o => o.id === openMenuId);
|
||||
if (op) onEdit(op);
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
}}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const op = operations.find(o => o.id === openMenuId);
|
||||
if (op) handleDeleteClick(op);
|
||||
}}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-gray-100"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={!!operationToDelete}
|
||||
onClose={() => setOperationToDelete(null)}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Operation"
|
||||
message={`Are you sure you want to delete the operation "${operationToDelete?.name}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
isDanger={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OperationsTable;
|
||||
291
components/PDFDocument.tsx
Normal file
291
components/PDFDocument.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import React from 'react';
|
||||
import { Page, Text, View, Document, StyleSheet } from '@react-pdf/renderer';
|
||||
import { format } from 'date-fns';
|
||||
import { Operation, Balance, OperationType } from '../types';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#F9FAFB',
|
||||
padding: 35,
|
||||
fontFamily: 'Helvetica',
|
||||
},
|
||||
header: {
|
||||
marginBottom: 25,
|
||||
paddingBottom: 15,
|
||||
paddingTop: 20,
|
||||
paddingLeft: 25,
|
||||
paddingRight: 25,
|
||||
backgroundColor: '#1F2937',
|
||||
borderRadius: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 8,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 12,
|
||||
color: '#D1D5DB',
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
balanceSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
balanceTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#1F2937',
|
||||
marginTop: 15,
|
||||
marginBottom: 12,
|
||||
paddingBottom: 8,
|
||||
paddingLeft: 10,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: '#3B82F6',
|
||||
backgroundColor: '#EFF6FF',
|
||||
paddingTop: 8,
|
||||
paddingRight: 10,
|
||||
},
|
||||
table: {
|
||||
display: 'flex',
|
||||
width: 'auto',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 1,
|
||||
borderColor: '#D1D5DB',
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
tableRow: {
|
||||
margin: 'auto',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
tableColHeader: {
|
||||
width: '25%',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 1,
|
||||
borderLeftWidth: 0,
|
||||
borderTopWidth: 0,
|
||||
borderColor: '#E5E7EB',
|
||||
backgroundColor: '#F3F4F6',
|
||||
padding: 8,
|
||||
},
|
||||
tableCol: {
|
||||
width: '25%',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 1,
|
||||
borderLeftWidth: 0,
|
||||
borderTopWidth: 0,
|
||||
borderColor: '#E5E7EB',
|
||||
padding: 8,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
tableCellHeader: {
|
||||
margin: 'auto',
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold',
|
||||
color: '#1F2937',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
tableCell: {
|
||||
margin: 'auto',
|
||||
fontSize: 10,
|
||||
color: '#4B5563',
|
||||
},
|
||||
tableCellIncome: {
|
||||
margin: 'auto',
|
||||
fontSize: 10,
|
||||
color: '#059669',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tableCellExpense: {
|
||||
margin: 'auto',
|
||||
fontSize: 10,
|
||||
color: '#DC2626',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tableCellBalance: {
|
||||
margin: 'auto',
|
||||
fontSize: 10,
|
||||
color: '#1F2937',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
totalRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 12,
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: '#EFF6FF',
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
},
|
||||
totalText: {
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
color: '#1F2937',
|
||||
},
|
||||
emptyMessage: {
|
||||
fontSize: 11,
|
||||
color: '#9CA3AF',
|
||||
fontStyle: 'italic',
|
||||
padding: 20,
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderRadius: 6,
|
||||
},
|
||||
summaryCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 15,
|
||||
borderRadius: 8,
|
||||
marginBottom: 15,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: '#3B82F6',
|
||||
}
|
||||
});
|
||||
|
||||
interface PDFDocumentProps {
|
||||
operations: Operation[];
|
||||
balances: Balance[];
|
||||
dateRange: { start: Date; end: Date };
|
||||
associationName: string;
|
||||
}
|
||||
|
||||
const PDFDocument: React.FC<PDFDocumentProps> = ({ operations, balances, dateRange, associationName }) => {
|
||||
|
||||
const summaryData = balances.map(balance => {
|
||||
const balanceOps = operations.filter(op => op.balanceId === balance.id);
|
||||
|
||||
const previousOps = balanceOps.filter(op => new Date(op.date) < dateRange.start);
|
||||
const initialIncome = previousOps.filter(op => op.type === OperationType.INCOME).reduce((sum, op) => sum + op.amount, 0);
|
||||
const initialExpense = previousOps.filter(op => op.type === OperationType.EXPENSE).reduce((sum, op) => sum + op.amount, 0);
|
||||
const startBalance = balance.initialAmount + initialIncome - initialExpense;
|
||||
|
||||
const periodOps = balanceOps.filter(op => {
|
||||
const opDate = new Date(op.date);
|
||||
return opDate >= dateRange.start && opDate <= dateRange.end;
|
||||
});
|
||||
|
||||
const periodIncome = periodOps.filter(op => op.type === OperationType.INCOME).reduce((sum, op) => sum + op.amount, 0);
|
||||
const periodExpense = periodOps.filter(op => op.type === OperationType.EXPENSE).reduce((sum, op) => sum + op.amount, 0);
|
||||
const endBalance = startBalance + periodIncome - periodExpense;
|
||||
|
||||
return {
|
||||
balance,
|
||||
startBalance,
|
||||
endBalance,
|
||||
periodIncome,
|
||||
periodExpense,
|
||||
periodOps: periodOps.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Document>
|
||||
{/* Summary Page */}
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{associationName}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Report Period: {format(dateRange.start, 'MMM dd, yyyy')} - {format(dateRange.end, 'MMM dd, yyyy')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.balanceTitle}>Summary</Text>
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableRow}>
|
||||
<View style={{ ...styles.tableColHeader, width: '20%' }}>
|
||||
<Text style={styles.tableCellHeader}>Balance</Text>
|
||||
</View>
|
||||
<View style={{ ...styles.tableColHeader, width: '20%' }}>
|
||||
<Text style={styles.tableCellHeader}>Start</Text>
|
||||
</View>
|
||||
<View style={{ ...styles.tableColHeader, width: '20%' }}>
|
||||
<Text style={styles.tableCellHeader}>Income</Text>
|
||||
</View>
|
||||
<View style={{ ...styles.tableColHeader, width: '20%' }}>
|
||||
<Text style={styles.tableCellHeader}>Expense</Text>
|
||||
</View>
|
||||
<View style={{ ...styles.tableColHeader, width: '20%' }}>
|
||||
<Text style={styles.tableCellHeader}>End</Text>
|
||||
</View>
|
||||
</View>
|
||||
{summaryData.map((data) => (
|
||||
<View style={styles.tableRow} key={data.balance.id}>
|
||||
<View style={{ ...styles.tableCol, width: '20%' }}>
|
||||
<Text style={styles.tableCell}>{data.balance.name}</Text>
|
||||
</View>
|
||||
<View style={{ ...styles.tableCol, width: '20%' }}>
|
||||
<Text style={styles.tableCellBalance}>{data.startBalance.toFixed(2)} €</Text>
|
||||
</View>
|
||||
<View style={{ ...styles.tableCol, width: '20%' }}>
|
||||
<Text style={styles.tableCellIncome}>+{data.periodIncome.toFixed(2)} €</Text>
|
||||
</View>
|
||||
<View style={{ ...styles.tableCol, width: '20%' }}>
|
||||
<Text style={styles.tableCellExpense}>-{data.periodExpense.toFixed(2)} €</Text>
|
||||
</View>
|
||||
<View style={{ ...styles.tableCol, width: '20%' }}>
|
||||
<Text style={styles.tableCellBalance}>{data.endBalance.toFixed(2)} €</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Page>
|
||||
|
||||
{/* Detailed Pages */}
|
||||
{summaryData.map((data) => (
|
||||
<Page key={data.balance.id} size="A4" style={styles.page}>
|
||||
<View style={styles.balanceSection}>
|
||||
<Text style={styles.balanceTitle}>{data.balance.name} - Details</Text>
|
||||
|
||||
{data.periodOps.length > 0 ? (
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableRow}>
|
||||
<View style={styles.tableColHeader}>
|
||||
<Text style={styles.tableCellHeader}>Date</Text>
|
||||
</View>
|
||||
<View style={styles.tableColHeader}>
|
||||
<Text style={styles.tableCellHeader}>Name</Text>
|
||||
</View>
|
||||
<View style={styles.tableColHeader}>
|
||||
<Text style={styles.tableCellHeader}>Type</Text>
|
||||
</View>
|
||||
<View style={styles.tableColHeader}>
|
||||
<Text style={styles.tableCellHeader}>Amount</Text>
|
||||
</View>
|
||||
</View>
|
||||
{data.periodOps.map((op) => (
|
||||
<View style={styles.tableRow} key={op.id}>
|
||||
<View style={styles.tableCol}>
|
||||
<Text style={styles.tableCell}>{format(new Date(op.date), 'MMM dd, yyyy')}</Text>
|
||||
</View>
|
||||
<View style={styles.tableCol}>
|
||||
<Text style={styles.tableCell}>{op.name}</Text>
|
||||
</View>
|
||||
<View style={styles.tableCol}>
|
||||
<Text style={styles.tableCell}>{op.type}</Text>
|
||||
</View>
|
||||
<View style={styles.tableCol}>
|
||||
<Text style={op.type === OperationType.EXPENSE ? styles.tableCellExpense : styles.tableCellIncome}>
|
||||
{op.type === OperationType.EXPENSE ? '-' : '+'}{op.amount.toFixed(2)} €
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.emptyMessage}>No operations for this period.</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalText}>End Balance: {data.endBalance.toFixed(2)} €</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
))}
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
export default PDFDocument;
|
||||
29
index.html
Normal file
29
index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Abacus - Coodlab</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"date-fns": "https://aistudiocdn.com/date-fns@^4.1.0",
|
||||
"recharts": "https://aistudiocdn.com/recharts@^3.3.0",
|
||||
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
|
||||
"react": "https://aistudiocdn.com/react@^19.2.0",
|
||||
"react/": "https://aistudiocdn.com/react@^19.2.0/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
16
index.tsx
Normal file
16
index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
2759
package-lock.json
generated
Normal file
2759
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "abacus",
|
||||
"private": false,
|
||||
"version": "2025.11.22",
|
||||
"description": "Abacus is a web application for association accounting.",
|
||||
"author": "Coodlab, Mallevaey Lino",
|
||||
"license": "CC BY-NC-SA 4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
15
public/abacus.svg
Normal file
15
public/abacus.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="913" height="325" viewBox="0 0 913 325" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M60.2705 325C48.8656 325 38.6012 322.119 29.4773 316.356C20.3534 310.593 13.1595 302.648 7.8957 292.519C2.6319 282.216 0 270.254 0 256.633C0 242.139 3.24601 229.392 9.73803 218.39C16.2301 207.214 25.1785 198.483 36.5834 192.196C48.1638 185.909 61.5865 182.766 76.8515 182.766C91.7656 182.766 104.75 185.822 115.804 191.934C126.858 197.871 135.455 206.253 141.596 217.08C147.913 227.733 151.071 240.131 151.071 254.276V321.595H103.17V301.425H101.591C99.3104 306.14 96.2398 310.331 92.3797 313.998C88.695 317.491 84.2208 320.198 78.957 322.119C73.6932 324.04 67.4644 325 60.2705 325ZM75.7987 280.994C80.5362 280.994 84.6595 279.859 88.1687 277.589C91.8533 275.319 94.6607 272.175 96.5907 268.159C98.6963 263.968 99.749 259.253 99.749 254.014C99.749 248.95 98.6963 244.497 96.5907 240.655C94.6607 236.813 91.8533 233.757 88.1687 231.487C84.6595 229.217 80.5362 228.082 75.7987 228.082C71.2368 228.082 67.1135 229.217 63.4288 231.487C59.7441 233.757 56.849 236.813 54.7435 240.655C52.8135 244.497 51.8484 248.95 51.8484 254.014C51.8484 259.253 52.8135 263.968 54.7435 268.159C56.849 272.175 59.7441 275.319 63.4288 277.589C67.1135 279.859 71.2368 280.994 75.7987 280.994Z" fill="black"/>
|
||||
<path d="M248.83 325C233.916 325 220.756 321.944 209.351 315.832C197.946 309.72 189.086 301.163 182.769 290.162C176.452 279.16 173.294 266.238 173.294 251.395V130.378H223.827V196.649H225.406C228.038 193.506 231.108 190.886 234.617 188.791C238.127 186.695 241.987 185.211 246.198 184.338C250.409 183.29 254.883 182.766 259.62 182.766C271.727 182.766 282.694 185.647 292.519 191.41C302.345 197.173 310.065 205.206 315.68 215.509C321.47 225.812 324.365 237.948 324.365 251.918C324.365 262.745 322.523 272.612 318.838 281.518C315.329 290.424 310.241 298.195 303.573 304.831C296.906 311.292 288.922 316.269 279.623 319.761C270.499 323.254 260.235 325 248.83 325ZM248.83 280.994C253.567 280.994 257.69 279.859 261.2 277.589C264.884 275.319 267.692 272.175 269.622 268.159C271.727 263.968 272.78 259.253 272.78 254.014C272.78 248.95 271.727 244.497 269.622 240.655C267.692 236.813 264.884 233.757 261.2 231.487C257.69 229.217 253.567 228.082 248.83 228.082C244.268 228.082 240.144 229.217 236.46 231.487C232.775 233.757 229.88 236.813 227.775 240.655C225.844 244.497 224.879 248.95 224.879 254.014C224.879 259.253 225.844 263.968 227.775 268.159C229.88 272.175 232.775 275.319 236.46 277.589C240.144 279.859 244.268 280.994 248.83 280.994Z" fill="black"/>
|
||||
<path d="M401.338 325C389.933 325 379.669 322.119 370.545 316.356C361.421 310.593 354.227 302.648 348.963 292.519C343.699 282.216 341.068 270.254 341.068 256.633C341.068 242.139 344.314 229.392 350.806 218.39C357.298 207.214 366.246 198.483 377.651 192.196C389.231 185.909 402.654 182.766 417.919 182.766C432.833 182.766 445.817 185.822 456.871 191.934C467.925 197.871 476.523 206.253 482.664 217.08C488.98 227.733 492.139 240.131 492.139 254.276V321.595H444.238V301.425H442.659C440.378 306.14 437.307 310.331 433.447 313.998C429.763 317.491 425.288 320.198 420.025 322.119C414.761 324.04 408.532 325 401.338 325ZM416.866 280.994C421.604 280.994 425.727 279.859 429.236 277.589C432.921 275.319 435.728 272.175 437.658 268.159C439.764 263.968 440.817 259.253 440.817 254.014C440.817 248.95 439.764 244.497 437.658 240.655C435.728 236.813 432.921 233.757 429.236 231.487C425.727 229.217 421.604 228.082 416.866 228.082C412.304 228.082 408.181 229.217 404.496 231.487C400.812 233.757 397.917 236.813 395.811 240.655C393.881 244.497 392.916 248.95 392.916 254.014C392.916 259.253 393.881 263.968 395.811 268.159C397.917 272.175 400.812 275.319 404.496 277.589C408.181 279.859 412.304 280.994 416.866 280.994Z" fill="black"/>
|
||||
<path d="M585.16 321.595C570.948 321.595 558.314 318.801 547.26 313.213C536.206 307.45 527.521 299.504 521.205 289.376C514.888 279.248 511.73 267.46 511.73 254.014C511.73 240.568 514.888 228.78 521.205 218.652C527.521 208.349 536.206 200.316 547.26 194.553C558.314 188.791 570.948 185.909 585.16 185.909H612.005V227.296H589.634C584.019 227.296 579.106 228.518 574.895 230.963C570.86 233.233 567.702 236.377 565.421 240.393C563.14 244.41 561.999 248.95 561.999 254.014C561.999 259.078 563.14 263.706 565.421 267.897C567.702 271.913 570.86 275.144 574.895 277.589C579.106 279.859 584.019 280.994 589.634 280.994H612.005V321.595H585.16Z" fill="black"/>
|
||||
<path d="M701.615 325C687.403 325 674.945 322.381 664.242 317.142C653.539 311.728 645.205 304.481 639.239 295.401C633.274 286.145 630.291 275.668 630.291 263.968V185.909H680.56V260.824C680.56 263.968 681.35 267.024 682.929 269.992C684.508 272.961 686.789 275.406 689.772 277.327C692.93 279.073 696.527 279.946 700.562 279.946C704.598 279.946 708.02 279.073 710.827 277.327C713.81 275.406 716.091 272.961 717.67 269.992C719.249 267.024 720.039 263.968 720.039 260.824V185.909H770.571V263.968C770.571 275.668 767.764 286.145 762.149 295.401C756.71 304.481 748.814 311.728 738.462 317.142C728.11 322.381 715.827 325 701.615 325Z" fill="black"/>
|
||||
<path d="M792.985 321.595V285.971H856.677C858.081 285.971 859.221 285.709 860.099 285.185C860.976 284.486 861.678 283.701 862.204 282.827C862.731 281.78 862.994 280.732 862.994 279.684C862.994 278.636 862.731 277.676 862.204 276.803C861.678 275.755 860.976 274.969 860.099 274.445C859.221 273.922 858.081 273.66 856.677 273.66H836.149C827.2 273.66 819.041 272.175 811.672 269.207C804.302 266.063 798.425 261.261 794.038 254.8C789.827 248.164 787.722 239.695 787.722 229.392C787.722 221.359 789.652 214.112 793.512 207.65C797.547 201.015 802.987 195.776 809.829 191.934C816.672 187.918 824.217 185.909 832.464 185.909H901.683V221.533H844.307C842.553 221.533 841.061 222.145 839.833 223.367C838.78 224.589 838.254 225.986 838.254 227.558C838.254 229.13 838.78 230.527 839.833 231.749C841.061 232.971 842.553 233.583 844.307 233.583H863.257C873.434 233.583 882.207 235.154 889.576 238.298C897.121 241.266 902.911 245.981 906.947 252.442C910.982 258.904 913 267.286 913 277.589C913 285.622 910.894 292.956 906.683 299.592C902.648 306.228 897.209 311.554 890.366 315.57C883.523 319.587 875.802 321.595 867.205 321.595H792.985Z" fill="black"/>
|
||||
<path d="M314.263 128.097C305.121 128.097 296.963 126.108 289.788 122.131C282.613 118.153 276.943 112.83 272.777 106.162C268.611 99.3773 266.528 91.7734 266.528 83.3506C266.528 74.9278 268.611 67.3824 272.777 60.7143C276.943 53.9293 282.613 48.5481 289.788 44.5706C296.963 40.5932 305.121 38.6045 314.263 38.6045H323.637V42.6404H312.875C305.468 42.6404 298.583 44.4537 292.218 48.0801C285.854 51.7066 280.704 56.6199 276.769 62.82C272.835 68.9032 270.868 75.7467 270.868 83.3506C270.868 90.9545 272.835 97.8565 276.769 104.057C280.704 110.14 285.854 114.995 292.218 118.621C298.583 122.248 305.468 124.061 312.875 124.061H323.637V128.097H314.263Z" fill="black"/>
|
||||
<path d="M384.882 130.378C376.087 130.378 368.333 128.331 361.621 124.236C354.91 120.025 349.644 114.351 345.825 107.215C342.007 100.079 340.097 92.1244 340.097 83.3506C340.097 74.5769 342.007 66.622 345.825 59.486C349.644 52.35 354.91 46.7348 361.621 42.6404C368.333 38.429 376.087 36.3233 384.882 36.3233C393.676 36.3233 401.43 38.429 408.142 42.6404C414.853 46.7348 420.119 52.35 423.938 59.486C427.756 66.505 429.666 74.4599 429.666 83.3506C429.666 92.1244 427.756 100.079 423.938 107.215C420.119 114.351 414.853 120.025 408.142 124.236C401.43 128.331 393.676 130.378 384.882 130.378ZM384.882 126.342C392.751 126.342 399.694 124.47 405.711 120.727C411.845 116.866 416.647 111.719 420.119 105.285C423.59 98.7339 425.326 91.4225 425.326 83.3506C425.326 75.2788 423.59 68.0258 420.119 61.5917C416.647 55.0406 411.845 49.8934 405.711 46.1499C399.694 42.2895 392.751 40.3592 384.882 40.3592C377.012 40.3592 370.011 42.2895 363.878 46.1499C357.86 49.8934 353.116 55.0406 349.644 61.5917C346.173 68.0258 344.437 75.2788 344.437 83.3506C344.437 91.4225 346.173 98.7339 349.644 105.285C353.116 111.719 357.86 116.866 363.878 120.727C370.011 124.47 377.012 126.342 384.882 126.342Z" fill="black"/>
|
||||
<path d="M495.235 130.378C486.44 130.378 478.687 128.331 471.975 124.236C465.263 120.025 459.998 114.351 456.179 107.215C452.36 100.079 450.451 92.1244 450.451 83.3506C450.451 74.5769 452.36 66.622 456.179 59.486C459.998 52.35 465.263 46.7348 471.975 42.6404C478.687 38.429 486.44 36.3233 495.235 36.3233C504.03 36.3233 511.784 38.429 518.495 42.6404C525.207 46.7348 530.473 52.35 534.291 59.486C538.11 66.505 540.02 74.4599 540.02 83.3506C540.02 92.1244 538.11 100.079 534.291 107.215C530.473 114.351 525.207 120.025 518.495 124.236C511.784 128.331 504.03 130.378 495.235 130.378ZM495.235 126.342C503.104 126.342 510.048 124.47 516.065 120.727C522.199 116.866 527.001 111.719 530.473 105.285C533.944 98.7339 535.68 91.4225 535.68 83.3506C535.68 75.2788 533.944 68.0258 530.473 61.5917C527.001 55.0406 522.199 49.8934 516.065 46.1499C510.048 42.2895 503.104 40.3592 495.235 40.3592C487.366 40.3592 480.365 42.2895 474.232 46.1499C468.214 49.8934 463.47 55.0406 459.998 61.5917C456.526 68.0258 454.791 75.2788 454.791 83.3506C454.791 91.4225 456.526 98.7339 459.998 105.285C463.47 111.719 468.214 116.866 474.232 120.727C480.365 124.47 487.366 126.342 495.235 126.342Z" fill="black"/>
|
||||
<path d="M604.721 130.378C599.282 130.378 593.959 129.442 588.752 127.57C583.544 125.582 578.8 122.657 574.518 118.797C570.352 114.819 566.996 109.906 564.45 104.057C562.02 98.0905 560.805 91.1885 560.805 83.3506C560.805 73.29 562.888 64.8088 567.054 57.9067C571.22 50.8877 576.601 45.565 583.197 41.9385C589.909 38.312 597.026 36.4988 604.548 36.4988C610.218 36.4988 615.599 37.6101 620.691 39.8328C625.783 42.0555 630.296 45.0971 634.23 48.9575C638.165 52.701 641.174 57.0294 643.257 61.9427H643.777V0H648.117V84.7544C648.117 94.815 645.976 103.238 641.694 110.023C637.528 116.808 632.147 121.897 625.551 125.289C618.955 128.682 612.012 130.378 604.721 130.378ZM604.895 126.342C611.375 126.342 617.566 124.763 623.468 121.604C629.486 118.446 634.346 113.708 638.049 107.391C641.868 101.074 643.777 93.1772 643.777 83.7016C643.777 74.1089 641.868 66.1541 638.049 59.837C634.23 53.4029 629.312 48.6066 623.295 45.448C617.393 42.1725 611.086 40.5347 604.374 40.5347C597.778 40.5347 591.471 42.231 585.454 45.6235C579.552 49.016 574.691 53.9293 570.873 60.3634C567.054 66.6805 565.144 74.3429 565.144 83.3506C565.144 92.8263 567.054 100.781 570.873 107.215C574.807 113.532 579.783 118.329 585.801 121.604C591.934 124.763 598.299 126.342 604.895 126.342Z" fill="black"/>
|
||||
<path d="M677.702 128.097V0H682.042V128.097H677.702Z" fill="black"/>
|
||||
<path d="M751.355 130.378C741.982 130.378 733.997 128.331 727.401 124.236C720.805 120.025 715.771 114.351 712.299 107.215C708.943 100.079 707.265 92.0659 707.265 83.1751C707.265 74.2844 709.059 66.3295 712.646 59.3105C716.349 52.2915 721.557 46.7348 728.269 42.6404C735.096 38.546 743.197 36.4988 752.57 36.4988C761.134 36.4988 768.656 38.4875 775.136 42.4649C781.732 46.4424 786.882 51.8236 790.585 58.6086C794.288 65.3937 796.14 73.1146 796.14 81.7713V128.097H791.8V104.759H791.279C789.659 108.502 787.055 112.362 783.468 116.34C779.881 120.317 775.368 123.651 769.929 126.342C764.605 129.033 758.414 130.378 751.355 130.378ZM751.355 126.517C758.993 126.517 765.82 124.646 771.838 120.902C777.971 117.159 782.774 112.012 786.245 105.46C789.833 98.7924 791.626 91.247 791.626 82.8242C791.626 74.6354 790.006 67.3824 786.766 61.0653C783.526 54.6312 778.955 49.6009 773.053 45.9744C767.151 42.231 760.208 40.3592 752.223 40.3592C743.775 40.3592 736.543 42.2895 730.525 46.1499C724.508 49.8934 719.879 55.0991 716.639 61.7672C713.398 68.3183 711.778 75.8052 711.778 84.228C711.778 91.9489 713.283 99.0264 716.292 105.46C719.416 111.895 723.929 117.042 729.831 120.902C735.733 124.646 742.908 126.517 751.355 126.517Z" fill="black"/>
|
||||
<path d="M869.084 130.378C861.909 130.378 854.966 128.682 848.254 125.289C841.658 121.897 836.219 116.808 831.937 110.023C827.771 103.238 825.688 94.815 825.688 84.7544V0H830.028V61.9427H830.548C832.631 57.0294 835.64 52.701 839.575 48.9575C843.509 45.0971 848.022 42.0555 853.114 39.8328C858.206 37.6101 863.587 36.4988 869.257 36.4988C876.779 36.4988 883.838 38.312 890.434 41.9385C897.146 45.565 902.585 50.8877 906.751 57.9067C910.917 64.8088 913 73.29 913 83.3506C913 91.1885 911.727 98.0905 909.181 104.057C906.751 109.906 903.395 114.819 899.113 118.797C894.947 122.657 890.261 125.582 885.053 127.57C879.846 129.442 874.523 130.378 869.084 130.378ZM868.91 126.342C875.506 126.342 881.813 124.763 887.831 121.604C893.964 118.329 898.94 113.532 902.759 107.215C906.693 100.781 908.66 92.8263 908.66 83.3506C908.66 74.3429 906.751 66.6805 902.932 60.3634C899.113 53.9293 894.195 49.016 888.178 45.6235C882.276 42.231 876.027 40.5347 869.431 40.5347C862.835 40.5347 856.528 42.1725 850.51 45.448C844.493 48.6066 839.575 53.4029 835.756 59.837C831.937 66.1541 830.028 74.1089 830.028 83.7016C830.028 93.1772 831.879 101.074 835.582 107.391C839.401 113.708 844.261 118.446 850.163 121.604C856.181 124.763 862.43 126.342 868.91 126.342Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
42
run_prod.py
Normal file
42
run_prod.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import subprocess
|
||||
import shutil
|
||||
import os
|
||||
import sys
|
||||
|
||||
def run_command(command, cwd=None):
|
||||
print(f"Running: {command}")
|
||||
result = subprocess.run(command, shell=True, cwd=cwd)
|
||||
if result.returncode != 0:
|
||||
print(f"Error running command: {command}")
|
||||
sys.exit(1)
|
||||
|
||||
def main():
|
||||
root_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
backend_dir = os.path.join(root_dir, "backend")
|
||||
static_dir = os.path.join(backend_dir, "static")
|
||||
|
||||
print("Building Frontend...")
|
||||
run_command("npm run build", cwd=root_dir)
|
||||
|
||||
print("Copying build artifacts to backend/static...")
|
||||
if os.path.exists(static_dir):
|
||||
shutil.rmtree(static_dir)
|
||||
|
||||
dist_dir = os.path.join(root_dir, "dist")
|
||||
if not os.path.exists(dist_dir):
|
||||
print("Error: dist directory not found. Build failed?")
|
||||
sys.exit(1)
|
||||
|
||||
shutil.copytree(dist_dir, static_dir)
|
||||
print("Artifacts copied.")
|
||||
|
||||
print("Starting Production Server...")
|
||||
os.environ["PYTHONPATH"] = backend_dir
|
||||
run_command("python cli.py start --host 0.0.0.0 --port 9874 --no-reload", cwd=backend_dir)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down...")
|
||||
sys.exit(0)
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
32
types.ts
Normal file
32
types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
export enum OperationType {
|
||||
INCOME = 'income',
|
||||
EXPENSE = 'expense',
|
||||
}
|
||||
|
||||
export interface Operation {
|
||||
id: string;
|
||||
balanceId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
group: string;
|
||||
amount: number;
|
||||
type: OperationType;
|
||||
date: string;
|
||||
invoice?: string;
|
||||
}
|
||||
|
||||
export interface Balance {
|
||||
id: string;
|
||||
name: string;
|
||||
initialAmount: number;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface Association {
|
||||
id: string;
|
||||
name: string;
|
||||
password: string;
|
||||
balances: Balance[];
|
||||
operations: Operation[];
|
||||
}
|
||||
29
vite.config.ts
Normal file
29
vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
server: {
|
||||
port: 9873,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user