Version : 2025.11.22

This commit is contained in:
2025-11-23 16:34:11 +01:00
commit b399e803b3
31 changed files with 5810 additions and 0 deletions

View 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;

View 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
View 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;

View 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">&#8203;</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
View 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;

View 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
View 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
View 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;

View 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;

View 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
View 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;