messaggi di conferma su rimborsi, messaggio per accesso neagato e altro
This commit is contained in:
parent
4ff685557f
commit
59e7343598
12 changed files with 199 additions and 97 deletions
104
README.md
104
README.md
|
|
@ -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)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
| Risorsa | URL |
|
||||
|---------|-----|
|
||||
| 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
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
## Funzionalità
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
- Login / registrazione / logout
|
||||
- Dashboard differenziata per ruolo
|
||||
- Dipendente: creazione, elenco, filtri, modifica ed eliminazione richieste in attesa
|
||||
- Responsabile: gestione di tutte le richieste, approvazione, rifiuto, liquidazione
|
||||
- Statistiche aggregate per mese e categoria
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
## Avvio locale
|
||||
|
||||
Configurare il file `.env` (`VITE_API_URL` vuoto in sviluppo per usare il proxy Vite verso il backend locale).
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
Build di produzione:
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```bash
|
||||
VITE_API_URL=https://provapraticabackend.andreavillari.it npm run build
|
||||
```
|
||||
|
||||
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`.
|
||||
|
|
|
|||
|
|
@ -219,6 +219,13 @@ input:focus, select:focus, textarea:focus {
|
|||
color: #2563eb;
|
||||
}
|
||||
|
||||
.table .cell-desc {
|
||||
max-width: 220px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import GuestRoute from "./components/GuestRoute";
|
||||
import Layout from "./components/Layout";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
|
|
@ -17,9 +18,9 @@ export default function App() {
|
|||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/" element={<GuestRoute><Home /></GuestRoute>} />
|
||||
<Route path="/login" element={<GuestRoute><Login /></GuestRoute>} />
|
||||
<Route path="/register" element={<GuestRoute><Register /></GuestRoute>} />
|
||||
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/rimborsi" element={<RimborsiList />} />
|
||||
|
|
|
|||
|
|
@ -7,20 +7,23 @@ interface Props {
|
|||
categorie: CategoriaSpesa[];
|
||||
dipendenti?: Pick<Utente, "id" | "nome" | "cognome">[];
|
||||
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 (
|
||||
<div className="filters">
|
||||
<label>
|
||||
Stato
|
||||
<select value={filtri.stato} onChange={(e) => onChange({ ...filtri, stato: e.target.value })}>
|
||||
<option value="">Tutti</option>
|
||||
{(Object.keys(STATO_LABELS) as StatoRichiesta[]).map((s) => (
|
||||
<option key={s} value={s}>{STATO_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{showStato && (
|
||||
<label>
|
||||
Stato
|
||||
<select value={filtri.stato} onChange={(e) => onChange({ ...filtri, stato: e.target.value })}>
|
||||
<option value="">Tutti</option>
|
||||
{(Object.keys(STATO_LABELS) as StatoRichiesta[]).map((s) => (
|
||||
<option key={s} value={s}>{STATO_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Categoria
|
||||
<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 })}>
|
||||
<option value="">Tutti</option>
|
||||
{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>
|
||||
</label>
|
||||
|
|
|
|||
11
src/components/GuestRoute.tsx
Normal file
11
src/components/GuestRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
|
|
@ -13,7 +13,15 @@ export default function ProtectedRoute({
|
|||
|
||||
if (loading) return <p className="loading">Caricamento...</p>;
|
||||
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}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
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 (
|
||||
<div className="page">
|
||||
<h1>Dashboard</h1>
|
||||
{deniedMessage && <p className="error">{deniedMessage}</p>}
|
||||
<p>Benvenuto, <strong>{user?.nome} {user?.cognome}</strong></p>
|
||||
<p className="muted">Ruolo: {isAdmin ? "Responsabile amministrativo" : "Dipendente"}</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ export default function Register() {
|
|||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (form.password !== form.confermaPassword) {
|
||||
setError("Le password non coincidono");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(form);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 FiltriRimborsi from "../components/FiltriRimborsi";
|
||||
import StatoBadge from "../components/StatoBadge";
|
||||
|
|
@ -16,22 +16,37 @@ function formatEuro(n: number) {
|
|||
|
||||
export default function RimborsiList() {
|
||||
const { user } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = user?.ruolo === "RESPONSABILE_AMMINISTRATIVO";
|
||||
const [rimborsi, setRimborsi] = useState<RichiestaRimborso[]>([]);
|
||||
const [categorie, setCategorie] = useState<CategoriaSpesa[]>([]);
|
||||
const [dipendenti, setDipendenti] = useState<Pick<Utente, "id" | "nome" | "cognome">[]>([]);
|
||||
const [filtri, setFiltri] = useState({ stato: "", categoriaId: "", mese: "", dipendenteId: "" });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getCategorie(),
|
||||
isAdmin ? getDipendenti() : Promise.resolve([]),
|
||||
]).then(([cats, dips]) => {
|
||||
setCategorie(cats);
|
||||
setDipendenti(dips);
|
||||
});
|
||||
const flash = (location.state as { flash?: string } | null)?.flash;
|
||||
if (flash) {
|
||||
setSuccess(flash);
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}
|
||||
}, [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]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -63,6 +78,7 @@ export default function RimborsiList() {
|
|||
showDipendente={isAdmin}
|
||||
/>
|
||||
|
||||
{success && <p className="success">{success}</p>}
|
||||
{error && <p className="error">{error}</p>}
|
||||
{loading ? (
|
||||
<p className="loading">Caricamento...</p>
|
||||
|
|
@ -75,8 +91,11 @@ export default function RimborsiList() {
|
|||
<th>Data spesa</th>
|
||||
<th>Categoria</th>
|
||||
{isAdmin && <th>Dipendente</th>}
|
||||
<th>Descrizione</th>
|
||||
<th>Importo</th>
|
||||
<th>Stato</th>
|
||||
<th>Valutazione</th>
|
||||
<th>Liquidazione</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -86,8 +105,11 @@ export default function RimborsiList() {
|
|||
<td>{formatDate(r.dataSpesa)}</td>
|
||||
<td>{r.categoria}</td>
|
||||
{isAdmin && <td>{r.dipendenteNome}</td>}
|
||||
<td className="cell-desc">{r.descrizione}</td>
|
||||
<td>{formatEuro(r.importo)}</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>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -33,11 +33,18 @@ export default function RimborsoDetail() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
const load = () => {
|
||||
setInitialLoading(true);
|
||||
setError(null);
|
||||
getRimborso(Number(id))
|
||||
.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]);
|
||||
|
|
@ -62,14 +69,22 @@ export default function RimborsoDetail() {
|
|||
setLoading(true);
|
||||
try {
|
||||
await deleteRimborso(Number(id));
|
||||
navigate("/rimborsi");
|
||||
navigate("/rimborsi", { state: { flash: "Richiesta eliminata con successo." } });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Eliminazione fallita");
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { createRimborso, getCategorie, getRimborso, updateRimborso } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export default function RimborsoForm() {
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [categorie, setCategorie] = useState<{ id: number; descrizione: string }[]>([]);
|
||||
const [form, setForm] = useState({
|
||||
dataSpesa: "",
|
||||
|
|
@ -16,6 +18,7 @@ export default function RimborsoForm() {
|
|||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(isEdit);
|
||||
|
||||
useEffect(() => {
|
||||
getCategorie().then((cats) => {
|
||||
|
|
@ -26,16 +29,27 @@ export default function RimborsoForm() {
|
|||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
getRimborso(Number(id)).then((r) => {
|
||||
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.message));
|
||||
}, [id]);
|
||||
getRimborso(Number(id))
|
||||
.then((r) => {
|
||||
if (r.stato !== "IN_ATTESA") {
|
||||
setError("La richiesta non può essere modificata nello stato corrente");
|
||||
return;
|
||||
}
|
||||
if (r.dipendenteId !== user?.id) {
|
||||
setError("Non puoi modificare questa richiesta");
|
||||
return;
|
||||
}
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -51,10 +65,11 @@ export default function RimborsoForm() {
|
|||
try {
|
||||
if (isEdit) {
|
||||
await updateRimborso(Number(id), payload);
|
||||
navigate("/rimborsi", { state: { flash: "Richiesta aggiornata con successo." } });
|
||||
} else {
|
||||
await createRimborso(payload);
|
||||
navigate("/rimborsi", { state: { flash: "Richiesta creata con successo." } });
|
||||
}
|
||||
navigate("/rimborsi");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Errore nel salvataggio");
|
||||
} finally {
|
||||
|
|
@ -62,9 +77,19 @@ export default function RimborsoForm() {
|
|||
}
|
||||
};
|
||||
|
||||
if (initialLoading) return <p className="loading">Caricamento...</p>;
|
||||
|
||||
const blocked = isEdit && error && !form.dataSpesa;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<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}>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<label>
|
||||
|
|
@ -98,6 +123,7 @@ export default function RimborsoForm() {
|
|||
<Link to="/rimborsi" className="btn btn-secondary">Annulla</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ export default function Statistiche() {
|
|||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
|
|
@ -44,6 +45,7 @@ export default function Statistiche() {
|
|||
categorie={categorie}
|
||||
dipendenti={dipendenti}
|
||||
showDipendente
|
||||
showStato={false}
|
||||
/>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue