feat: Add reports feature
Enables a new reports section in the application. This includes: - Adding a `reports` flag to `AppFeatures` and `AppSettings`. - Including a new "Reportistica" link in the main navigation for privileged users. - Adding a `getCondoPayments` endpoint to the mock DB service. - Updating the backend to support filtering payments by `condoId`. - Providing a basic `ReportsPage` component.
This commit is contained in:
BIN
.dockerignore
BIN
.dockerignore
Binary file not shown.
@@ -6,6 +6,6 @@ dist
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
Dockerfile
|
Dockerfile
|
||||||
server/node_modules
|
server
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
|||||||
2
App.tsx
2
App.tsx
@@ -6,6 +6,7 @@ import { FamilyList } from './pages/FamilyList';
|
|||||||
import { FamilyDetail } from './pages/FamilyDetail';
|
import { FamilyDetail } from './pages/FamilyDetail';
|
||||||
import { SettingsPage } from './pages/Settings';
|
import { SettingsPage } from './pages/Settings';
|
||||||
import { TicketsPage } from './pages/Tickets';
|
import { TicketsPage } from './pages/Tickets';
|
||||||
|
import { ReportsPage } from './pages/Reports';
|
||||||
import { LoginPage } from './pages/Login';
|
import { LoginPage } from './pages/Login';
|
||||||
import { CondoService } from './services/mockDb';
|
import { CondoService } from './services/mockDb';
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ const App: React.FC = () => {
|
|||||||
<Route index element={<FamilyList />} />
|
<Route index element={<FamilyList />} />
|
||||||
<Route path="family/:id" element={<FamilyDetail />} />
|
<Route path="family/:id" element={<FamilyDetail />} />
|
||||||
<Route path="tickets" element={<TicketsPage />} />
|
<Route path="tickets" element={<TicketsPage />} />
|
||||||
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -1,14 +0,0 @@
|
|||||||
# Stage 1: Build Frontend
|
|
||||||
FROM node:18-alpine as build
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm install
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Stage 2: Serve with Nginx
|
|
||||||
FROM nginx:alpine
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ RUN npm run build
|
|||||||
# Stage 2: Serve with Nginx
|
# Stage 2: Serve with Nginx
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
# Copy the nginx configuration file (using the .txt extension as provided in source)
|
||||||
|
COPY nginx.txt /etc/nginx/nginx.conf
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import { Users, Settings, Building, LogOut, Menu, X, ChevronDown, Check, LayoutDashboard, Megaphone, Info, AlertTriangle, Hammer, Calendar, MessageSquareWarning } from 'lucide-react';
|
import { Users, Settings, Building, LogOut, Menu, X, ChevronDown, Check, LayoutDashboard, Megaphone, Info, AlertTriangle, Hammer, Calendar, MessageSquareWarning, PieChart } from 'lucide-react';
|
||||||
import { CondoService } from '../services/mockDb';
|
import { CondoService } from '../services/mockDb';
|
||||||
import { Condo, Notice, AppSettings } from '../types';
|
import { Condo, Notice, AppSettings } from '../types';
|
||||||
|
|
||||||
@@ -240,6 +240,14 @@ export const Layout: React.FC = () => {
|
|||||||
<span className="font-medium">Famiglie</span>
|
<span className="font-medium">Famiglie</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
{/* Privileged Links */}
|
||||||
|
{isAdmin && settings?.features.reports && (
|
||||||
|
<NavLink to="/reports" className={navClass} onClick={closeMenu}>
|
||||||
|
<PieChart className="w-5 h-5" />
|
||||||
|
<span className="font-medium">Reportistica</span>
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Hide Tickets if disabled */}
|
{/* Hide Tickets if disabled */}
|
||||||
{settings?.features.tickets && (
|
{settings?.features.tickets && (
|
||||||
<NavLink to="/tickets" className={navClass} onClick={closeMenu}>
|
<NavLink to="/tickets" className={navClass} onClick={closeMenu}>
|
||||||
@@ -264,7 +272,7 @@ export const Layout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => CondoService.logout()}
|
onClick={() => CondoService.logout()}
|
||||||
className="flex items-center gap-3 px-4 py-2.5 w-full text-slate-600 hover:bg-red-50 hover:text-red-600 rounded-lg transition-colors text-sm font-medium border border-slate-200 hover:border-red-200 bg-white"
|
className="flex-1 flex items-center gap-3 px-4 py-2.5 w-full text-slate-600 hover:bg-red-50 hover:text-red-600 rounded-lg transition-colors text-sm font-medium border border-slate-200 hover:border-red-200 bg-white"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
Esci
|
Esci
|
||||||
|
|||||||
38
nginx.conf
38
nginx.conf
@@ -1,38 +0,0 @@
|
|||||||
worker_processes 1;
|
|
||||||
|
|
||||||
events { worker_connections 1024; }
|
|
||||||
|
|
||||||
http {
|
|
||||||
include mime.types;
|
|
||||||
default_type application/octet-stream;
|
|
||||||
sendfile on;
|
|
||||||
keepalive_timeout 65;
|
|
||||||
|
|
||||||
# Limite upload per allegati (es. foto/video ticket)
|
|
||||||
client_max_body_size 50M;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Compressione Gzip
|
|
||||||
gzip on;
|
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
|
||||||
|
|
||||||
# Gestione SPA (React Router)
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy API verso il backend
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://backend:3001;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ http {
|
|||||||
sendfile on;
|
sendfile on;
|
||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
|
|
||||||
# Limite upload per allegati (es. foto/video ticket)
|
|
||||||
client_max_body_size 50M;
|
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Limite upload per allegati (es. foto/video ticket) - Allineato con il backend
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
# Compressione Gzip
|
# Compressione Gzip
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|||||||
273
pages/Reports.tsx
Normal file
273
pages/Reports.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
|
||||||
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { CondoService } from '../services/mockDb';
|
||||||
|
import { Payment, Family, Condo } from '../types';
|
||||||
|
import { PieChart, Download, Calendar, Search, CreditCard, Banknote, Filter, ArrowUpRight } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
"Gennaio", "Febbraio", "Marzo", "Aprile", "Maggio", "Giugno",
|
||||||
|
"Luglio", "Agosto", "Settembre", "Ottobre", "Novembre", "Dicembre"
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ReportsPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [payments, setPayments] = useState<Payment[]>([]);
|
||||||
|
const [families, setFamilies] = useState<Family[]>([]);
|
||||||
|
const [activeCondo, setActiveCondo] = useState<Condo | undefined>(undefined);
|
||||||
|
const [availableYears, setAvailableYears] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState<number | 'ALL'>('ALL');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const settings = await CondoService.getSettings();
|
||||||
|
if (!settings.features.reports) {
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const condo = await CondoService.getActiveCondo();
|
||||||
|
setActiveCondo(condo);
|
||||||
|
|
||||||
|
if (condo) {
|
||||||
|
const [payList, famList, years] = await Promise.all([
|
||||||
|
CondoService.getCondoPayments(condo.id),
|
||||||
|
CondoService.getFamilies(condo.id),
|
||||||
|
CondoService.getAvailableYears()
|
||||||
|
]);
|
||||||
|
setPayments(payList);
|
||||||
|
setFamilies(famList);
|
||||||
|
setAvailableYears(years);
|
||||||
|
if (years.length > 0 && !years.includes(selectedYear)) {
|
||||||
|
setSelectedYear(years[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error loading reports", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// Filter Logic
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return payments.filter(p => {
|
||||||
|
const matchesYear = p.forYear === selectedYear;
|
||||||
|
const matchesMonth = selectedMonth === 'ALL' || p.forMonth === selectedMonth;
|
||||||
|
|
||||||
|
const familyName = families.find(f => f.id === p.familyId)?.name.toLowerCase() || '';
|
||||||
|
const matchesSearch = searchTerm === '' ||
|
||||||
|
familyName.includes(searchTerm.toLowerCase()) ||
|
||||||
|
(p.notes && p.notes.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
|
|
||||||
|
return matchesYear && matchesMonth && matchesSearch;
|
||||||
|
}).map(p => {
|
||||||
|
const isPayPal = p.notes && p.notes.includes("PayPal");
|
||||||
|
const family = families.find(f => f.id === p.familyId);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
familyName: family ? family.name : 'Sconosciuto',
|
||||||
|
familyUnit: family ? family.unitNumber : '-',
|
||||||
|
method: isPayPal ? 'PayPal' : 'Manuale'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [payments, families, selectedYear, selectedMonth, searchTerm]);
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const totalAmount = filteredData.reduce((acc, curr) => acc + curr.amount, 0);
|
||||||
|
const paypalAmount = filteredData.filter(p => p.method === 'PayPal').reduce((acc, curr) => acc + curr.amount, 0);
|
||||||
|
const manualAmount = filteredData.filter(p => p.method === 'Manuale').reduce((acc, curr) => acc + curr.amount, 0);
|
||||||
|
const count = filteredData.length;
|
||||||
|
|
||||||
|
return { totalAmount, paypalAmount, manualAmount, count };
|
||||||
|
}, [filteredData]);
|
||||||
|
|
||||||
|
const handleExportCSV = () => {
|
||||||
|
if (!activeCondo) return;
|
||||||
|
|
||||||
|
const headers = ["Data Pagamento", "Famiglia", "Interno", "Mese Rif.", "Anno Rif.", "Importo", "Metodo", "Note"];
|
||||||
|
const rows = filteredData.map(p => [
|
||||||
|
new Date(p.datePaid).toLocaleDateString(),
|
||||||
|
p.familyName,
|
||||||
|
p.familyUnit,
|
||||||
|
MONTH_NAMES[p.forMonth - 1],
|
||||||
|
p.forYear,
|
||||||
|
p.amount.toFixed(2),
|
||||||
|
p.method,
|
||||||
|
p.notes ? `"${p.notes.replace(/"/g, '""')}"` : ""
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = "data:text/csv;charset=utf-8,"
|
||||||
|
+ headers.join(",") + "\n"
|
||||||
|
+ rows.map(e => e.join(",")).join("\n");
|
||||||
|
|
||||||
|
const encodedUri = encodeURI(csvContent);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.setAttribute("href", encodedUri);
|
||||||
|
link.setAttribute("download", `Report_Pagamenti_${activeCondo.name}_${selectedYear}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-center text-slate-400">Caricamento report...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 pb-20 animate-fade-in">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between md:items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
|
||||||
|
<PieChart className="w-6 h-6 text-blue-600"/> Reportistica & Transazioni
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500 text-sm">Analisi incassi per {activeCondo?.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleExportCSV} className="bg-slate-800 text-white px-4 py-2 rounded-lg font-medium flex items-center gap-2 hover:bg-slate-900 transition-colors">
|
||||||
|
<Download className="w-4 h-4"/> Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Bar */}
|
||||||
|
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="flex-1 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase mb-1 block">Anno</label>
|
||||||
|
<select
|
||||||
|
value={selectedYear}
|
||||||
|
onChange={e => setSelectedYear(parseInt(e.target.value))}
|
||||||
|
className="w-full border p-2 rounded-lg text-slate-700 bg-slate-50 font-medium"
|
||||||
|
>
|
||||||
|
{availableYears.map(y => <option key={y} value={y}>{y}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase mb-1 block">Mese</label>
|
||||||
|
<select
|
||||||
|
value={selectedMonth}
|
||||||
|
onChange={e => setSelectedMonth(e.target.value === 'ALL' ? 'ALL' : parseInt(e.target.value))}
|
||||||
|
className="w-full border p-2 rounded-lg text-slate-700 bg-slate-50 font-medium"
|
||||||
|
>
|
||||||
|
<option value="ALL">Tutti</option>
|
||||||
|
{MONTH_NAMES.map((m, i) => <option key={i} value={i+1}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase mb-1 block">Cerca Famiglia/ID</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cerca..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full border p-2 pl-9 rounded-lg text-slate-700 bg-slate-50"
|
||||||
|
/>
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-2.5"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-blue-600 text-white p-6 rounded-xl shadow-lg shadow-blue-200">
|
||||||
|
<p className="text-blue-100 text-sm font-medium mb-1">Incasso Totale</p>
|
||||||
|
<h3 className="text-3xl font-bold">€ {stats.totalAmount.toLocaleString('it-IT', { minimumFractionDigits: 2 })}</h3>
|
||||||
|
<p className="text-blue-200 text-xs mt-2 flex items-center gap-1">
|
||||||
|
<ArrowUpRight className="w-3 h-3"/> su {stats.count} transazioni
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500 text-xs font-bold uppercase mb-1 flex items-center gap-1"><CreditCard className="w-3 h-3"/> PayPal / Online</p>
|
||||||
|
<h3 className="text-2xl font-bold text-slate-800">€ {stats.paypalAmount.toLocaleString('it-IT', { minimumFractionDigits: 2 })}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-10 bg-blue-50 text-blue-600 rounded-full flex items-center justify-center font-bold text-xs">
|
||||||
|
{stats.count > 0 ? Math.round((stats.paypalAmount / stats.totalAmount) * 100) : 0}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500 text-xs font-bold uppercase mb-1 flex items-center gap-1"><Banknote className="w-3 h-3"/> Manuale / Bonifico</p>
|
||||||
|
<h3 className="text-2xl font-bold text-slate-800">€ {stats.manualAmount.toLocaleString('it-IT', { minimumFractionDigits: 2 })}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-10 bg-slate-100 text-slate-600 rounded-full flex items-center justify-center font-bold text-xs">
|
||||||
|
{stats.count > 0 ? Math.round((stats.manualAmount / stats.totalAmount) * 100) : 0}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transactions Table */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
|
||||||
|
<h3 className="font-bold text-slate-800">Dettaglio Transazioni</h3>
|
||||||
|
<span className="text-xs bg-slate-200 text-slate-600 px-2 py-1 rounded-full font-bold">{filteredData.length} risultati</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm text-slate-600">
|
||||||
|
<thead className="bg-slate-50 text-slate-700 font-semibold border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 whitespace-nowrap">Data</th>
|
||||||
|
<th className="px-6 py-3">Famiglia</th>
|
||||||
|
<th className="px-6 py-3">Riferimento</th>
|
||||||
|
<th className="px-6 py-3">Metodo</th>
|
||||||
|
<th className="px-6 py-3">Note / ID Transazione</th>
|
||||||
|
<th className="px-6 py-3 text-right">Importo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{filteredData.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="p-8 text-center text-slate-400">Nessuna transazione trovata con i filtri attuali.</td></tr>
|
||||||
|
) : (
|
||||||
|
filteredData.map((t) => (
|
||||||
|
<tr key={t.id} className="hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-slate-500 font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-slate-300"/>
|
||||||
|
{new Date(t.datePaid).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-bold text-slate-700">{t.familyName}</div>
|
||||||
|
<div className="text-xs text-slate-400">Int. {t.familyUnit}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="bg-slate-100 text-slate-600 px-2 py-1 rounded text-xs font-bold uppercase">
|
||||||
|
{MONTH_NAMES[t.forMonth - 1].substring(0, 3)} {t.forYear}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-bold uppercase ${t.method === 'PayPal' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}`}>
|
||||||
|
{t.method === 'PayPal' ? <CreditCard className="w-3 h-3"/> : <Banknote className="w-3 h-3"/>}
|
||||||
|
{t.method}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 max-w-xs truncate text-slate-500 text-xs" title={t.notes}>
|
||||||
|
{t.notes || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right font-bold text-slate-800">
|
||||||
|
€ {t.amount.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { CondoService } from '../services/mockDb';
|
import { CondoService } from '../services/mockDb';
|
||||||
import { AppSettings, Family, User, AlertDefinition, Condo, Notice, NoticeIconType, NoticeRead } from '../types';
|
import { AppSettings, Family, User, AlertDefinition, Condo, Notice, NoticeIconType, NoticeRead } from '../types';
|
||||||
import { Save, Building, Coins, Plus, Pencil, Trash2, X, CalendarCheck, AlertTriangle, User as UserIcon, Server, Bell, Clock, FileText, Lock, Megaphone, CheckCircle2, Info, Hammer, Link as LinkIcon, Eye, Calendar, List, UserCog, Mail, Power, MapPin, CreditCard, ToggleLeft, ToggleRight, LayoutGrid } from 'lucide-react';
|
import { Save, Building, Coins, Plus, Pencil, Trash2, X, CalendarCheck, AlertTriangle, User as UserIcon, Server, Bell, Clock, FileText, Lock, Megaphone, CheckCircle2, Info, Hammer, Link as LinkIcon, Eye, Calendar, List, UserCog, Mail, Power, MapPin, CreditCard, ToggleLeft, ToggleRight, LayoutGrid, PieChart } from 'lucide-react';
|
||||||
|
|
||||||
export const SettingsPage: React.FC = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
const currentUser = CondoService.getCurrentUser();
|
const currentUser = CondoService.getCurrentUser();
|
||||||
@@ -623,6 +623,17 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<span className={`${globalSettings.features.notices ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}/>
|
<span className={`${globalSettings.features.notices ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reports */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-slate-800">Reportistica Avanzata</p>
|
||||||
|
<p className="text-sm text-slate-500">Abilita grafici e tabelle dettagliate sui pagamenti per amministratori.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => toggleFeature('reports')} className={`${globalSettings.features.reports ? 'bg-green-500' : 'bg-slate-300'} relative inline-flex h-6 w-11 items-center rounded-full transition-colors`}>
|
||||||
|
<span className={`${globalSettings.features.reports ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 flex justify-between items-center">
|
<div className="pt-2 flex justify-between items-center">
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
FROM node:18-alpine
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm install --production
|
|
||||||
COPY . .
|
|
||||||
EXPOSE 3001
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Set production environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --production
|
RUN npm install --production
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -322,7 +322,8 @@ const initDb = async () => {
|
|||||||
multiCondo: true,
|
multiCondo: true,
|
||||||
tickets: true,
|
tickets: true,
|
||||||
payPal: true,
|
payPal: true,
|
||||||
notices: true
|
notices: true,
|
||||||
|
reports: true
|
||||||
};
|
};
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ app.get('/api/settings', authenticateToken, async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
currentYear: rows[0].current_year,
|
currentYear: rows[0].current_year,
|
||||||
smtpConfig: rows[0].smtp_config || {},
|
smtpConfig: rows[0].smtp_config || {},
|
||||||
features: rows[0].features || { multiCondo: true, tickets: true, payPal: true, notices: true }
|
features: rows[0].features || { multiCondo: true, tickets: true, payPal: true, notices: true, reports: true }
|
||||||
});
|
});
|
||||||
} else { res.status(404).json({ message: 'Settings not found' }); }
|
} else { res.status(404).json({ message: 'Settings not found' }); }
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
@@ -349,7 +349,7 @@ app.get('/api/notices/unread', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
// --- PAYMENTS ---
|
// --- PAYMENTS ---
|
||||||
app.get('/api/payments', authenticateToken, async (req, res) => {
|
app.get('/api/payments', authenticateToken, async (req, res) => {
|
||||||
const { familyId } = req.query;
|
const { familyId, condoId } = req.query;
|
||||||
try {
|
try {
|
||||||
const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser';
|
const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser';
|
||||||
if (!isPrivileged) {
|
if (!isPrivileged) {
|
||||||
@@ -359,12 +359,27 @@ app.get('/api/payments', authenticateToken, async (req, res) => {
|
|||||||
return res.json(rows.map(mapPaymentRow));
|
return res.json(rows.map(mapPaymentRow));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let query = 'SELECT * FROM payments';
|
|
||||||
|
let query = 'SELECT p.* FROM payments p';
|
||||||
let params = [];
|
let params = [];
|
||||||
if (familyId) {
|
|
||||||
query += ' WHERE family_id = ?';
|
// If condoId provided, we need to JOIN with families to filter
|
||||||
params.push(familyId);
|
if (condoId) {
|
||||||
|
query += ' JOIN families f ON p.family_id = f.id WHERE f.condo_id = ?';
|
||||||
|
params.push(condoId);
|
||||||
|
|
||||||
|
if (familyId) {
|
||||||
|
query += ' AND p.family_id = ?';
|
||||||
|
params.push(familyId);
|
||||||
|
}
|
||||||
|
} else if (familyId) {
|
||||||
|
query += ' WHERE p.family_id = ?';
|
||||||
|
params.push(familyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by date (newest first)
|
||||||
|
query += ' ORDER BY p.date_paid DESC';
|
||||||
|
|
||||||
const [rows] = await pool.query(query, params);
|
const [rows] = await pool.query(query, params);
|
||||||
res.json(rows.map(mapPaymentRow));
|
res.json(rows.map(mapPaymentRow));
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ export const CondoService = {
|
|||||||
return request<Payment[]>(`/payments?familyId=${familyId}`);
|
return request<Payment[]>(`/payments?familyId=${familyId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getCondoPayments: async (condoId: string): Promise<Payment[]> => {
|
||||||
|
return request<Payment[]>(`/payments?condoId=${condoId}`);
|
||||||
|
},
|
||||||
|
|
||||||
addPayment: async (payment: Omit<Payment, 'id'>): Promise<Payment> => {
|
addPayment: async (payment: Omit<Payment, 'id'>): Promise<Payment> => {
|
||||||
return request<Payment>('/payments', {
|
return request<Payment>('/payments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user