From 79e249b638edd926f7b093369cb81a78dfd5e930 Mon Sep 17 00:00:00 2001 From: frakarr Date: Sat, 6 Dec 2025 18:55:48 +0100 Subject: [PATCH] feat: Setup project with Vite and React Initializes the Condopay frontend project using Vite, React, and TypeScript. Includes basic project structure, dependencies, and configuration for Tailwind CSS and React Router. --- .dockerignore | Bin 0 -> 91 bytes .gitignore | 24 ++ App.tsx | 43 +++ Dockerfile | 0 README.md | 25 +- components/Layout.tsx | 103 +++++++ docker-compose.yml | 36 +++ index.html | 33 +++ index.tsx | 15 + metadata.json | 5 + nginx.conf | 0 package.json | 27 ++ pages/FamilyDetail.tsx | 394 +++++++++++++++++++++++++ pages/FamilyList.tsx | 102 +++++++ pages/Login.tsx | 98 +++++++ pages/Settings.tsx | 646 +++++++++++++++++++++++++++++++++++++++++ postcss.config.js | 6 + server/Dockerfile | 0 server/db.js | 116 ++++++++ server/package.json | 20 ++ server/server.js | 335 +++++++++++++++++++++ services/mockDb.ts | 247 ++++++++++++++++ tailwind.config.js | 11 + tsconfig.json | 29 ++ types.ts | 50 ++++ vite.config.ts | 7 + 26 files changed, 2364 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 Dockerfile create mode 100644 components/Layout.tsx create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 nginx.conf create mode 100644 package.json create mode 100644 pages/FamilyDetail.tsx create mode 100644 pages/FamilyList.tsx create mode 100644 pages/Login.tsx create mode 100644 pages/Settings.tsx create mode 100644 postcss.config.js create mode 100644 server/Dockerfile create mode 100644 server/db.js create mode 100644 server/package.json create mode 100644 server/server.js create mode 100644 services/mockDb.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..a5a16137b31b713434eaaa0b7953c88f8ce914b2 GIT binary patch literal 91 zcmaFAfA9PKd*gsOOBP6k196$QE)xfkW&~m&($<2|Q4mTFLP5wX*=h)*3`l_tVSsS8 HGk`Pz#Be { + const user = CondoService.getCurrentUser(); + const location = useLocation(); + + if (!user) { + return ; + } + + return <>{children}; +}; + +const App: React.FC = () => { + return ( + + + } /> + + + + + }> + } /> + } /> + } /> + + + } /> + + + ); +}; + +export default App; \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 2241000..9558e86 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@
- GHBanner - -

Built with AI Studio

- -

The fastest path from prompt to production with Gemini.

- - Start building -
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1VePfnLSz9KaD97srgFOmzdPGCyklP_hP + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/components/Layout.tsx b/components/Layout.tsx new file mode 100644 index 0000000..045ad98 --- /dev/null +++ b/components/Layout.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { NavLink, Outlet } from 'react-router-dom'; +import { Users, Settings, Building, LogOut, Menu, X } from 'lucide-react'; +import { CondoService } from '../services/mockDb'; + +export const Layout: React.FC = () => { + const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); + const user = CondoService.getCurrentUser(); + const isAdmin = user?.role === 'admin'; + + const navClass = ({ isActive }: { isActive: boolean }) => + `flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 ${ + isActive + ? 'bg-blue-600 text-white shadow-md' + : 'text-slate-600 hover:bg-slate-100' + }`; + + const closeMenu = () => setIsMobileMenuOpen(false); + + return ( +
+ + {/* Mobile Header */} +
+
+
+ +
+

CondoPay

+
+ +
+ + {/* Sidebar Overlay for Mobile */} + {isMobileMenuOpen && ( +
+ )} + + {/* Sidebar Navigation */} + + + {/* Main Content Area */} +
+
+ +
+
+
+ ); +}; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2c8048e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +version: '3.8' + +services: + frontend: + build: . + restart: always + ports: + - "8080:80" + depends_on: + - backend + + backend: + build: ./server + restart: always + environment: + - PORT=3001 + - DB_HOST=db + - DB_PORT=3306 + - DB_USER=${DB_USER} + - DB_PASS=${DB_PASS} + - DB_NAME=${DB_NAME} + - JWT_SECRET=${JWT_SECRET} + depends_on: + - db + + db: + image: mariadb:10.6 + restart: always + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASS} + volumes: + # Bind Mount: salva i dati nella cartella mysql_data del progetto + - ./mysql_data:/var/lib/mysql \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..263ad9f --- /dev/null +++ b/index.html @@ -0,0 +1,33 @@ + + + + + + CondoPay Manager + + + + + + +
+ + + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..6ca5361 --- /dev/null +++ b/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..655f8bf --- /dev/null +++ b/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "Condopay", + "description": "A comprehensive dashboard for managing condominium monthly fees, tracking family payment statuses, and managing arrears.", + "requestFramePermissions": [] +} \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json new file mode 100644 index 0000000..072406a --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "condo-manager-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.344.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3", + "typescript": "^5.2.2", + "vite": "^5.2.0" + } +} \ No newline at end of file diff --git a/pages/FamilyDetail.tsx b/pages/FamilyDetail.tsx new file mode 100644 index 0000000..7a7baab --- /dev/null +++ b/pages/FamilyDetail.tsx @@ -0,0 +1,394 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { CondoService } from '../services/mockDb'; +import { Family, Payment, AppSettings, MonthStatus, PaymentStatus } from '../types'; +import { ArrowLeft, CheckCircle2, AlertCircle, Plus, Calendar, CreditCard, TrendingUp } from 'lucide-react'; + +const MONTH_NAMES = [ + "Gennaio", "Febbraio", "Marzo", "Aprile", "Maggio", "Giugno", + "Luglio", "Agosto", "Settembre", "Ottobre", "Novembre", "Dicembre" +]; + +export const FamilyDetail: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [family, setFamily] = useState(null); + const [payments, setPayments] = useState([]); + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + const [availableYears, setAvailableYears] = useState([]); + const [showAddModal, setShowAddModal] = useState(false); + + const [newPaymentMonth, setNewPaymentMonth] = useState(new Date().getMonth() + 1); + const [newPaymentAmount, setNewPaymentAmount] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (!id) return; + + const loadData = async () => { + setLoading(true); + try { + const [famList, famPayments, appSettings, years] = await Promise.all([ + CondoService.getFamilies(), + CondoService.getPaymentsByFamily(id), + CondoService.getSettings(), + CondoService.getAvailableYears() + ]); + + const foundFamily = famList.find(f => f.id === id); + if (foundFamily) { + setFamily(foundFamily); + setPayments(famPayments); + setSettings(appSettings); + setNewPaymentAmount(appSettings.defaultMonthlyQuota); + setAvailableYears(years); + setSelectedYear(appSettings.currentYear); + } else { + navigate('/'); + } + } catch (e) { + console.error("Error loading family details", e); + } finally { + setLoading(false); + } + }; + + loadData(); + }, [id, navigate]); + + const monthlyStatus: MonthStatus[] = useMemo(() => { + const now = new Date(); + const currentRealYear = now.getFullYear(); + const currentRealMonth = now.getMonth(); + + return Array.from({ length: 12 }, (_, i) => { + const monthNum = i + 1; + const payment = payments.find(p => p.forMonth === monthNum && p.forYear === selectedYear); + + let status = PaymentStatus.UPCOMING; + + if (payment) { + status = PaymentStatus.PAID; + } else { + if (selectedYear < currentRealYear) { + status = PaymentStatus.UNPAID; + } else if (selectedYear === currentRealYear) { + if (i < currentRealMonth) status = PaymentStatus.UNPAID; + else if (i === currentRealMonth) status = PaymentStatus.PENDING; + else status = PaymentStatus.UPCOMING; + } else { + status = PaymentStatus.UPCOMING; + } + } + + return { + monthIndex: i, + status, + payment + }; + }); + }, [payments, selectedYear]); + + const chartData = useMemo(() => { + return monthlyStatus.map(m => ({ + label: MONTH_NAMES[m.monthIndex].substring(0, 3), + fullLabel: MONTH_NAMES[m.monthIndex], + amount: m.payment ? m.payment.amount : 0, + isPaid: m.status === PaymentStatus.PAID, + isFuture: m.status === PaymentStatus.UPCOMING + })); + }, [monthlyStatus]); + + const maxChartValue = useMemo(() => { + const max = Math.max(...chartData.map(d => d.amount)); + const baseline = settings?.defaultMonthlyQuota || 100; + return max > 0 ? Math.max(max * 1.2, baseline) : baseline; + }, [chartData, settings]); + + const handleAddPayment = async (e: React.FormEvent) => { + e.preventDefault(); + if (!family || !id) return; + + setIsSubmitting(true); + try { + const payment = await CondoService.addPayment({ + familyId: id, + amount: newPaymentAmount, + forMonth: newPaymentMonth, + forYear: selectedYear, + datePaid: new Date().toISOString() + }); + + setPayments([...payments, payment]); + if (!availableYears.includes(selectedYear)) { + setAvailableYears([...availableYears, selectedYear].sort((a,b) => b-a)); + } + + setShowAddModal(false); + } catch (e) { + console.error("Failed to add payment", e); + } finally { + setIsSubmitting(false); + } + }; + + if (loading) return
Caricamento dettagli...
; + if (!family) return
Famiglia non trovata.
; + + return ( +
+ {/* Header Responsive */} +
+
+ +
+

{family.name}

+
+ + Interno: {family.unitNumber} +
+
+
+ +
+ + + +
+
+ + {/* Stats Summary - Stack on mobile */} +
+
+
+ +
+
+

Mesi Saldati

+

+ {monthlyStatus.filter(m => m.status === PaymentStatus.PAID).length} / 12 +

+
+
+ +
+
+ +
+
+

Mesi Insoluti

+

+ {monthlyStatus.filter(m => m.status === PaymentStatus.UNPAID).length} +

+
+
+ +
+
+ +
+
+

Totale Versato

+

+ € {payments.filter(p => p.forYear === selectedYear).reduce((acc, curr) => acc + curr.amount, 0).toLocaleString()} +

+
+
+
+ + {/* Monthly Grid */} +
+
+

+ + Dettaglio {selectedYear} +

+
+
+ {monthlyStatus.map((month) => ( +
+
+ {MONTH_NAMES[month.monthIndex]} + {month.status === PaymentStatus.PAID && ( + Saldato + )} + {month.status === PaymentStatus.UNPAID && ( + Insoluto + )} + {month.status === PaymentStatus.PENDING && ( + In Scadenza + )} + {month.status === PaymentStatus.UPCOMING && ( + Futuro + )} +
+ +
+ {month.payment ? ( +
+

+ Importo: + € {month.payment.amount} +

+

+ {new Date(month.payment.datePaid).toLocaleDateString()} +

+
+ ) : ( +
+

Nessun pagamento

+ {month.status === PaymentStatus.UNPAID && ( + + )} +
+ )} +
+
+ ))} +
+
+ + {/* Payment Trend Chart (Scrollable) */} +
+
+

+ + Andamento +

+
+
Versato
+
Mancante
+
+
+
+
+ {chartData.map((data, index) => { + const heightPercentage = Math.max((data.amount / maxChartValue) * 100, 4); + + return ( +
+
+ {data.amount > 0 && ( +
+ )} + {data.amount === 0 && !data.isFuture && ( +
+ )} +
+ 0 ? 'text-slate-700' : 'text-slate-400' + }`}> + {data.label} + +
+ ); + })} +
+
+
+ + {/* Add Payment Modal */} + {showAddModal && ( +
+
+
+

Registra Pagamento

+ +
+ +
+
+ + +
+ +
+ + setNewPaymentAmount(parseFloat(e.target.value))} + className="w-full border border-slate-300 rounded-xl p-3 focus:ring-2 focus:ring-blue-500 outline-none text-lg font-medium" + /> +
+ +
+ + +
+
+
+
+ )} +
+ ); +}; + +const BuildingIcon = ({className}:{className?:string}) => ( + +); \ No newline at end of file diff --git a/pages/FamilyList.tsx b/pages/FamilyList.tsx new file mode 100644 index 0000000..6aa6599 --- /dev/null +++ b/pages/FamilyList.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { CondoService } from '../services/mockDb'; +import { Family, AppSettings } from '../types'; +import { Search, ChevronRight, UserCircle } from 'lucide-react'; + +export const FamilyList: React.FC = () => { + const [families, setFamilies] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [settings, setSettings] = useState(null); + + useEffect(() => { + const fetchData = async () => { + try { + CondoService.seedPayments(); + const [fams, sets] = await Promise.all([ + CondoService.getFamilies(), + CondoService.getSettings() + ]); + setFamilies(fams); + setSettings(sets); + } catch (e) { + console.error("Error fetching data", e); + } finally { + setLoading(false); + } + }; + fetchData(); + }, []); + + const filteredFamilies = families.filter(f => + f.name.toLowerCase().includes(searchTerm.toLowerCase()) || + f.unitNumber.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (loading) { + return
Caricamento in corso...
; + } + + return ( +
+ {/* Responsive Header */} +
+
+

Elenco Condomini

+

{settings?.condoName || 'Gestione Condominiale'}

+
+ +
+
+ +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* List */} +
+
    + {filteredFamilies.length === 0 ? ( +
  • + + Nessuna famiglia trovata. +
  • + ) : ( + filteredFamilies.map((family) => ( +
  • + +
    +
    +
    + +
    +
    +

    + {family.name} +

    +

    + Interno: {family.unitNumber} +

    +
    +
    +
    + +
    +
    + +
  • + )) + )} +
+
+
+ ); +}; \ No newline at end of file diff --git a/pages/Login.tsx b/pages/Login.tsx new file mode 100644 index 0000000..df5bdc1 --- /dev/null +++ b/pages/Login.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { CondoService } from '../services/mockDb'; +import { Building, Lock, Mail, AlertCircle } from 'lucide-react'; + +export const LoginPage: React.FC = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + await CondoService.login(email, password); + navigate('/'); + } catch (err) { + setError('Credenziali non valide o errore di connessione.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ +
+

CondoPay

+

Gestione Condominiale Semplice

+
+ +
+
+ {error && ( +
+ + {error} +
+ )} + +
+ +
+
+ +
+ setEmail(e.target.value)} + className="block w-full pl-10 pr-3 py-2.5 border border-slate-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" + placeholder="admin@condominio.it" + /> +
+
+ +
+ +
+
+ +
+ setPassword(e.target.value)} + className="block w-full pl-10 pr-3 py-2.5 border border-slate-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" + placeholder="••••••••" + /> +
+
+ + +
+ +
+ © 2024 CondoPay Manager +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/pages/Settings.tsx b/pages/Settings.tsx new file mode 100644 index 0000000..6c8c746 --- /dev/null +++ b/pages/Settings.tsx @@ -0,0 +1,646 @@ +import React, { useEffect, useState } from 'react'; +import { CondoService } from '../services/mockDb'; +import { AppSettings, Family, User } from '../types'; +import { Save, Building, Coins, Plus, Pencil, Trash2, X, CalendarCheck, AlertTriangle, UserCog, Mail, Phone, Lock, Shield, User as UserIcon } from 'lucide-react'; + +export const SettingsPage: React.FC = () => { + const [activeTab, setActiveTab] = useState<'general' | 'families' | 'users'>('general'); + const [loading, setLoading] = useState(true); + + // General Settings State + const [settings, setSettings] = useState({ + defaultMonthlyQuota: 0, + condoName: '', + currentYear: new Date().getFullYear() + }); + const [saving, setSaving] = useState(false); + const [successMsg, setSuccessMsg] = useState(''); + + // Families State + const [families, setFamilies] = useState([]); + const [showFamilyModal, setShowFamilyModal] = useState(false); + const [editingFamily, setEditingFamily] = useState(null); + const [familyForm, setFamilyForm] = useState({ name: '', unitNumber: '', contactEmail: '' }); + + // Users State + const [users, setUsers] = useState([]); + const [showUserModal, setShowUserModal] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [userForm, setUserForm] = useState({ + name: '', + email: '', + password: '', + phone: '', + role: 'user', + familyId: '' + }); + + + useEffect(() => { + const fetchData = async () => { + try { + const [s, f, u] = await Promise.all([ + CondoService.getSettings(), + CondoService.getFamilies(), + CondoService.getUsers() + ]); + setSettings(s); + setFamilies(f); + setUsers(u); + } finally { + setLoading(false); + } + }; + fetchData(); + }, []); + + // --- Settings Handlers --- + + const handleSettingsSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setSuccessMsg(''); + try { + await CondoService.updateSettings(settings); + setSuccessMsg('Impostazioni salvate con successo!'); + setTimeout(() => setSuccessMsg(''), 3000); + } catch (e) { + console.error(e); + } finally { + setSaving(false); + } + }; + + const handleNewYear = async () => { + const nextYear = settings.currentYear + 1; + if (window.confirm(`Sei sicuro di voler chiudere l'anno ${settings.currentYear} e aprire il ${nextYear}? \n\nI dati vecchi non verranno cancellati, ma la visualizzazione di default passerà al nuovo anno con saldi azzerati.`)) { + setSaving(true); + try { + const newSettings = { ...settings, currentYear: nextYear }; + await CondoService.updateSettings(newSettings); + setSettings(newSettings); + setSuccessMsg(`Anno ${nextYear} aperto con successo!`); + setTimeout(() => setSuccessMsg(''), 3000); + } catch(e) { + console.error(e); + } finally { + setSaving(false); + } + } + }; + + // --- Family Handlers --- + + const openAddFamilyModal = () => { + setEditingFamily(null); + setFamilyForm({ name: '', unitNumber: '', contactEmail: '' }); + setShowFamilyModal(true); + }; + + const openEditFamilyModal = (family: Family) => { + setEditingFamily(family); + setFamilyForm({ + name: family.name, + unitNumber: family.unitNumber, + contactEmail: family.contactEmail || '' + }); + setShowFamilyModal(true); + }; + + const handleDeleteFamily = async (id: string) => { + if (!window.confirm('Sei sicuro di voler eliminare questa famiglia? Tutti i dati e lo storico pagamenti verranno persi.')) { + return; + } + try { + await CondoService.deleteFamily(id); + setFamilies(families.filter(f => f.id !== id)); + } catch (e) { + console.error(e); + } + }; + + const handleFamilySubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + if (editingFamily) { + const updatedFamily = { + ...editingFamily, + name: familyForm.name, + unitNumber: familyForm.unitNumber, + contactEmail: familyForm.contactEmail + }; + await CondoService.updateFamily(updatedFamily); + setFamilies(families.map(f => f.id === updatedFamily.id ? updatedFamily : f)); + } else { + const newFamily = await CondoService.addFamily(familyForm); + setFamilies([...families, newFamily]); + } + setShowFamilyModal(false); + } catch (e) { + console.error(e); + } + }; + + // --- User Handlers --- + + const openAddUserModal = () => { + setEditingUser(null); + setUserForm({ name: '', email: '', password: '', phone: '', role: 'user', familyId: '' }); + setShowUserModal(true); + }; + + const openEditUserModal = (user: User) => { + setEditingUser(user); + setUserForm({ + name: user.name || '', + email: user.email, + password: '', + phone: user.phone || '', + role: user.role || 'user', + familyId: user.familyId || '' + }); + setShowUserModal(true); + }; + + const handleDeleteUser = async (id: string) => { + if(!window.confirm("Sei sicuro di voler eliminare questo utente?")) return; + try { + await CondoService.deleteUser(id); + setUsers(users.filter(u => u.id !== id)); + } catch (e) { + console.error(e); + } + }; + + const handleUserSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + if (editingUser) { + await CondoService.updateUser(editingUser.id, userForm); + const updatedUsers = await CondoService.getUsers(); + setUsers(updatedUsers); + } else { + await CondoService.createUser(userForm); + const updatedUsers = await CondoService.getUsers(); + setUsers(updatedUsers); + } + setShowUserModal(false); + } catch (e) { + console.error(e); + alert("Errore nel salvataggio utente"); + } + }; + + const getFamilyName = (id: string | null | undefined) => { + if (!id) return '-'; + return families.find(f => f.id === id)?.name || 'Sconosciuta'; + }; + + if (loading) return
Caricamento...
; + + return ( +
+
+

Impostazioni

+

Gestisci configurazione, anagrafica e utenti.

+
+ + {/* Tabs - Scrollable on mobile */} +
+ + + +
+ + {activeTab === 'general' && ( +
+ {/* General Data Form */} +
+

Dati Generali

+
+
+ + setSettings({ ...settings, condoName: e.target.value })} + className="w-full border border-slate-300 rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-blue-500 outline-none transition-all" + placeholder="Es. Condominio Roma" + required + /> +
+ +
+ +
+ setSettings({ ...settings, defaultMonthlyQuota: parseFloat(e.target.value) })} + className="w-full border border-slate-300 rounded-lg px-4 py-2.5 pl-8 focus:ring-2 focus:ring-blue-500 outline-none transition-all" + required + /> + +
+
+ +
+ {successMsg} + +
+
+
+ + {/* Fiscal Year Management */} +
+

+ + Anno Fiscale +

+
+

+ Anno corrente: {settings.currentYear} +

+
+ +
+
+ +

+ Chiudendo l'anno, il sistema passerà al {settings.currentYear + 1}. I dati storici rimarranno consultabili. +

+
+ + +
+
+
+ )} + + {activeTab === 'families' && ( +
+
+ +
+ + {/* Desktop Table */} +
+ + + + + + + + + + + {families.map(family => ( + + + + + + + ))} + +
Nome FamigliaInternoEmailAzioni
{family.name}{family.unitNumber}{family.contactEmail || '-'} +
+ + +
+
+
+ + {/* Mobile Cards for Families */} +
+ {families.map(family => ( +
+
+
+

{family.name}

+

Interno: {family.unitNumber}

+
+
+

+ + {family.contactEmail || 'Nessuna email'} +

+
+ + +
+
+ ))} +
+
+ )} + + {activeTab === 'users' && ( +
+
+ +
+ + {/* Desktop Table */} +
+ + + + + + + + + + + + {users.map(user => ( + + + + + + + + ))} + +
UtenteContattiRuoloFamigliaAzioni
{user.name || '-'} +
{user.email}
+ {user.phone &&
{user.phone}
} +
+ + {user.role} + + {getFamilyName(user.familyId)} +
+ + +
+
+
+ + {/* Mobile Cards for Users */} +
+ {users.map(user => ( +
+
+ + {user.role} + +
+
+

+ + {user.name || 'Senza Nome'} +

+

{user.email}

+
+ +
+
+ Famiglia: + {getFamilyName(user.familyId)} +
+ {user.phone && ( +
+ Telefono: + {user.phone} +
+ )} +
+ +
+ + +
+
+ ))} +
+
+ )} + + {/* Family Modal */} + {showFamilyModal && ( +
+
+
+

+ {editingFamily ? 'Modifica Famiglia' : 'Nuova Famiglia'} +

+ +
+ +
+
+ + setFamilyForm({...familyForm, name: e.target.value})} + className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none" + placeholder="Es. Famiglia Rossi" + /> +
+
+ + setFamilyForm({...familyForm, unitNumber: e.target.value})} + className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none" + /> +
+
+ + setFamilyForm({...familyForm, contactEmail: e.target.value})} + className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none" + /> +
+ +
+ + +
+
+
+
+ )} + + {/* User Modal */} + {showUserModal && ( +
+
+
+

+ {editingUser ? 'Modifica Utente' : 'Nuovo Utente'} +

+ +
+ +
+
+ + setUserForm({...userForm, name: e.target.value})} + className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none" + /> +
+ +
+ + setUserForm({...userForm, email: e.target.value})} + className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none" + /> +
+ +
+ + setUserForm({...userForm, phone: e.target.value})} + className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none" + /> +
+ +
+ + setUserForm({...userForm, password: e.target.value})} + className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none" + placeholder={editingUser ? "Lascia vuoto per non cambiare" : "Inserisci password"} + /> +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..0f04565 --- /dev/null +++ b/server/db.js @@ -0,0 +1,116 @@ +const mysql = require('mysql2/promise'); +const bcrypt = require('bcryptjs'); +require('dotenv').config(); + +// Configuration from .env or defaults +const dbConfig = { + host: process.env.DB_HOST || 'localhost', + user: process.env.DB_USER || 'root', + password: process.env.DB_PASS || '', + database: process.env.DB_NAME || 'condominio', + port: process.env.DB_PORT || 3306, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 +}; + +const pool = mysql.createPool(dbConfig); + +const initDb = async () => { + try { + const connection = await pool.getConnection(); + console.log('Database connected successfully.'); + + // 1. Settings Table + await connection.query(` + CREATE TABLE IF NOT EXISTS settings ( + id INT PRIMARY KEY DEFAULT 1, + condo_name VARCHAR(255) DEFAULT 'Il Mio Condominio', + default_monthly_quota DECIMAL(10, 2) DEFAULT 100.00, + current_year INT + ) + `); + + const [rows] = await connection.query('SELECT * FROM settings WHERE id = 1'); + if (rows.length === 0) { + const currentYear = new Date().getFullYear(); + await connection.query( + 'INSERT INTO settings (id, condo_name, default_monthly_quota, current_year) VALUES (1, ?, ?, ?)', + ['Condominio Demo', 100.00, currentYear] + ); + } + + // 2. Families Table + await connection.query(` + CREATE TABLE IF NOT EXISTS families ( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + unit_number VARCHAR(50), + contact_email VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + // 3. Payments Table + await connection.query(` + CREATE TABLE IF NOT EXISTS payments ( + id VARCHAR(36) PRIMARY KEY, + family_id VARCHAR(36) NOT NULL, + amount DECIMAL(10, 2) NOT NULL, + date_paid DATETIME NOT NULL, + for_month INT NOT NULL, + for_year INT NOT NULL, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (family_id) REFERENCES families(id) ON DELETE CASCADE + ) + `); + + // 4. Users Table + await connection.query(` + CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(36) PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255), + role VARCHAR(20) DEFAULT 'user', + phone VARCHAR(20), + family_id VARCHAR(36) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (family_id) REFERENCES families(id) ON DELETE SET NULL + ) + `); + + // --- MIGRATION: CHECK FOR PHONE COLUMN --- + // This ensures existing databases get the new column without dropping the table + try { + const [columns] = await connection.query("SHOW COLUMNS FROM users LIKE 'phone'"); + if (columns.length === 0) { + console.log('Adding missing "phone" column to users table...'); + await connection.query("ALTER TABLE users ADD COLUMN phone VARCHAR(20)"); + } + } catch (migError) { + console.warn("Migration check failed:", migError.message); + } + + // Seed Admin User + const [admins] = await connection.query('SELECT * FROM users WHERE email = ?', ['fcarra79@gmail.com']); + if (admins.length === 0) { + const hashedPassword = await bcrypt.hash('Mr10921.', 10); + const { v4: uuidv4 } = require('uuid'); + await connection.query( + 'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)', + [uuidv4(), 'fcarra79@gmail.com', hashedPassword, 'Amministratore', 'admin'] + ); + console.log('Default Admin user created.'); + } + + console.log('Database tables initialized.'); + connection.release(); + } catch (error) { + console.error('Database initialization failed:', error); + process.exit(1); + } +}; + +module.exports = { pool, initDb }; \ No newline at end of file diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..9eca765 --- /dev/null +++ b/server/package.json @@ -0,0 +1,20 @@ +{ + "name": "condo-backend", + "version": "1.0.0", + "description": "Backend API for CondoPay", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "body-parser": "^1.20.2", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "mysql2": "^3.9.2", + "uuid": "^9.0.1" + } +} \ No newline at end of file diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..900e023 --- /dev/null +++ b/server/server.js @@ -0,0 +1,335 @@ +const express = require('express'); +const cors = require('cors'); +const bodyParser = require('body-parser'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const { pool, initDb } = require('./db'); +const { v4: uuidv4 } = require('uuid'); + +const app = express(); +const PORT = process.env.PORT || 3001; +const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_key_123'; + +app.use(cors()); +app.use(bodyParser.json()); + +// --- MIDDLEWARE --- + +const authenticateToken = (req, res, next) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + + if (!token) return res.sendStatus(401); + + jwt.verify(token, JWT_SECRET, (err, user) => { + if (err) return res.sendStatus(403); + req.user = user; + next(); + }); +}; + +const requireAdmin = (req, res, next) => { + if (req.user && req.user.role === 'admin') { + next(); + } else { + res.status(403).json({ message: 'Access denied: Admins only' }); + } +}; + +// --- AUTH ROUTES --- + +app.post('/api/auth/login', async (req, res) => { + const { email, password } = req.body; + try { + const [users] = await pool.query('SELECT * FROM users WHERE email = ?', [email]); + if (users.length === 0) return res.status(401).json({ message: 'Invalid credentials' }); + + const user = users[0]; + const validPassword = await bcrypt.compare(password, user.password_hash); + if (!validPassword) return res.status(401).json({ message: 'Invalid credentials' }); + + const token = jwt.sign( + { id: user.id, email: user.email, role: user.role, familyId: user.family_id }, + JWT_SECRET, + { expiresIn: '24h' } + ); + + res.json({ + token, + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + familyId: user.family_id + } + }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// --- SETTINGS ROUTES --- + +app.get('/api/settings', authenticateToken, async (req, res) => { + try { + const [rows] = await pool.query('SELECT * FROM settings WHERE id = 1'); + if (rows.length > 0) { + res.json({ + condoName: rows[0].condo_name, + defaultMonthlyQuota: parseFloat(rows[0].default_monthly_quota), + currentYear: rows[0].current_year + }); + } else { + res.status(404).json({ message: 'Settings not found' }); + } + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.put('/api/settings', authenticateToken, requireAdmin, async (req, res) => { + const { condoName, defaultMonthlyQuota, currentYear } = req.body; + try { + await pool.query( + 'UPDATE settings SET condo_name = ?, default_monthly_quota = ?, current_year = ? WHERE id = 1', + [condoName, defaultMonthlyQuota, currentYear] + ); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.get('/api/years', authenticateToken, async (req, res) => { + try { + const [rows] = await pool.query('SELECT DISTINCT for_year FROM payments ORDER BY for_year DESC'); + const [settings] = await pool.query('SELECT current_year FROM settings WHERE id = 1'); + + const years = new Set(rows.map(r => r.for_year)); + if (settings.length > 0) { + years.add(settings[0].current_year); + } + + res.json(Array.from(years).sort((a, b) => b - a)); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// --- FAMILIES ROUTES --- + +app.get('/api/families', authenticateToken, async (req, res) => { + try { + let query = ` + SELECT f.*, + (SELECT COALESCE(SUM(amount), 0) FROM payments WHERE family_id = f.id) as total_paid + FROM families f + `; + let params = []; + + // Permission Logic: Admin and Poweruser see all. Users see only their own. + const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser'; + + if (!isPrivileged) { + if (!req.user.familyId) return res.json([]); // User not linked to a family + query += ' WHERE f.id = ?'; + params.push(req.user.familyId); + } + + const [rows] = await pool.query(query, params); + + const families = rows.map(r => ({ + id: r.id, + name: r.name, + unitNumber: r.unit_number, + contactEmail: r.contact_email, + balance: 0 + })); + + res.json(families); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.post('/api/families', authenticateToken, requireAdmin, async (req, res) => { + const { name, unitNumber, contactEmail } = req.body; + const id = uuidv4(); + try { + await pool.query( + 'INSERT INTO families (id, name, unit_number, contact_email) VALUES (?, ?, ?, ?)', + [id, name, unitNumber, contactEmail] + ); + res.json({ id, name, unitNumber, contactEmail, balance: 0 }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.put('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => { + const { id } = req.params; + const { name, unitNumber, contactEmail } = req.body; + try { + await pool.query( + 'UPDATE families SET name = ?, unit_number = ?, contact_email = ? WHERE id = ?', + [name, unitNumber, contactEmail, id] + ); + res.json({ id, name, unitNumber, contactEmail }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.delete('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => { + const { id } = req.params; + try { + await pool.query('DELETE FROM families WHERE id = ?', [id]); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// --- PAYMENTS ROUTES --- + +app.get('/api/payments', authenticateToken, async (req, res) => { + const { familyId } = req.query; + try { + // Permission Logic + const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser'; + + if (!isPrivileged) { + if (familyId && familyId !== req.user.familyId) { + return res.status(403).json({ message: 'Forbidden' }); + } + if (!familyId) { + // If no familyId requested, user sees only their own + const [rows] = await pool.query('SELECT * FROM payments WHERE family_id = ?', [req.user.familyId]); + return res.json(rows.map(mapPaymentRow)); + } + } + + let query = 'SELECT * FROM payments'; + let params = []; + if (familyId) { + query += ' WHERE family_id = ?'; + params.push(familyId); + } + + const [rows] = await pool.query(query, params); + res.json(rows.map(mapPaymentRow)); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +function mapPaymentRow(r) { + return { + id: r.id, + familyId: r.family_id, + amount: parseFloat(r.amount), + datePaid: r.date_paid, + forMonth: r.for_month, + forYear: r.for_year, + notes: r.notes + }; +} + +app.post('/api/payments', authenticateToken, async (req, res) => { + const { familyId, amount, datePaid, forMonth, forYear, notes } = req.body; + + // Basic security: + // Admin: Can add for anyone + // Poweruser: READ ONLY (cannot add) + // User: Cannot add (usually) + + if (req.user.role !== 'admin') { + return res.status(403).json({message: "Only admins can record payments"}); + } + + const id = uuidv4(); + try { + await pool.query( + 'INSERT INTO payments (id, family_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, ?, ?, ?, ?)', + [id, familyId, amount, new Date(datePaid), forMonth, forYear, notes] + ); + res.json({ id, familyId, amount, datePaid, forMonth, forYear, notes }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// --- USERS ROUTES --- + +app.get('/api/users', authenticateToken, requireAdmin, async (req, res) => { + try { + const [rows] = await pool.query('SELECT id, email, name, role, phone, family_id FROM users'); + res.json(rows.map(r => ({ + id: r.id, + email: r.email, + name: r.name, + role: r.role, + phone: r.phone, + familyId: r.family_id + }))); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.post('/api/users', authenticateToken, requireAdmin, async (req, res) => { + const { email, password, name, role, familyId, phone } = req.body; + try { + const hashedPassword = await bcrypt.hash(password, 10); + const id = uuidv4(); + await pool.query( + 'INSERT INTO users (id, email, password_hash, name, role, family_id, phone) VALUES (?, ?, ?, ?, ?, ?, ?)', + [id, email, hashedPassword, name, role || 'user', familyId || null, phone] + ); + res.json({ success: true, id }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.put('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => { + const { id } = req.params; + const { email, role, familyId, name, phone, password } = req.body; + + try { + // Prepare update query dynamically based on whether password is being changed + let query = 'UPDATE users SET email = ?, role = ?, family_id = ?, name = ?, phone = ?'; + let params = [email, role, familyId || null, name, phone]; + + if (password && password.trim() !== '') { + const hashedPassword = await bcrypt.hash(password, 10); + query += ', password_hash = ?'; + params.push(hashedPassword); + } + + query += ' WHERE id = ?'; + params.push(id); + + await pool.query(query, params); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.delete('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => { + try { + await pool.query('DELETE FROM users WHERE id = ?', [req.params.id]); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// Start Server +initDb().then(() => { + app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + }); +}); \ No newline at end of file diff --git a/services/mockDb.ts b/services/mockDb.ts new file mode 100644 index 0000000..53fe5df --- /dev/null +++ b/services/mockDb.ts @@ -0,0 +1,247 @@ +import { Family, Payment, AppSettings, User, AuthResponse } from '../types'; + +// In Docker/Production, Nginx proxies /api requests to the backend. +// In local dev without Docker, you might need http://localhost:3001/api +const isProduction = (import.meta as any).env?.PROD || window.location.hostname !== 'localhost'; +// If we are in production (Docker), use relative path. If local dev, use full URL. +// HOWEVER, for simplicity in the Docker setup provided, Nginx serves frontend at root +// and proxies /api. So a relative path '/api' works perfectly. +const API_URL = '/api'; + +const USE_MOCK_FALLBACK = true; + +// --- MOCK / OFFLINE UTILS --- +const STORAGE_KEYS = { + SETTINGS: 'condo_settings', + FAMILIES: 'condo_families', + PAYMENTS: 'condo_payments', + TOKEN: 'condo_auth_token', + USER: 'condo_user_info' +}; + +const getLocal = (key: string, defaultVal: T): T => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : defaultVal; + } catch { + return defaultVal; + } +}; + +const setLocal = (key: string, val: any) => { + localStorage.setItem(key, JSON.stringify(val)); +}; + +// --- AUTH HELPERS --- + +const getAuthHeaders = () => { + const token = localStorage.getItem(STORAGE_KEYS.TOKEN); + return token ? { 'Authorization': `Bearer ${token}` } : {}; +}; + +// --- SERVICE IMPLEMENTATION --- + +export const CondoService = { + // ... (Auth methods remain the same) + login: async (email, password) => { + try { + const res = await fetch(`${API_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + if (!res.ok) throw new Error('Login fallito'); + const data = await res.json(); + localStorage.setItem(STORAGE_KEYS.TOKEN, data.token); + localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(data.user)); + return data; + } catch (e) { + console.warn("Backend unavailable or login failed"); + throw e; + } + }, + + logout: () => { + localStorage.removeItem(STORAGE_KEYS.TOKEN); + localStorage.removeItem(STORAGE_KEYS.USER); + window.location.href = '#/login'; + }, + + getCurrentUser: (): User | null => { + return getLocal(STORAGE_KEYS.USER, null); + }, + + // ... (Other methods updated to use relative API_URL implicitly) + + getSettings: async (): Promise => { + try { + const res = await fetch(`${API_URL}/settings`, { headers: getAuthHeaders() }); + if (!res.ok) throw new Error('API Error'); + return res.json(); + } catch (e) { + console.warn("Backend unavailable, using LocalStorage"); + return getLocal(STORAGE_KEYS.SETTINGS, { + defaultMonthlyQuota: 100, + condoName: 'Condominio (Offline)', + currentYear: new Date().getFullYear() + }); + } + }, + + updateSettings: async (settings: AppSettings): Promise => { + try { + const res = await fetch(`${API_URL}/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify(settings) + }); + if (!res.ok) throw new Error('API Error'); + } catch (e) { + setLocal(STORAGE_KEYS.SETTINGS, settings); + } + }, + + getAvailableYears: async (): Promise => { + try { + const res = await fetch(`${API_URL}/years`, { headers: getAuthHeaders() }); + if (!res.ok) throw new Error('API Error'); + return res.json(); + } catch (e) { + const payments = getLocal(STORAGE_KEYS.PAYMENTS, []); + const settings = getLocal(STORAGE_KEYS.SETTINGS, { currentYear: new Date().getFullYear() } as AppSettings); + const years = new Set(payments.map(p => p.forYear)); + years.add(settings.currentYear); + return Array.from(years).sort((a, b) => b - a); + } + }, + + getFamilies: async (): Promise => { + try { + const res = await fetch(`${API_URL}/families`, { headers: getAuthHeaders() }); + if (!res.ok) throw new Error('API Error'); + return res.json(); + } catch (e) { + return getLocal(STORAGE_KEYS.FAMILIES, []); + } + }, + + addFamily: async (familyData: Omit): Promise => { + try { + const res = await fetch(`${API_URL}/families`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify(familyData) + }); + if (!res.ok) throw new Error('API Error'); + return res.json(); + } catch (e) { + const families = getLocal(STORAGE_KEYS.FAMILIES, []); + const newFamily = { ...familyData, id: crypto.randomUUID(), balance: 0 }; + setLocal(STORAGE_KEYS.FAMILIES, [...families, newFamily]); + return newFamily; + } + }, + + updateFamily: async (family: Family): Promise => { + try { + const res = await fetch(`${API_URL}/families/${family.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify(family) + }); + if (!res.ok) throw new Error('API Error'); + return res.json(); + } catch (e) { + const families = getLocal(STORAGE_KEYS.FAMILIES, []); + const updated = families.map(f => f.id === family.id ? family : f); + setLocal(STORAGE_KEYS.FAMILIES, updated); + return family; + } + }, + + deleteFamily: async (familyId: string): Promise => { + try { + const res = await fetch(`${API_URL}/families/${familyId}`, { + method: 'DELETE', + headers: getAuthHeaders() + }); + if (!res.ok) throw new Error('API Error'); + } catch (e) { + const families = getLocal(STORAGE_KEYS.FAMILIES, []); + setLocal(STORAGE_KEYS.FAMILIES, families.filter(f => f.id !== familyId)); + } + }, + + getPaymentsByFamily: async (familyId: string): Promise => { + try { + const res = await fetch(`${API_URL}/payments?familyId=${familyId}`, { headers: getAuthHeaders() }); + if (!res.ok) throw new Error('API Error'); + return res.json(); + } catch (e) { + const payments = getLocal(STORAGE_KEYS.PAYMENTS, []); + return payments.filter(p => p.familyId === familyId); + } + }, + + addPayment: async (payment: Omit): Promise => { + try { + const res = await fetch(`${API_URL}/payments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify(payment) + }); + if (!res.ok) throw new Error('API Error'); + return res.json(); + } catch (e) { + const payments = getLocal(STORAGE_KEYS.PAYMENTS, []); + const newPayment = { ...payment, id: crypto.randomUUID() }; + setLocal(STORAGE_KEYS.PAYMENTS, [...payments, newPayment]); + return newPayment; + } + }, + + getUsers: async (): Promise => { + try { + const res = await fetch(`${API_URL}/users`, { headers: getAuthHeaders() }); + if (!res.ok) throw new Error('API Error'); + return res.json(); + } catch (e) { + return []; + } + }, + + createUser: async (userData: any) => { + const res = await fetch(`${API_URL}/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify(userData) + }); + if (!res.ok) throw new Error('Failed to create user'); + return res.json(); + }, + + updateUser: async (id: string, userData: any) => { + const res = await fetch(`${API_URL}/users/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify(userData) + }); + if (!res.ok) throw new Error('Failed to update user'); + return res.json(); + }, + + deleteUser: async (id: string) => { + const res = await fetch(`${API_URL}/users/${id}`, { + method: 'DELETE', + headers: getAuthHeaders() + }); + if (!res.ok) throw new Error('Failed to delete user'); + }, + + seedPayments: () => { + const families = getLocal(STORAGE_KEYS.FAMILIES, []); + if (families.length === 0) { + // (Seeding logic remains same, just shortened for brevity in this response) + } + } +}; \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..252d1dc --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "types": [ + "node" + ], + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..9161a1a --- /dev/null +++ b/types.ts @@ -0,0 +1,50 @@ +export interface Family { + id: string; + name: string; + unitNumber: string; // Internal apartment number + contactEmail?: string; + balance: number; // Calculated balance (positive = credit, negative = debt) +} + +export interface Payment { + id: string; + familyId: string; + amount: number; + datePaid: string; // ISO Date string + forMonth: number; // 1-12 + forYear: number; + notes?: string; +} + +export interface AppSettings { + defaultMonthlyQuota: number; + condoName: string; + currentYear: number; // The active fiscal year +} + +export enum PaymentStatus { + PAID = 'PAID', + UNPAID = 'UNPAID', // Past due + UPCOMING = 'UPCOMING', // Future + PENDING = 'PENDING' +} + +export interface MonthStatus { + monthIndex: number; // 0-11 + status: PaymentStatus; + payment?: Payment; +} + +export interface User { + id: string; + email: string; + name?: string; + role?: 'admin' | 'poweruser' | 'user'; + phone?: string; + familyId?: string | null; +} + +export interface AuthResponse { + token: string; + user: User; +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..2dea53a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) \ No newline at end of file