messaggi di conferma su rimborsi, messaggio per accesso neagato e altro
All checks were successful
Deploy to VPS / build (push) Successful in 18s
Deploy to VPS / deploy (push) Successful in 13s

This commit is contained in:
AV77web 2026-06-09 16:28:25 +02:00
parent 4ff685557f
commit 59e7343598
12 changed files with 199 additions and 97 deletions

104
README.md
View file

@ -1,73 +1,61 @@
# React + TypeScript + Vite # Frontend Gestione Rimborsi Spese Aziendali (Prova S5)
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Applicazione web CSR (React + Vite + TypeScript) per la gestione dei rimborsi spese.
Currently, two official plugins are available: ## URL produzione
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) | Risorsa | URL |
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) |---------|-----|
| Applicazione | https://provapraticafrontend.andreavillari.it |
| API backend | https://provapraticabackend.andreavillari.it |
| Documentazione API (Swagger) | https://provapraticabackend.andreavillari.it/api/docs |
## React Compiler ## Credenziali di test
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). Password comune: `Password123!`
## Expanding the ESLint configuration | Ruolo | Email |
|-------|-------|
| Responsabile amministrativo | `admin@azienda.it` |
| Dipendente | `mario.rossi@azienda.it` |
| Dipendente | `marco.bianchi@azienda.it` |
| Dipendente | `anna.verdi@azienda.it` |
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: Dalla pagina **Registrati** è possibile creare nuovi account scegliendo il ruolo.
```js ## Funzionalità
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this - Login / registrazione / logout
tseslint.configs.recommendedTypeChecked, - Dashboard differenziata per ruolo
// Alternatively, use this for stricter rules - Dipendente: creazione, elenco, filtri, modifica ed eliminazione richieste in attesa
tseslint.configs.strictTypeChecked, - Responsabile: gestione di tutte le richieste, approvazione, rifiuto, liquidazione
// Optionally, add this for stylistic rules - Statistiche aggregate per mese e categoria
tseslint.configs.stylisticTypeChecked,
// Other configs... ## Avvio locale
],
languageOptions: { Configurare il file `.env` (`VITE_API_URL` vuoto in sviluppo per usare il proxy Vite verso il backend locale).
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'], ```bash
tsconfigRootDir: import.meta.dirname, npm install
}, npm run dev
// other options...
},
},
])
``` ```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: Con `VITE_API_URL` vuoto, Vite inoltra le chiamate `/api` al backend su `http://localhost:3003`.
```js Build di produzione:
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([ ```bash
globalIgnores(['dist']), VITE_API_URL=https://provapraticabackend.andreavillari.it npm run build
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
``` ```
Con Docker:
```bash
docker compose up -d --build
```
L'app è servita da nginx sulla porta `3002`.
## Deploy
Push su `main` → Forgejo Actions → SSH sul VPS → `docker compose up -d --build` in `/root/provapraticafrontend`.

View file

@ -219,6 +219,13 @@ input:focus, select:focus, textarea:focus {
color: #2563eb; color: #2563eb;
} }
.table .cell-desc {
max-width: 220px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.filters { .filters {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View file

@ -1,4 +1,5 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import GuestRoute from "./components/GuestRoute";
import Layout from "./components/Layout"; import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute"; import ProtectedRoute from "./components/ProtectedRoute";
import { AuthProvider } from "./context/AuthContext"; import { AuthProvider } from "./context/AuthContext";
@ -17,9 +18,9 @@ export default function App() {
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<GuestRoute><Home /></GuestRoute>} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<GuestRoute><Login /></GuestRoute>} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<GuestRoute><Register /></GuestRoute>} />
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}> <Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/rimborsi" element={<RimborsiList />} /> <Route path="/rimborsi" element={<RimborsiList />} />

View file

@ -7,20 +7,23 @@ interface Props {
categorie: CategoriaSpesa[]; categorie: CategoriaSpesa[];
dipendenti?: Pick<Utente, "id" | "nome" | "cognome">[]; dipendenti?: Pick<Utente, "id" | "nome" | "cognome">[];
showDipendente?: boolean; showDipendente?: boolean;
showStato?: boolean;
} }
export default function FiltriRimborsi({ filtri, onChange, categorie, dipendenti, showDipendente }: Props) { export default function FiltriRimborsi({ filtri, onChange, categorie, dipendenti, showDipendente, showStato = true }: Props) {
return ( return (
<div className="filters"> <div className="filters">
<label> {showStato && (
Stato <label>
<select value={filtri.stato} onChange={(e) => onChange({ ...filtri, stato: e.target.value })}> Stato
<option value="">Tutti</option> <select value={filtri.stato} onChange={(e) => onChange({ ...filtri, stato: e.target.value })}>
{(Object.keys(STATO_LABELS) as StatoRichiesta[]).map((s) => ( <option value="">Tutti</option>
<option key={s} value={s}>{STATO_LABELS[s]}</option> {(Object.keys(STATO_LABELS) as StatoRichiesta[]).map((s) => (
))} <option key={s} value={s}>{STATO_LABELS[s]}</option>
</select> ))}
</label> </select>
</label>
)}
<label> <label>
Categoria Categoria
<select value={filtri.categoriaId} onChange={(e) => onChange({ ...filtri, categoriaId: e.target.value })}> <select value={filtri.categoriaId} onChange={(e) => onChange({ ...filtri, categoriaId: e.target.value })}>
@ -40,7 +43,7 @@ export default function FiltriRimborsi({ filtri, onChange, categorie, dipendenti
<select value={filtri.dipendenteId} onChange={(e) => onChange({ ...filtri, dipendenteId: e.target.value })}> <select value={filtri.dipendenteId} onChange={(e) => onChange({ ...filtri, dipendenteId: e.target.value })}>
<option value="">Tutti</option> <option value="">Tutti</option>
{dipendenti.map((d) => ( {dipendenti.map((d) => (
<option key={d.id} value={d.id}>{d.cognome} {d.nome}</option> <option key={d.id} value={String(d.id)}>{d.cognome} {d.nome}</option>
))} ))}
</select> </select>
</label> </label>

View file

@ -0,0 +1,11 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
export default function GuestRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return <p className="loading">Caricamento...</p>;
if (user) return <Navigate to="/dashboard" replace />;
return <>{children}</>;
}

View file

@ -13,7 +13,15 @@ export default function ProtectedRoute({
if (loading) return <p className="loading">Caricamento...</p>; if (loading) return <p className="loading">Caricamento...</p>;
if (!user) return <Navigate to="/login" replace />; if (!user) return <Navigate to="/login" replace />;
if (roles && !roles.includes(user.ruolo)) return <Navigate to="/dashboard" replace />; if (roles && !roles.includes(user.ruolo)) {
return (
<Navigate
to="/dashboard"
replace
state={{ denied: "Non hai i permessi per accedere a questa sezione." }}
/>
);
}
return <>{children}</>; return <>{children}</>;
} }

View file

@ -1,13 +1,26 @@
import { Link } from "react-router-dom"; import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
export default function Dashboard() { export default function Dashboard() {
const { user } = useAuth(); const { user } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const isAdmin = user?.ruolo === "RESPONSABILE_AMMINISTRATIVO"; const isAdmin = user?.ruolo === "RESPONSABILE_AMMINISTRATIVO";
const [deniedMessage, setDeniedMessage] = useState<string | null>(null);
useEffect(() => {
const denied = (location.state as { denied?: string } | null)?.denied;
if (denied) {
setDeniedMessage(denied);
navigate(location.pathname, { replace: true, state: {} });
}
}, [location, navigate]);
return ( return (
<div className="page"> <div className="page">
<h1>Dashboard</h1> <h1>Dashboard</h1>
{deniedMessage && <p className="error">{deniedMessage}</p>}
<p>Benvenuto, <strong>{user?.nome} {user?.cognome}</strong></p> <p>Benvenuto, <strong>{user?.nome} {user?.cognome}</strong></p>
<p className="muted">Ruolo: {isAdmin ? "Responsabile amministrativo" : "Dipendente"}</p> <p className="muted">Ruolo: {isAdmin ? "Responsabile amministrativo" : "Dipendente"}</p>

View file

@ -21,6 +21,12 @@ export default function Register() {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
setSuccess(null); setSuccess(null);
if (form.password !== form.confermaPassword) {
setError("Le password non coincidono");
return;
}
setLoading(true); setLoading(true);
try { try {
await register(form); await register(form);

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { getCategorie, getDipendenti, getRimborsi } from "../api/client"; import { getCategorie, getDipendenti, getRimborsi } from "../api/client";
import FiltriRimborsi from "../components/FiltriRimborsi"; import FiltriRimborsi from "../components/FiltriRimborsi";
import StatoBadge from "../components/StatoBadge"; import StatoBadge from "../components/StatoBadge";
@ -16,22 +16,37 @@ function formatEuro(n: number) {
export default function RimborsiList() { export default function RimborsiList() {
const { user } = useAuth(); const { user } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const isAdmin = user?.ruolo === "RESPONSABILE_AMMINISTRATIVO"; const isAdmin = user?.ruolo === "RESPONSABILE_AMMINISTRATIVO";
const [rimborsi, setRimborsi] = useState<RichiestaRimborso[]>([]); const [rimborsi, setRimborsi] = useState<RichiestaRimborso[]>([]);
const [categorie, setCategorie] = useState<CategoriaSpesa[]>([]); const [categorie, setCategorie] = useState<CategoriaSpesa[]>([]);
const [dipendenti, setDipendenti] = useState<Pick<Utente, "id" | "nome" | "cognome">[]>([]); const [dipendenti, setDipendenti] = useState<Pick<Utente, "id" | "nome" | "cognome">[]>([]);
const [filtri, setFiltri] = useState({ stato: "", categoriaId: "", mese: "", dipendenteId: "" }); const [filtri, setFiltri] = useState({ stato: "", categoriaId: "", mese: "", dipendenteId: "" });
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
Promise.all([ const flash = (location.state as { flash?: string } | null)?.flash;
getCategorie(), if (flash) {
isAdmin ? getDipendenti() : Promise.resolve([]), setSuccess(flash);
]).then(([cats, dips]) => { navigate(location.pathname, { replace: true, state: {} });
setCategorie(cats); }
setDipendenti(dips); }, [location, navigate]);
});
useEffect(() => {
if (!isAdmin) {
setDipendenti([]);
getCategorie().then(setCategorie).catch(() => {});
return;
}
Promise.all([getCategorie(), getDipendenti()])
.then(([cats, dips]) => {
setCategorie(cats);
setDipendenti(dips);
})
.catch((err) => setError(err instanceof Error ? err.message : "Errore caricamento filtri"));
}, [isAdmin]); }, [isAdmin]);
useEffect(() => { useEffect(() => {
@ -63,6 +78,7 @@ export default function RimborsiList() {
showDipendente={isAdmin} showDipendente={isAdmin}
/> />
{success && <p className="success">{success}</p>}
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{loading ? ( {loading ? (
<p className="loading">Caricamento...</p> <p className="loading">Caricamento...</p>
@ -75,8 +91,11 @@ export default function RimborsiList() {
<th>Data spesa</th> <th>Data spesa</th>
<th>Categoria</th> <th>Categoria</th>
{isAdmin && <th>Dipendente</th>} {isAdmin && <th>Dipendente</th>}
<th>Descrizione</th>
<th>Importo</th> <th>Importo</th>
<th>Stato</th> <th>Stato</th>
<th>Valutazione</th>
<th>Liquidazione</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -86,8 +105,11 @@ export default function RimborsiList() {
<td>{formatDate(r.dataSpesa)}</td> <td>{formatDate(r.dataSpesa)}</td>
<td>{r.categoria}</td> <td>{r.categoria}</td>
{isAdmin && <td>{r.dipendenteNome}</td>} {isAdmin && <td>{r.dipendenteNome}</td>}
<td className="cell-desc">{r.descrizione}</td>
<td>{formatEuro(r.importo)}</td> <td>{formatEuro(r.importo)}</td>
<td><StatoBadge stato={r.stato} /></td> <td><StatoBadge stato={r.stato} /></td>
<td>{r.dataValutazione ? formatDate(r.dataValutazione) : "—"}</td>
<td>{r.dataLiquidazione ? formatDate(r.dataLiquidazione) : "—"}</td>
<td><Link to={`/rimborsi/${r.id}`}>Dettaglio</Link></td> <td><Link to={`/rimborsi/${r.id}`}>Dettaglio</Link></td>
</tr> </tr>
))} ))}

View file

@ -33,11 +33,18 @@ export default function RimborsoDetail() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const load = () => { const load = () => {
setInitialLoading(true);
setError(null);
getRimborso(Number(id)) getRimborso(Number(id))
.then(setRimborso) .then(setRimborso)
.catch((err) => setError(err.message)); .catch((err) => {
setRimborso(null);
setError(err instanceof Error ? err.message : "Errore nel caricamento");
})
.finally(() => setInitialLoading(false));
}; };
useEffect(() => { load(); }, [id]); useEffect(() => { load(); }, [id]);
@ -62,14 +69,22 @@ export default function RimborsoDetail() {
setLoading(true); setLoading(true);
try { try {
await deleteRimborso(Number(id)); await deleteRimborso(Number(id));
navigate("/rimborsi"); navigate("/rimborsi", { state: { flash: "Richiesta eliminata con successo." } });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Eliminazione fallita"); setError(err instanceof Error ? err.message : "Eliminazione fallita");
setLoading(false); setLoading(false);
} }
}; };
if (!rimborso) return <p className="loading">Caricamento...</p>; if (initialLoading) return <p className="loading">Caricamento...</p>;
if (!rimborso) {
return (
<div className="page">
<p className="error">{error ?? "Richiesta non trovata"}</p>
<Link to="/rimborsi" className="btn btn-secondary">Torna all'elenco</Link>
</div>
);
}
const canEdit = !isAdmin && rimborso.stato === "IN_ATTESA" && rimborso.dipendenteId === user?.id; const canEdit = !isAdmin && rimborso.stato === "IN_ATTESA" && rimborso.dipendenteId === user?.id;

View file

@ -1,11 +1,13 @@
import { useEffect, useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { createRimborso, getCategorie, getRimborso, updateRimborso } from "../api/client"; import { createRimborso, getCategorie, getRimborso, updateRimborso } from "../api/client";
import { useAuth } from "../context/AuthContext";
export default function RimborsoForm() { export default function RimborsoForm() {
const { id } = useParams(); const { id } = useParams();
const isEdit = Boolean(id); const isEdit = Boolean(id);
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth();
const [categorie, setCategorie] = useState<{ id: number; descrizione: string }[]>([]); const [categorie, setCategorie] = useState<{ id: number; descrizione: string }[]>([]);
const [form, setForm] = useState({ const [form, setForm] = useState({
dataSpesa: "", dataSpesa: "",
@ -16,6 +18,7 @@ export default function RimborsoForm() {
}); });
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(isEdit);
useEffect(() => { useEffect(() => {
getCategorie().then((cats) => { getCategorie().then((cats) => {
@ -26,16 +29,27 @@ export default function RimborsoForm() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
getRimborso(Number(id)).then((r) => { getRimborso(Number(id))
setForm({ .then((r) => {
dataSpesa: r.dataSpesa.slice(0, 10), if (r.stato !== "IN_ATTESA") {
categoriaId: String(r.categoriaId), setError("La richiesta non può essere modificata nello stato corrente");
importo: String(r.importo), return;
descrizione: r.descrizione, }
riferimentoGiustificativo: r.riferimentoGiustificativo ?? "", if (r.dipendenteId !== user?.id) {
}); setError("Non puoi modificare questa richiesta");
}).catch((err) => setError(err.message)); return;
}, [id]); }
setForm({
dataSpesa: r.dataSpesa.slice(0, 10),
categoriaId: String(r.categoriaId),
importo: String(r.importo),
descrizione: r.descrizione,
riferimentoGiustificativo: r.riferimentoGiustificativo ?? "",
});
})
.catch((err) => setError(err instanceof Error ? err.message : "Errore nel caricamento"))
.finally(() => setInitialLoading(false));
}, [id, user?.id]);
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -51,10 +65,11 @@ export default function RimborsoForm() {
try { try {
if (isEdit) { if (isEdit) {
await updateRimborso(Number(id), payload); await updateRimborso(Number(id), payload);
navigate("/rimborsi", { state: { flash: "Richiesta aggiornata con successo." } });
} else { } else {
await createRimborso(payload); await createRimborso(payload);
navigate("/rimborsi", { state: { flash: "Richiesta creata con successo." } });
} }
navigate("/rimborsi");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Errore nel salvataggio"); setError(err instanceof Error ? err.message : "Errore nel salvataggio");
} finally { } finally {
@ -62,9 +77,19 @@ export default function RimborsoForm() {
} }
}; };
if (initialLoading) return <p className="loading">Caricamento...</p>;
const blocked = isEdit && error && !form.dataSpesa;
return ( return (
<div className="page"> <div className="page">
<h1>{isEdit ? "Modifica richiesta" : "Nuova richiesta di rimborso"}</h1> <h1>{isEdit ? "Modifica richiesta" : "Nuova richiesta di rimborso"}</h1>
{blocked ? (
<div className="form-card">
<p className="error">{error}</p>
<Link to={`/rimborsi/${id}`} className="btn btn-secondary">Torna al dettaglio</Link>
</div>
) : (
<form className="form-card" onSubmit={handleSubmit}> <form className="form-card" onSubmit={handleSubmit}>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<label> <label>
@ -98,6 +123,7 @@ export default function RimborsoForm() {
<Link to="/rimborsi" className="btn btn-secondary">Annulla</Link> <Link to="/rimborsi" className="btn btn-secondary">Annulla</Link>
</div> </div>
</form> </form>
)}
</div> </div>
); );
} }

View file

@ -17,7 +17,8 @@ export default function Statistiche() {
useEffect(() => { useEffect(() => {
Promise.all([getCategorie(), getDipendenti()]) Promise.all([getCategorie(), getDipendenti()])
.then(([cats, dips]) => { setCategorie(cats); setDipendenti(dips); }); .then(([cats, dips]) => { setCategorie(cats); setDipendenti(dips); })
.catch((err) => setError(err instanceof Error ? err.message : "Errore caricamento filtri"));
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -44,6 +45,7 @@ export default function Statistiche() {
categorie={categorie} categorie={categorie}
dipendenti={dipendenti} dipendenti={dipendenti}
showDipendente showDipendente
showStato={false}
/> />
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}