diff --git a/package-lock.json b/package-lock.json index 9cb080d..d8d5c99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "dependencies": { "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "react-router-dom": "^7.17.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -1306,6 +1307,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2367,6 +2381,44 @@ "react": "^19.2.7" } }, + "node_modules/react-router": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", + "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "license": "MIT", + "dependencies": { + "react-router": "7.17.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -2417,6 +2469,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index a2ffbcf..22ba891 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ }, "dependencies": { "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "react-router-dom": "^7.17.0" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/App.css b/src/App.css index 8914feb..dde569d 100644 --- a/src/App.css +++ b/src/App.css @@ -1,26 +1,326 @@ -.app { +.layout { min-height: 100vh; display: flex; flex-direction: column; +} + +.header { + background: #1e3a5f; + color: #fff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.header-inner { + max-width: 1100px; + margin: 0 auto; + padding: 0.75rem 1.5rem; + display: flex; + align-items: center; + gap: 1.5rem; + flex-wrap: wrap; +} + +.logo { + color: #fff; + font-weight: 700; + font-size: 1.1rem; + text-decoration: none; +} + +.header nav { + display: flex; + gap: 1rem; + flex: 1; +} + +.header nav a { + color: #cbd5e1; + text-decoration: none; + font-size: 0.95rem; +} + +.header nav a:hover { + color: #fff; +} + +.user-bar { + display: flex; + align-items: center; + gap: 1rem; + font-size: 0.9rem; +} + +.user-bar button { + background: transparent; + border: 1px solid #94a3b8; + color: #fff; + padding: 0.35rem 0.75rem; + border-radius: 6px; + cursor: pointer; +} + +.user-bar button:hover { + background: rgba(255, 255, 255, 0.1); +} + +.main { + flex: 1; + max-width: 1100px; + width: 100%; + margin: 0 auto; + padding: 2rem 1.5rem; +} + +.page h1 { + margin: 0 0 0.5rem; + color: #1e293b; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; +} + +.auth-page { + min-height: 100vh; + display: flex; align-items: center; justify-content: center; - gap: 1rem; padding: 2rem; + background: linear-gradient(135deg, #f0f4f8 0%, #e2e8f0 100%); +} + +.auth-card { + background: #fff; + padding: 2rem; + border-radius: 12px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); + width: 100%; + max-width: 420px; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.auth-card h1 { + margin: 0; + color: #1e3a5f; +} + +.auth-actions { + display: flex; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.auth-link { + text-align: center; + font-size: 0.9rem; + color: #64748b; +} + +.form-card { + background: #fff; + padding: 1.5rem; + border-radius: 10px; + border: 1px solid #e2e8f0; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 1rem; +} + +label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.9rem; + font-weight: 500; + color: #334155; +} + +input, select, textarea { + padding: 0.6rem 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 1rem; + font-family: inherit; +} + +input:focus, select:focus, textarea:focus { + outline: 2px solid #3b82f6; + outline-offset: 1px; +} + +.btn { + display: inline-block; + padding: 0.6rem 1.2rem; + border-radius: 6px; + font-size: 0.95rem; + font-weight: 500; + text-decoration: none; + border: none; + cursor: pointer; text-align: center; } -.status-card { - margin-top: 1rem; - padding: 1.5rem 2rem; - border: 1px solid #646cff; - border-radius: 12px; - min-width: 280px; +.btn-primary { + background: #2563eb; + color: #fff; } -.ok { - color: #2e7d32; +.btn-primary:hover:not(:disabled) { + background: #1d4ed8; } -.error { - color: #c62828; +.btn-secondary { + background: #f1f5f9; + color: #334155; + border: 1px solid #cbd5e1; } + +.btn-danger { + background: #dc2626; + color: #fff; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.table { + width: 100%; + border-collapse: collapse; + background: #fff; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +.table th, .table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid #e2e8f0; +} + +.table th { + background: #f8fafc; + font-weight: 600; + color: #475569; + font-size: 0.85rem; +} + +.table a { + color: #2563eb; +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: #f8fafc; + border-radius: 8px; +} + +.filters label { + min-width: 140px; +} + +.badge { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; +} + +.badge-in_attesa { background: #fef3c7; color: #92400e; } +.badge-approvata { background: #d1fae5; color: #065f46; } +.badge-rifiutata { background: #fee2e2; color: #991b1b; } +.badge-liquidata { background: #dbeafe; color: #1e40af; } + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +.card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 1.25rem; + text-decoration: none; + color: inherit; + transition: box-shadow 0.2s; +} + +.card:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); +} + +.card h3 { + margin: 0 0 0.5rem; + color: #1e3a5f; +} + +.card p { + margin: 0; + color: #64748b; + font-size: 0.9rem; +} + +.detail-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.detail-grid { + display: grid; + grid-template-columns: 180px 1fr; + gap: 0.75rem 1rem; + margin: 0; +} + +.detail-grid dt { + font-weight: 600; + color: #64748b; +} + +.detail-grid dd { + margin: 0; + color: #1e293b; +} + +.form-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.reject-box { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.reject-box input { + min-width: 220px; +} + +.error { color: #dc2626; margin: 0; } +.success { color: #059669; margin: 0; } +.muted { color: #64748b; } +.loading { color: #64748b; font-style: italic; } diff --git a/src/App.tsx b/src/App.tsx index ee32326..6ceb9dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,31 +1,42 @@ -import { useEffect, useState } from "react"; -import { fetchHealth } from "./api/client"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import Layout from "./components/Layout"; +import ProtectedRoute from "./components/ProtectedRoute"; +import { AuthProvider } from "./context/AuthContext"; +import Dashboard from "./pages/Dashboard"; +import Home from "./pages/Home"; +import Login from "./pages/Login"; +import Register from "./pages/Register"; +import RimborsoDetail from "./pages/RimborsoDetail"; +import RimborsoForm from "./pages/RimborsoForm"; +import RimborsiList from "./pages/RimborsiList"; +import Statistiche from "./pages/Statistiche"; import "./App.css"; -function App() { - const [health, setHealth] = useState("in attesa..."); - const [error, setError] = useState(null); - - useEffect(() => { - fetchHealth() - .then((data) => setHealth(data.status)) - .catch((err: Error) => setError(err.message)); - }, []); - +export default function App() { return ( -
-

Prova Pratica

-

Frontend React / Vite / TypeScript

-
-

Backend health

- {error ? ( -

{error}

- ) : ( -

status: {health}

- )} -
-
+ + + + } /> + } /> + } /> + }> + } /> + } /> + + } /> + + } /> + } /> + + } /> + + } /> + + + ); } - -export default App; diff --git a/src/api/client.ts b/src/api/client.ts index e5636e5..42c55f4 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,13 +1,172 @@ +import type { + CategoriaSpesa, + RichiestaRimborso, + Ruolo, + StatisticaRimborso, + StatoRichiesta, + Utente, +} from "../types"; + const API_URL = import.meta.env.VITE_API_URL ?? ""; -export async function fetchHealth(): Promise<{ status: string }> { - const response = await fetch(`${API_URL}/api/health`, { +async function request(path: string, options: RequestInit = {}): Promise { + const response = await fetch(`${API_URL}${path}`, { credentials: "include", + headers: { + "Content-Type": "application/json", + ...(options.headers ?? {}), + }, + ...options, }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { - throw new Error(`Health check failed: ${response.status}`); + throw new Error(data.error ?? `Errore ${response.status}`); } - return response.json(); + return data as T; +} + +export async function fetchHealth(): Promise<{ status: string }> { + return request("/api/health"); +} + +export async function getCsrfToken(): Promise { + const data = await request<{ csrfToken: string }>("/api/auth/csrf"); + return data.csrfToken; +} + +export async function login(email: string, password: string): Promise { + const csrfToken = await getCsrfToken(); + const body = new URLSearchParams({ + csrfToken, + email, + password, + redirect: "false", + json: "true", + }); + + const response = await fetch(`${API_URL}/api/auth/callback/credentials`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok || data.error) { + throw new Error("Credenziali non valide"); + } +} + +export async function logout(): Promise { + const csrfToken = await getCsrfToken(); + const body = new URLSearchParams({ csrfToken, json: "true" }); + await fetch(`${API_URL}/api/auth/signout`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); +} + +export async function getMe(): Promise { + return request("/api/utenti/me"); +} + +export async function register(data: { + nome: string; + cognome: string; + email: string; + password: string; + confermaPassword: string; + ruolo: Ruolo; +}): Promise { + await request("/api/utenti/register", { + method: "POST", + body: JSON.stringify(data), + }); +} + +export async function getCategorie(): Promise { + return request("/api/categorie-spesa"); +} + +export async function getDipendenti(): Promise[]> { + return request("/api/utenti/dipendenti"); +} + +export async function getRimborsi(filters: { + stato?: StatoRichiesta; + categoriaId?: number; + mese?: string; + dipendenteId?: number; +}): Promise { + const params = new URLSearchParams(); + if (filters.stato) params.set("stato", filters.stato); + if (filters.categoriaId) params.set("categoriaId", String(filters.categoriaId)); + if (filters.mese) params.set("mese", filters.mese); + if (filters.dipendenteId) params.set("dipendenteId", String(filters.dipendenteId)); + const qs = params.toString(); + return request(`/api/rimborsi${qs ? `?${qs}` : ""}`); +} + +export async function getRimborso(id: number): Promise { + return request(`/api/rimborsi/${id}`); +} + +export async function createRimborso(data: { + dataSpesa: string; + categoriaId: number; + importo: number; + descrizione: string; + riferimentoGiustificativo?: string; +}): Promise { + return request("/api/rimborsi", { method: "POST", body: JSON.stringify(data) }); +} + +export async function updateRimborso( + id: number, + data: { + dataSpesa: string; + categoriaId: number; + importo: number; + descrizione: string; + riferimentoGiustificativo?: string; + } +): Promise { + return request(`/api/rimborsi/${id}`, { method: "PUT", body: JSON.stringify(data) }); +} + +export async function deleteRimborso(id: number): Promise { + await request(`/api/rimborsi/${id}`, { method: "DELETE" }); +} + +export async function approvaRimborso(id: number): Promise { + return request(`/api/rimborsi/${id}/approva`, { method: "PUT", body: "{}" }); +} + +export async function rifiutaRimborso(id: number, motivazione?: string): Promise { + return request(`/api/rimborsi/${id}/rifiuta`, { + method: "PUT", + body: JSON.stringify({ motivazione }), + }); +} + +export async function liquidaRimborso(id: number): Promise { + return request(`/api/rimborsi/${id}/liquida`, { method: "PUT", body: "{}" }); +} + +export async function getStatistiche(filters: { + mese?: string; + categoriaId?: number; + dipendenteId?: number; +}): Promise { + const params = new URLSearchParams(); + if (filters.mese) params.set("mese", filters.mese); + if (filters.categoriaId) params.set("categoriaId", String(filters.categoriaId)); + if (filters.dipendenteId) params.set("dipendenteId", String(filters.dipendenteId)); + const qs = params.toString(); + return request(`/api/statistiche/rimborsi${qs ? `?${qs}` : ""}`); } diff --git a/src/components/FiltriRimborsi.tsx b/src/components/FiltriRimborsi.tsx new file mode 100644 index 0000000..7685ecc --- /dev/null +++ b/src/components/FiltriRimborsi.tsx @@ -0,0 +1,50 @@ +import type { CategoriaSpesa, StatoRichiesta, Utente } from "../types"; +import { STATO_LABELS } from "../types"; + +interface Props { + filtri: { stato: string; categoriaId: string; mese: string; dipendenteId: string }; + onChange: (filtri: Props["filtri"]) => void; + categorie: CategoriaSpesa[]; + dipendenti?: Pick[]; + showDipendente?: boolean; +} + +export default function FiltriRimborsi({ filtri, onChange, categorie, dipendenti, showDipendente }: Props) { + return ( +
+ + + + {showDipendente && dipendenti && ( + + )} +
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..b04d734 --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,38 @@ +import { Link, Outlet, useNavigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; + +export default function Layout() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const isAdmin = user?.ruolo === "RESPONSABILE_AMMINISTRATIVO"; + + const handleLogout = async () => { + await logout(); + navigate("/login"); + }; + + return ( +
+
+
+ + Rimborsi Spese + + +
+ {user?.nome} {user?.cognome} + +
+
+
+
+ +
+
+ ); +} diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..87eb323 --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,19 @@ +import { Navigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import type { Ruolo } from "../types"; + +export default function ProtectedRoute({ + children, + roles, +}: { + children: React.ReactNode; + roles?: Ruolo[]; +}) { + const { user, loading } = useAuth(); + + if (loading) return

Caricamento...

; + if (!user) return ; + if (roles && !roles.includes(user.ruolo)) return ; + + return <>{children}; +} diff --git a/src/components/StatoBadge.tsx b/src/components/StatoBadge.tsx new file mode 100644 index 0000000..54394b4 --- /dev/null +++ b/src/components/StatoBadge.tsx @@ -0,0 +1,5 @@ +import { STATO_LABELS, type StatoRichiesta } from "../types"; + +export default function StatoBadge({ stato }: { stato: StatoRichiesta }) { + return {STATO_LABELS[stato]}; +} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..e6cf6fc --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,53 @@ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; +import { getMe, login as apiLogin, logout as apiLogout } from "../api/client"; +import type { Utente } from "../types"; + +interface AuthContextValue { + user: Utente | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; + refresh: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const me = await getMe(); + setUser(me); + } catch { + setUser(null); + } + }, []); + + useEffect(() => { + refresh().finally(() => setLoading(false)); + }, [refresh]); + + const login = async (email: string, password: string) => { + await apiLogin(email, password); + await refresh(); + }; + + const logout = async () => { + await apiLogout(); + setUser(null); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth deve essere usato dentro AuthProvider"); + return ctx; +} diff --git a/src/index.css b/src/index.css index 5fb3313..ae829f4 100644 --- a/src/index.css +++ b/src/index.css @@ -1,111 +1,21 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; +*, +*::before, +*::after { box-sizing: border-box; } body { margin: 0; + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; + background: #f1f5f9; + color: #1e293b; + line-height: 1.5; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); +#root { + min-height: 100vh; } -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +a { + color: #2563eb; } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..1a1ca1e --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,34 @@ +import { Link } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; + +export default function Dashboard() { + const { user } = useAuth(); + const isAdmin = user?.ruolo === "RESPONSABILE_AMMINISTRATIVO"; + + return ( +
+

Dashboard

+

Benvenuto, {user?.nome} {user?.cognome}

+

Ruolo: {isAdmin ? "Responsabile amministrativo" : "Dipendente"}

+ +
+ +

Richieste di rimborso

+

{isAdmin ? "Visualizza e gestisci tutte le richieste" : "Visualizza le tue richieste"}

+ + {!isAdmin && ( + +

Nuova richiesta

+

Inserisci una nuova spesa da rimborsare

+ + )} + {isAdmin && ( + +

Statistiche

+

Riepilogo per mese e categoria

+ + )} +
+
+ ); +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000..d0d81b4 --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,16 @@ +import { Link } from "react-router-dom"; + +export default function Home() { + return ( +
+
+

Gestione Rimborsi Spese

+

Sistema aziendale per la gestione delle richieste di rimborso spese.

+
+ Accedi + Registrati +
+
+
+ ); +} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..1a46559 --- /dev/null +++ b/src/pages/Login.tsx @@ -0,0 +1,49 @@ +import { useState, type FormEvent } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; + +export default function Login() { + const { login } = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + await login(email, password); + navigate("/dashboard"); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore di accesso"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Accedi

+ {error &&

{error}

} + + + +

+ Non hai un account? Registrati +

+
+
+ ); +} diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx new file mode 100644 index 0000000..dba3ad6 --- /dev/null +++ b/src/pages/Register.tsx @@ -0,0 +1,78 @@ +import { useState, type FormEvent } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { register } from "../api/client"; +import type { Ruolo } from "../types"; + +export default function Register() { + const navigate = useNavigate(); + const [form, setForm] = useState({ + nome: "", + cognome: "", + email: "", + password: "", + confermaPassword: "", + ruolo: "DIPENDENTE" as Ruolo, + }); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(null); + setLoading(true); + try { + await register(form); + setSuccess("Registrazione completata. Ora puoi accedere."); + setTimeout(() => navigate("/login"), 1500); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore di registrazione"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Registrati

+ {error &&

{error}

} + {success &&

{success}

} + + + + + + + +

+ Hai già un account? Accedi +

+
+
+ ); +} diff --git a/src/pages/RimborsiList.tsx b/src/pages/RimborsiList.tsx new file mode 100644 index 0000000..fffc84b --- /dev/null +++ b/src/pages/RimborsiList.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { getCategorie, getDipendenti, getRimborsi } from "../api/client"; +import FiltriRimborsi from "../components/FiltriRimborsi"; +import StatoBadge from "../components/StatoBadge"; +import { useAuth } from "../context/AuthContext"; +import type { CategoriaSpesa, RichiestaRimborso, StatoRichiesta, Utente } from "../types"; + +function formatDate(d: string) { + return new Date(d).toLocaleDateString("it-IT"); +} + +function formatEuro(n: number) { + return n.toLocaleString("it-IT", { style: "currency", currency: "EUR" }); +} + +export default function RimborsiList() { + const { user } = useAuth(); + const isAdmin = user?.ruolo === "RESPONSABILE_AMMINISTRATIVO"; + const [rimborsi, setRimborsi] = useState([]); + const [categorie, setCategorie] = useState([]); + const [dipendenti, setDipendenti] = useState[]>([]); + const [filtri, setFiltri] = useState({ stato: "", categoriaId: "", mese: "", dipendenteId: "" }); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([ + getCategorie(), + isAdmin ? getDipendenti() : Promise.resolve([]), + ]).then(([cats, dips]) => { + setCategorie(cats); + setDipendenti(dips); + }); + }, [isAdmin]); + + useEffect(() => { + setLoading(true); + setError(null); + getRimborsi({ + stato: (filtri.stato as StatoRichiesta) || undefined, + categoriaId: filtri.categoriaId ? Number(filtri.categoriaId) : undefined, + mese: filtri.mese || undefined, + dipendenteId: filtri.dipendenteId ? Number(filtri.dipendenteId) : undefined, + }) + .then(setRimborsi) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [filtri]); + + return ( +
+
+

Richieste di rimborso

+ {!isAdmin && Nuova richiesta} +
+ + + + {error &&

{error}

} + {loading ? ( +

Caricamento...

+ ) : rimborsi.length === 0 ? ( +

Nessuna richiesta trovata.

+ ) : ( + + + + + + {isAdmin && } + + + + + + + {rimborsi.map((r) => ( + + + + {isAdmin && } + + + + + ))} + +
Data spesaCategoriaDipendenteImportoStato
{formatDate(r.dataSpesa)}{r.categoria}{r.dipendenteNome}{formatEuro(r.importo)}Dettaglio
+ )} +
+ ); +} diff --git a/src/pages/RimborsoDetail.tsx b/src/pages/RimborsoDetail.tsx new file mode 100644 index 0000000..e75a98a --- /dev/null +++ b/src/pages/RimborsoDetail.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { + approvaRimborso, + deleteRimborso, + getRimborso, + liquidaRimborso, + rifiutaRimborso, +} from "../api/client"; +import StatoBadge from "../components/StatoBadge"; +import { useAuth } from "../context/AuthContext"; +import type { RichiestaRimborso } from "../types"; + +function formatDateTime(d: string | null) { + return d ? new Date(d).toLocaleString("it-IT") : "—"; +} + +function formatDate(d: string) { + return new Date(d).toLocaleDateString("it-IT"); +} + +function formatEuro(n: number) { + return n.toLocaleString("it-IT", { style: "currency", currency: "EUR" }); +} + +export default function RimborsoDetail() { + const { id } = useParams(); + const navigate = useNavigate(); + const { user } = useAuth(); + const isAdmin = user?.ruolo === "RESPONSABILE_AMMINISTRATIVO"; + const [rimborso, setRimborso] = useState(null); + const [motivazione, setMotivazione] = useState(""); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [loading, setLoading] = useState(false); + + const load = () => { + getRimborso(Number(id)) + .then(setRimborso) + .catch((err) => setError(err.message)); + }; + + useEffect(() => { load(); }, [id]); + + const runAction = async (action: () => Promise, successMsg: string) => { + setError(null); + setMessage(null); + setLoading(true); + try { + const updated = await action(); + setRimborso(updated); + setMessage(successMsg); + } catch (err) { + setError(err instanceof Error ? err.message : "Operazione fallita"); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + if (!confirm("Eliminare questa richiesta?")) return; + setLoading(true); + try { + await deleteRimborso(Number(id)); + navigate("/rimborsi"); + } catch (err) { + setError(err instanceof Error ? err.message : "Eliminazione fallita"); + setLoading(false); + } + }; + + if (!rimborso) return

Caricamento...

; + + const canEdit = !isAdmin && rimborso.stato === "IN_ATTESA" && rimborso.dipendenteId === user?.id; + + return ( +
+
+

Dettaglio richiesta #{rimborso.id}

+ Torna all'elenco +
+ + {error &&

{error}

} + {message &&

{message}

} + +
+
+
Stato
+
Data spesa
{formatDate(rimborso.dataSpesa)}
+
Categoria
{rimborso.categoria}
+
Importo
{formatEuro(rimborso.importo)}
+
Descrizione
{rimborso.descrizione}
+
Giustificativo
{rimborso.riferimentoGiustificativo ?? "—"}
+
Dipendente
{rimborso.dipendenteNome}
+
Data inserimento
{formatDateTime(rimborso.dataInserimento)}
+
Data valutazione
{formatDateTime(rimborso.dataValutazione)}
+
Responsabile
{rimborso.responsabileNome ?? "—"}
+
Motivazione rifiuto
{rimborso.motivazioneRifiuto ?? "—"}
+
Data liquidazione
{formatDateTime(rimborso.dataLiquidazione)}
+
+
+ +
+ {canEdit && ( + <> + Modifica + + + )} + {isAdmin && rimborso.stato === "IN_ATTESA" && ( + <> + +
+ setMotivazione(e.target.value)} /> + +
+ + )} + {isAdmin && rimborso.stato === "APPROVATA" && ( + + )} +
+
+ ); +} diff --git a/src/pages/RimborsoForm.tsx b/src/pages/RimborsoForm.tsx new file mode 100644 index 0000000..423343c --- /dev/null +++ b/src/pages/RimborsoForm.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { createRimborso, getCategorie, getRimborso, updateRimborso } from "../api/client"; + +export default function RimborsoForm() { + const { id } = useParams(); + const isEdit = Boolean(id); + const navigate = useNavigate(); + const [categorie, setCategorie] = useState<{ id: number; descrizione: string }[]>([]); + const [form, setForm] = useState({ + dataSpesa: "", + categoriaId: "", + importo: "", + descrizione: "", + riferimentoGiustificativo: "", + }); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + getCategorie().then((cats) => { + setCategorie(cats); + if (!isEdit && cats.length) setForm((f) => ({ ...f, categoriaId: String(cats[0].id) })); + }); + }, [isEdit]); + + 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]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + const payload = { + dataSpesa: form.dataSpesa, + categoriaId: Number(form.categoriaId), + importo: Number(form.importo), + descrizione: form.descrizione, + riferimentoGiustificativo: form.riferimentoGiustificativo || undefined, + }; + try { + if (isEdit) { + await updateRimborso(Number(id), payload); + } else { + await createRimborso(payload); + } + navigate("/rimborsi"); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore nel salvataggio"); + } finally { + setLoading(false); + } + }; + + return ( +
+

{isEdit ? "Modifica richiesta" : "Nuova richiesta di rimborso"}

+
+ {error &&

{error}

} + + + +