Version : 2025.11.22
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user