implementato modulo HR

This commit is contained in:
2025-12-04 02:12:34 +01:00
parent ed2472febc
commit 500a3197e2
41 changed files with 12556 additions and 674 deletions

View File

@@ -20,3 +20,5 @@ File riassuntivo dello stato di sviluppo di Zentral.
- Implementazione modulo Gestione Eventi: strutturazione frontend, integrazione funzionalità e attivazione store.
- [Event Module Development](./devlog/event-module.md) - Implementazione modulo eventi
- [Menu Refactoring](./devlog/menu-refactoring.md) - Riorganizzazione menu e moduli (Dashboard, Clienti, Articoli, Risorse)
- [2025-12-03 Implementazione Modulo Personale](./devlog/2025-12-03_implementazione_modulo_personale.md) - **In Corso**
- Implementazione entità, API e Frontend per gestione Personale (Dipendenti, Contratti, Assenze, Pagamenti).

View File

@@ -0,0 +1,40 @@
# Implementazione Modulo Personale
## Obiettivo
Implementare il modulo "Personale" per la gestione delle risorse umane, come richiesto nelle specifiche di posizionamento di mercato.
## Funzionalità Richieste
- **Gestione Personale (Dipendenti)**: Anagrafica dipendenti.
- **Contratti**: Gestione dei contratti di lavoro (tipo, date, livello, retribuzione).
- **Assenze**: Tracciamento ferie, malattie, permessi.
- **Pagamenti**: Registro dei pagamenti stipendi.
- **Rimborsi**: Gestione note spese e rimborsi.
- **Analisi**: Dashboard statistiche (da implementare successivamente).
## Piano di Lavoro
### Backend (.NET)
1. [ ] Creare cartella `src/backend/Zentral.Domain/Entities/Personale`.
2. [ ] Definire le entità:
* `Dipendente`
* `Contratto`
* `Assenza`
* `Pagamento`
* `Rimborso`
3. [ ] Aggiornare `ZentralDbContext` aggiungendo i `DbSet`.
4. [ ] Creare la migrazione EF Core.
5. [ ] Creare i Controller API in `src/backend/Zentral.API/Controllers/Personale`.
### Frontend (React)
1. [ ] Strutturare `src/frontend/src/modules/personale`.
2. [ ] Implementare le pagine CRUD:
* `DipendentiPage`
* `ContrattiPage`
* `AssenzePage`
* `PagamentiPage` (include Rimborsi per ora o separato).
3. [ ] Configurare il routing del modulo.
4. [ ] Aggiungere il modulo alla configurazione `AppModule` (se non presente) e verificare l'attivazione.
### Integrazione
1. [ ] Verificare che il modulo appaia nel menu solo se attivo.
2. [ ] Testare il flusso completo (creazione dipendente -> contratto -> assenza).

View File

@@ -0,0 +1,94 @@
using Zentral.Domain.Entities.HR;
using Zentral.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Zentral.API.Modules.HR.Controllers;
[ApiController]
[Route("api/hr/[controller]")]
public class AssenzeController : ControllerBase
{
private readonly ZentralDbContext _context;
public AssenzeController(ZentralDbContext context)
{
_context = context;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<Assenza>>> GetAssenze([FromQuery] int? dipendenteId, [FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var query = _context.Assenze.Include(a => a.Dipendente).AsQueryable();
if (dipendenteId.HasValue)
query = query.Where(a => a.DipendenteId == dipendenteId.Value);
if (from.HasValue)
query = query.Where(a => a.DataInizio >= from.Value);
if (to.HasValue)
query = query.Where(a => a.DataFine <= to.Value);
return await query.OrderByDescending(a => a.DataInizio).ToListAsync();
}
[HttpGet("{id}")]
public async Task<ActionResult<Assenza>> GetAssenza(int id)
{
var assenza = await _context.Assenze
.Include(a => a.Dipendente)
.FirstOrDefaultAsync(a => a.Id == id);
if (assenza == null)
return NotFound();
return assenza;
}
[HttpPost]
public async Task<ActionResult<Assenza>> CreateAssenza(Assenza assenza)
{
assenza.CreatedAt = DateTime.UtcNow;
_context.Assenze.Add(assenza);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetAssenza), new { id = assenza.Id }, assenza);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateAssenza(int id, Assenza assenza)
{
if (id != assenza.Id)
return BadRequest();
assenza.UpdatedAt = DateTime.UtcNow;
_context.Entry(assenza).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!await _context.Assenze.AnyAsync(a => a.Id == id))
return NotFound();
throw;
}
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteAssenza(int id)
{
var assenza = await _context.Assenze.FindAsync(id);
if (assenza == null)
return NotFound();
_context.Assenze.Remove(assenza);
await _context.SaveChangesAsync();
return NoContent();
}
}

View File

@@ -0,0 +1,88 @@
using Zentral.Domain.Entities.HR;
using Zentral.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Zentral.API.Modules.HR.Controllers;
[ApiController]
[Route("api/hr/[controller]")]
public class ContrattiController : ControllerBase
{
private readonly ZentralDbContext _context;
public ContrattiController(ZentralDbContext context)
{
_context = context;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<Contratto>>> GetContratti([FromQuery] int? dipendenteId)
{
var query = _context.Contratti.Include(c => c.Dipendente).AsQueryable();
if (dipendenteId.HasValue)
query = query.Where(c => c.DipendenteId == dipendenteId.Value);
return await query.OrderByDescending(c => c.DataInizio).ToListAsync();
}
[HttpGet("{id}")]
public async Task<ActionResult<Contratto>> GetContratto(int id)
{
var contratto = await _context.Contratti
.Include(c => c.Dipendente)
.FirstOrDefaultAsync(c => c.Id == id);
if (contratto == null)
return NotFound();
return contratto;
}
[HttpPost]
public async Task<ActionResult<Contratto>> CreateContratto(Contratto contratto)
{
contratto.CreatedAt = DateTime.UtcNow;
_context.Contratti.Add(contratto);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetContratto), new { id = contratto.Id }, contratto);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateContratto(int id, Contratto contratto)
{
if (id != contratto.Id)
return BadRequest();
contratto.UpdatedAt = DateTime.UtcNow;
_context.Entry(contratto).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!await _context.Contratti.AnyAsync(c => c.Id == id))
return NotFound();
throw;
}
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteContratto(int id)
{
var contratto = await _context.Contratti.FindAsync(id);
if (contratto == null)
return NotFound();
_context.Contratti.Remove(contratto);
await _context.SaveChangesAsync();
return NoContent();
}
}

View File

@@ -0,0 +1,93 @@
using Zentral.Domain.Entities.HR;
using Zentral.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Zentral.API.Modules.HR.Controllers;
[ApiController]
[Route("api/hr/[controller]")]
public class DipendentiController : ControllerBase
{
private readonly ZentralDbContext _context;
public DipendentiController(ZentralDbContext context)
{
_context = context;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<Dipendente>>> GetDipendenti([FromQuery] string? search)
{
var query = _context.Dipendenti.AsQueryable();
if (!string.IsNullOrEmpty(search))
query = query.Where(d => d.Nome.Contains(search) ||
d.Cognome.Contains(search) ||
(d.CodiceFiscale != null && d.CodiceFiscale.Contains(search)));
return await query.OrderBy(d => d.Cognome).ThenBy(d => d.Nome).ToListAsync();
}
[HttpGet("{id}")]
public async Task<ActionResult<Dipendente>> GetDipendente(int id)
{
var dipendente = await _context.Dipendenti
.Include(d => d.Contratti)
.Include(d => d.Assenze)
.Include(d => d.Pagamenti)
.Include(d => d.Rimborsi)
.FirstOrDefaultAsync(d => d.Id == id);
if (dipendente == null)
return NotFound();
return dipendente;
}
[HttpPost]
public async Task<ActionResult<Dipendente>> CreateDipendente(Dipendente dipendente)
{
dipendente.CreatedAt = DateTime.UtcNow;
_context.Dipendenti.Add(dipendente);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetDipendente), new { id = dipendente.Id }, dipendente);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateDipendente(int id, Dipendente dipendente)
{
if (id != dipendente.Id)
return BadRequest();
dipendente.UpdatedAt = DateTime.UtcNow;
_context.Entry(dipendente).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!await _context.Dipendenti.AnyAsync(d => d.Id == id))
return NotFound();
throw;
}
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteDipendente(int id)
{
var dipendente = await _context.Dipendenti.FindAsync(id);
if (dipendente == null)
return NotFound();
_context.Dipendenti.Remove(dipendente);
await _context.SaveChangesAsync();
return NoContent();
}
}

View File

@@ -0,0 +1,94 @@
using Zentral.Domain.Entities.HR;
using Zentral.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Zentral.API.Modules.HR.Controllers;
[ApiController]
[Route("api/hr/[controller]")]
public class PagamentiController : ControllerBase
{
private readonly ZentralDbContext _context;
public PagamentiController(ZentralDbContext context)
{
_context = context;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<Pagamento>>> GetPagamenti([FromQuery] int? dipendenteId, [FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var query = _context.Pagamenti.Include(p => p.Dipendente).AsQueryable();
if (dipendenteId.HasValue)
query = query.Where(p => p.DipendenteId == dipendenteId.Value);
if (from.HasValue)
query = query.Where(p => p.DataPagamento >= from.Value);
if (to.HasValue)
query = query.Where(p => p.DataPagamento <= to.Value);
return await query.OrderByDescending(p => p.DataPagamento).ToListAsync();
}
[HttpGet("{id}")]
public async Task<ActionResult<Pagamento>> GetPagamento(int id)
{
var pagamento = await _context.Pagamenti
.Include(p => p.Dipendente)
.FirstOrDefaultAsync(p => p.Id == id);
if (pagamento == null)
return NotFound();
return pagamento;
}
[HttpPost]
public async Task<ActionResult<Pagamento>> CreatePagamento(Pagamento pagamento)
{
pagamento.CreatedAt = DateTime.UtcNow;
_context.Pagamenti.Add(pagamento);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetPagamento), new { id = pagamento.Id }, pagamento);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdatePagamento(int id, Pagamento pagamento)
{
if (id != pagamento.Id)
return BadRequest();
pagamento.UpdatedAt = DateTime.UtcNow;
_context.Entry(pagamento).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!await _context.Pagamenti.AnyAsync(p => p.Id == id))
return NotFound();
throw;
}
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeletePagamento(int id)
{
var pagamento = await _context.Pagamenti.FindAsync(id);
if (pagamento == null)
return NotFound();
_context.Pagamenti.Remove(pagamento);
await _context.SaveChangesAsync();
return NoContent();
}
}

View File

@@ -0,0 +1,91 @@
using Zentral.Domain.Entities.HR;
using Zentral.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Zentral.API.Modules.HR.Controllers;
[ApiController]
[Route("api/hr/[controller]")]
public class RimborsiController : ControllerBase
{
private readonly ZentralDbContext _context;
public RimborsiController(ZentralDbContext context)
{
_context = context;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<Rimborso>>> GetRimborsi([FromQuery] int? dipendenteId, [FromQuery] string? stato)
{
var query = _context.Rimborsi.Include(r => r.Dipendente).AsQueryable();
if (dipendenteId.HasValue)
query = query.Where(r => r.DipendenteId == dipendenteId.Value);
if (!string.IsNullOrEmpty(stato))
query = query.Where(r => r.Stato == stato);
return await query.OrderByDescending(r => r.DataSpesa).ToListAsync();
}
[HttpGet("{id}")]
public async Task<ActionResult<Rimborso>> GetRimborso(int id)
{
var rimborso = await _context.Rimborsi
.Include(r => r.Dipendente)
.FirstOrDefaultAsync(r => r.Id == id);
if (rimborso == null)
return NotFound();
return rimborso;
}
[HttpPost]
public async Task<ActionResult<Rimborso>> CreateRimborso(Rimborso rimborso)
{
rimborso.CreatedAt = DateTime.UtcNow;
_context.Rimborsi.Add(rimborso);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetRimborso), new { id = rimborso.Id }, rimborso);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateRimborso(int id, Rimborso rimborso)
{
if (id != rimborso.Id)
return BadRequest();
rimborso.UpdatedAt = DateTime.UtcNow;
_context.Entry(rimborso).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!await _context.Rimborsi.AnyAsync(r => r.Id == id))
return NotFound();
throw;
}
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteRimborso(int id)
{
var rimborso = await _context.Rimborsi.FindAsync(id);
if (rimborso == null)
return NotFound();
_context.Rimborsi.Remove(rimborso);
await _context.SaveChangesAsync();
return NoContent();
}
}

View File

@@ -405,9 +405,6 @@ public class ModuleService
/// </summary>
public async Task SeedDefaultModulesAsync()
{
if (await _context.AppModules.AnyAsync())
return;
var defaultModules = new List<AppModule>
{
new AppModule
@@ -482,12 +479,46 @@ public class ModuleService
RoutePath = "/quality",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
},
new AppModule
{
Code = "events",
Name = "Gestione Eventi",
Description = "Gestione eventi, pianificazione e controllo avanzamento",
Icon = "Event",
BasePrice = 2000m,
MonthlyMultiplier = 1.2m,
SortOrder = 60,
IsCore = false,
RoutePath = "/events",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
},
new AppModule
{
Code = "hr",
Name = "Gestione Personale",
Description = "Gestione personale, contratti, pagamenti, assenze, rimborsi e analisi personale",
Icon = "People",
BasePrice = 1600m,
MonthlyMultiplier = 1.2m,
SortOrder = 70,
IsCore = false,
RoutePath = "/hr",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
}
};
_context.AppModules.AddRange(defaultModules);
await _context.SaveChangesAsync();
var existingCodes = await _context.AppModules.Select(m => m.Code).ToListAsync();
var newModules = defaultModules.Where(m => !existingCodes.Contains(m.Code)).ToList();
_logger.LogInformation("Seed {Count} moduli di default completato", defaultModules.Count);
if (newModules.Any())
{
_context.AppModules.AddRange(newModules);
await _context.SaveChangesAsync();
_logger.LogInformation("Added {Count} new default modules: {Modules}",
newModules.Count, string.Join(", ", newModules.Select(m => m.Code)));
}
}
}

View File

@@ -0,0 +1,15 @@
using System;
namespace Zentral.Domain.Entities.HR;
public class Assenza : BaseEntity
{
public int DipendenteId { get; set; }
public Dipendente Dipendente { get; set; } = null!;
public DateTime DataInizio { get; set; }
public DateTime DataFine { get; set; }
public string TipoAssenza { get; set; } = string.Empty;
public string Stato { get; set; } = "Richiesta";
public string? Note { get; set; }
}

View File

@@ -0,0 +1,17 @@
using System;
namespace Zentral.Domain.Entities.HR;
public class Contratto : BaseEntity
{
public int DipendenteId { get; set; }
public Dipendente Dipendente { get; set; } = null!;
public DateTime DataInizio { get; set; }
public DateTime? DataFine { get; set; }
public string TipoContratto { get; set; } = string.Empty;
public string? Livello { get; set; }
public decimal RetribuzioneLorda { get; set; }
public string? Note { get; set; }
public bool Attivo { get; set; } = true;
}

View File

@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
namespace Zentral.Domain.Entities.HR;
public class Dipendente : BaseEntity
{
public string Nome { get; set; } = string.Empty;
public string Cognome { get; set; } = string.Empty;
public string? CodiceFiscale { get; set; }
public string? Email { get; set; }
public string? Telefono { get; set; }
public string? Indirizzo { get; set; }
public DateTime? DataNascita { get; set; }
public string? Ruolo { get; set; }
public List<Contratto> Contratti { get; set; } = new();
public List<Assenza> Assenze { get; set; } = new();
public List<Pagamento> Pagamenti { get; set; } = new();
public List<Rimborso> Rimborsi { get; set; } = new();
}

View File

@@ -0,0 +1,14 @@
using System;
namespace Zentral.Domain.Entities.HR;
public class Pagamento : BaseEntity
{
public int DipendenteId { get; set; }
public Dipendente Dipendente { get; set; } = null!;
public DateTime DataPagamento { get; set; }
public decimal ImportoNetto { get; set; }
public string Descrizione { get; set; } = string.Empty;
public bool Pagato { get; set; } = false;
}

View File

@@ -0,0 +1,14 @@
using System;
namespace Zentral.Domain.Entities.HR;
public class Rimborso : BaseEntity
{
public int DipendenteId { get; set; }
public Dipendente Dipendente { get; set; } = null!;
public DateTime DataSpesa { get; set; }
public decimal Importo { get; set; }
public string Descrizione { get; set; } = string.Empty;
public string Stato { get; set; } = "Richiesto";
}

View File

@@ -3,6 +3,7 @@ using Zentral.Domain.Entities.Warehouse;
using Zentral.Domain.Entities.Purchases;
using Zentral.Domain.Entities.Sales;
using Zentral.Domain.Entities.Production;
using Zentral.Domain.Entities.HR;
using Microsoft.EntityFrameworkCore;
namespace Zentral.Infrastructure.Data;
@@ -86,10 +87,70 @@ public class ZentralDbContext : DbContext
public DbSet<ProductionOrderPhase> ProductionOrderPhases => Set<ProductionOrderPhase>();
public DbSet<MrpSuggestion> MrpSuggestions => Set<MrpSuggestion>();
// Personale module entities
public DbSet<Dipendente> Dipendenti => Set<Dipendente>();
public DbSet<Contratto> Contratti => Set<Contratto>();
public DbSet<Assenza> Assenze => Set<Assenza>();
public DbSet<Pagamento> Pagamenti => Set<Pagamento>();
public DbSet<Rimborso> Rimborsi => Set<Rimborso>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// ===============================================
// PERSONALE MODULE ENTITIES
// ===============================================
modelBuilder.Entity<Dipendente>(entity =>
{
entity.ToTable("Dipendenti");
entity.HasIndex(e => e.CodiceFiscale).IsUnique();
});
modelBuilder.Entity<Contratto>(entity =>
{
entity.ToTable("Contratti");
entity.Property(e => e.RetribuzioneLorda).HasPrecision(18, 2);
entity.HasOne(e => e.Dipendente)
.WithMany(d => d.Contratti)
.HasForeignKey(e => e.DipendenteId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Assenza>(entity =>
{
entity.ToTable("Assenze");
entity.HasOne(e => e.Dipendente)
.WithMany(d => d.Assenze)
.HasForeignKey(e => e.DipendenteId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Pagamento>(entity =>
{
entity.ToTable("Pagamenti");
entity.Property(e => e.ImportoNetto).HasPrecision(18, 2);
entity.HasOne(e => e.Dipendente)
.WithMany(d => d.Pagamenti)
.HasForeignKey(e => e.DipendenteId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Rimborso>(entity =>
{
entity.ToTable("Rimborsi");
entity.Property(e => e.Importo).HasPrecision(18, 2);
entity.HasOne(e => e.Dipendente)
.WithMany(d => d.Rimborsi)
.HasForeignKey(e => e.DipendenteId)
.OnDelete(DeleteBehavior.Cascade);
});
// Cliente
modelBuilder.Entity<Cliente>(entity =>
{

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Zentral.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPersonaleModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Dipendenti",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Nome = table.Column<string>(type: "TEXT", nullable: false),
Cognome = table.Column<string>(type: "TEXT", nullable: false),
CodiceFiscale = table.Column<string>(type: "TEXT", nullable: true),
Email = table.Column<string>(type: "TEXT", nullable: true),
Telefono = table.Column<string>(type: "TEXT", nullable: true),
Indirizzo = table.Column<string>(type: "TEXT", nullable: true),
DataNascita = table.Column<DateTime>(type: "TEXT", nullable: true),
Ruolo = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Dipendenti", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Assenze",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
DipendenteId = table.Column<int>(type: "INTEGER", nullable: false),
DataInizio = table.Column<DateTime>(type: "TEXT", nullable: false),
DataFine = table.Column<DateTime>(type: "TEXT", nullable: false),
TipoAssenza = table.Column<string>(type: "TEXT", nullable: false),
Stato = table.Column<string>(type: "TEXT", nullable: false),
Note = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Assenze", x => x.Id);
table.ForeignKey(
name: "FK_Assenze_Dipendenti_DipendenteId",
column: x => x.DipendenteId,
principalTable: "Dipendenti",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Contratti",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
DipendenteId = table.Column<int>(type: "INTEGER", nullable: false),
DataInizio = table.Column<DateTime>(type: "TEXT", nullable: false),
DataFine = table.Column<DateTime>(type: "TEXT", nullable: true),
TipoContratto = table.Column<string>(type: "TEXT", nullable: false),
Livello = table.Column<string>(type: "TEXT", nullable: true),
RetribuzioneLorda = table.Column<decimal>(type: "TEXT", precision: 18, scale: 2, nullable: false),
Note = table.Column<string>(type: "TEXT", nullable: true),
Attivo = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Contratti", x => x.Id);
table.ForeignKey(
name: "FK_Contratti_Dipendenti_DipendenteId",
column: x => x.DipendenteId,
principalTable: "Dipendenti",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Pagamenti",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
DipendenteId = table.Column<int>(type: "INTEGER", nullable: false),
DataPagamento = table.Column<DateTime>(type: "TEXT", nullable: false),
ImportoNetto = table.Column<decimal>(type: "TEXT", precision: 18, scale: 2, nullable: false),
Descrizione = table.Column<string>(type: "TEXT", nullable: false),
Pagato = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Pagamenti", x => x.Id);
table.ForeignKey(
name: "FK_Pagamenti_Dipendenti_DipendenteId",
column: x => x.DipendenteId,
principalTable: "Dipendenti",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Rimborsi",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
DipendenteId = table.Column<int>(type: "INTEGER", nullable: false),
DataSpesa = table.Column<DateTime>(type: "TEXT", nullable: false),
Importo = table.Column<decimal>(type: "TEXT", precision: 18, scale: 2, nullable: false),
Descrizione = table.Column<string>(type: "TEXT", nullable: false),
Stato = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Rimborsi", x => x.Id);
table.ForeignKey(
name: "FK_Rimborsi_Dipendenti_DipendenteId",
column: x => x.DipendenteId,
principalTable: "Dipendenti",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Assenze_DipendenteId",
table: "Assenze",
column: "DipendenteId");
migrationBuilder.CreateIndex(
name: "IX_Contratti_DipendenteId",
table: "Contratti",
column: "DipendenteId");
migrationBuilder.CreateIndex(
name: "IX_Dipendenti_CodiceFiscale",
table: "Dipendenti",
column: "CodiceFiscale",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Pagamenti_DipendenteId",
table: "Pagamenti",
column: "DipendenteId");
migrationBuilder.CreateIndex(
name: "IX_Rimborsi_DipendenteId",
table: "Rimborsi",
column: "DipendenteId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Assenze");
migrationBuilder.DropTable(
name: "Contratti");
migrationBuilder.DropTable(
name: "Pagamenti");
migrationBuilder.DropTable(
name: "Rimborsi");
migrationBuilder.DropTable(
name: "Dipendenti");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Zentral.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class RenamePersonaleToHR : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -940,6 +940,254 @@ namespace Zentral.Infrastructure.Migrations
b.ToTable("EventiDettaglioRisorse");
});
modelBuilder.Entity("Zentral.Domain.Entities.HR.Assenza", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT");
b.Property<string>("CustomFieldsJson")
.HasColumnType("TEXT");
b.Property<DateTime>("DataFine")
.HasColumnType("TEXT");
b.Property<DateTime>("DataInizio")
.HasColumnType("TEXT");
b.Property<int>("DipendenteId")
.HasColumnType("INTEGER");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Stato")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TipoAssenza")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UpdatedBy")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("DipendenteId");
b.ToTable("Assenze", (string)null);
});
modelBuilder.Entity("Zentral.Domain.Entities.HR.Contratto", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Attivo")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT");
b.Property<string>("CustomFieldsJson")
.HasColumnType("TEXT");
b.Property<DateTime?>("DataFine")
.HasColumnType("TEXT");
b.Property<DateTime>("DataInizio")
.HasColumnType("TEXT");
b.Property<int>("DipendenteId")
.HasColumnType("INTEGER");
b.Property<string>("Livello")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<decimal>("RetribuzioneLorda")
.HasPrecision(18, 2)
.HasColumnType("TEXT");
b.Property<string>("TipoContratto")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UpdatedBy")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("DipendenteId");
b.ToTable("Contratti", (string)null);
});
modelBuilder.Entity("Zentral.Domain.Entities.HR.Dipendente", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CodiceFiscale")
.HasColumnType("TEXT");
b.Property<string>("Cognome")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT");
b.Property<string>("CustomFieldsJson")
.HasColumnType("TEXT");
b.Property<DateTime?>("DataNascita")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasColumnType("TEXT");
b.Property<string>("Indirizzo")
.HasColumnType("TEXT");
b.Property<string>("Nome")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Ruolo")
.HasColumnType("TEXT");
b.Property<string>("Telefono")
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UpdatedBy")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CodiceFiscale")
.IsUnique();
b.ToTable("Dipendenti", (string)null);
});
modelBuilder.Entity("Zentral.Domain.Entities.HR.Pagamento", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT");
b.Property<string>("CustomFieldsJson")
.HasColumnType("TEXT");
b.Property<DateTime>("DataPagamento")
.HasColumnType("TEXT");
b.Property<string>("Descrizione")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("DipendenteId")
.HasColumnType("INTEGER");
b.Property<decimal>("ImportoNetto")
.HasPrecision(18, 2)
.HasColumnType("TEXT");
b.Property<bool>("Pagato")
.HasColumnType("INTEGER");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UpdatedBy")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("DipendenteId");
b.ToTable("Pagamenti", (string)null);
});
modelBuilder.Entity("Zentral.Domain.Entities.HR.Rimborso", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT");
b.Property<string>("CustomFieldsJson")
.HasColumnType("TEXT");
b.Property<DateTime>("DataSpesa")
.HasColumnType("TEXT");
b.Property<string>("Descrizione")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("DipendenteId")
.HasColumnType("INTEGER");
b.Property<decimal>("Importo")
.HasPrecision(18, 2)
.HasColumnType("TEXT");
b.Property<string>("Stato")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UpdatedBy")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("DipendenteId");
b.ToTable("Rimborsi", (string)null);
});
modelBuilder.Entity("Zentral.Domain.Entities.Location", b =>
{
b.Property<int>("Id")
@@ -3697,6 +3945,50 @@ namespace Zentral.Infrastructure.Migrations
b.Navigation("Risorsa");
});
modelBuilder.Entity("Zentral.Domain.Entities.HR.Assenza", b =>
{
b.HasOne("Zentral.Domain.Entities.HR.Dipendente", "Dipendente")
.WithMany("Assenze")
.HasForeignKey("DipendenteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Dipendente");
});
modelBuilder.Entity("Zentral.Domain.Entities.HR.Contratto", b =>
{
b.HasOne("Zentral.Domain.Entities.HR.Dipendente", "Dipendente")
.WithMany("Contratti")
.HasForeignKey("DipendenteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Dipendente");
});
modelBuilder.Entity("Zentral.Domain.Entities.HR.Pagamento", b =>
{
b.HasOne("Zentral.Domain.Entities.HR.Dipendente", "Dipendente")
.WithMany("Pagamenti")
.HasForeignKey("DipendenteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Dipendente");
});
modelBuilder.Entity("Zentral.Domain.Entities.HR.Rimborso", b =>
{
b.HasOne("Zentral.Domain.Entities.HR.Dipendente", "Dipendente")
.WithMany("Rimborsi")
.HasForeignKey("DipendenteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Dipendente");
});
modelBuilder.Entity("Zentral.Domain.Entities.ModuleSubscription", b =>
{
b.HasOne("Zentral.Domain.Entities.AppModule", "Module")
@@ -4219,6 +4511,17 @@ namespace Zentral.Infrastructure.Migrations
b.Navigation("DettagliRisorse");
});
modelBuilder.Entity("Zentral.Domain.Entities.HR.Dipendente", b =>
{
b.Navigation("Assenze");
b.Navigation("Contratti");
b.Navigation("Pagamenti");
b.Navigation("Rimborsi");
});
modelBuilder.Entity("Zentral.Domain.Entities.Location", b =>
{
b.Navigation("Eventi");

View File

@@ -47,6 +47,7 @@
"purchases": "Purchases",
"sales": "Sales",
"production": "Production",
"hr": "Human Resources",
"reports": "Reports",
"modules": "Modules",
"autoCodes": "Auto Codes",
@@ -264,6 +265,14 @@
"stock": "Stock",
"categories": "Categories"
},
"hr": {
"title": "Human Resources",
"dipendenti": "Employees",
"contratti": "Contracts",
"assenze": "Absences",
"pagamenti": "Payments",
"rimborsi": "Reimbursements"
},
"admin": {
"title": "Module Management",
"subtitle": "Configure active modules and manage subscriptions",
@@ -356,6 +365,14 @@
"4": "Certifications and audits",
"5": "Quality statistics"
},
"hr": {
"0": "Employee management",
"1": "Contract management",
"2": "Absence and leave register",
"3": "Payroll and salary management",
"4": "Expense reports and reimbursements",
"5": "Personnel cost analysis"
},
"default": "Complete module features"
}
},
@@ -1337,5 +1354,45 @@
"process": "Process / Create Order"
}
}
},
"personale": {
"dipendentiTitle": "Employee Management",
"newDipendente": "New Employee",
"editDipendente": "Edit Employee",
"nome": "First Name",
"cognome": "Last Name",
"codiceFiscale": "Tax Code",
"email": "Email",
"telefono": "Phone",
"ruolo": "Role",
"indirizzo": "Address",
"citta": "City",
"cap": "ZIP Code",
"dataAssunzione": "Hiring Date",
"dipartimento": "Department",
"contrattiTitle": "Contract Management",
"newContratto": "New Contract",
"editContratto": "Edit Contract",
"dipendente": "Employee",
"tipoContratto": "Contract Type",
"dataInizio": "Start Date",
"dataFine": "End Date",
"retribuzione": "Salary",
"attivo": "Active",
"assenzeTitle": "Absence Management",
"newAssenza": "New Absence",
"editAssenza": "Edit Absence",
"tipoAssenza": "Absence Type",
"stato": "Status",
"pagamentiTitle": "Payment Management",
"newPagamento": "New Payment",
"editPagamento": "Edit Payment",
"data": "Date",
"importo": "Amount",
"causale": "Reason",
"rimborsiTitle": "Reimbursement Management",
"newRimborso": "New Reimbursement",
"editRimborso": "Edit Reimbursement",
"descrizione": "Description"
}
}

View File

@@ -43,6 +43,7 @@
"purchases": "Acquisti",
"sales": "Vendite",
"production": "Produzione",
"hr": "Gestione Personale",
"reports": "Report",
"modules": "Moduli",
"autoCodes": "Codici Auto",
@@ -260,6 +261,14 @@
"stock": "Giacenze",
"categories": "Categorie"
},
"hr": {
"title": "Gestione Personale",
"dipendenti": "Dipendenti",
"contratti": "Contratti",
"assenze": "Assenze",
"pagamenti": "Pagamenti",
"rimborsi": "Rimborsi"
},
"admin": {
"title": "Gestione Moduli",
"subtitle": "Configura i moduli attivi e gestisci le subscription",
@@ -353,6 +362,14 @@
"4": "Certificazioni e audit",
"5": "Statistiche qualità"
},
"hr": {
"0": "Gestione anagrafica dipendenti",
"1": "Gestione contratti",
"2": "Registro assenze e ferie",
"3": "Gestione pagamenti e stipendi",
"4": "Note spese e rimborsi",
"5": "Analisi costi personale"
},
"default": "Funzionalità complete del modulo"
}
},
@@ -1390,5 +1407,45 @@
}
}
}
},
"personale": {
"dipendentiTitle": "Gestione Dipendenti",
"newDipendente": "Nuovo Dipendente",
"editDipendente": "Modifica Dipendente",
"nome": "Nome",
"cognome": "Cognome",
"codiceFiscale": "Codice Fiscale",
"email": "Email",
"telefono": "Telefono",
"ruolo": "Ruolo",
"indirizzo": "Indirizzo",
"citta": "Città",
"cap": "CAP",
"dataAssunzione": "Data Assunzione",
"dipartimento": "Dipartimento",
"contrattiTitle": "Gestione Contratti",
"newContratto": "Nuovo Contratto",
"editContratto": "Modifica Contratto",
"dipendente": "Dipendente",
"tipoContratto": "Tipo Contratto",
"dataInizio": "Data Inizio",
"dataFine": "Data Fine",
"retribuzione": "Retribuzione",
"attivo": "Attivo",
"assenzeTitle": "Gestione Assenze",
"newAssenza": "Nuova Assenza",
"editAssenza": "Modifica Assenza",
"tipoAssenza": "Tipo Assenza",
"stato": "Stato",
"pagamentiTitle": "Gestione Pagamenti",
"newPagamento": "Nuovo Pagamento",
"editPagamento": "Modifica Pagamento",
"data": "Data",
"importo": "Importo",
"causale": "Causale",
"rimborsiTitle": "Gestione Rimborsi",
"newRimborso": "Nuovo Rimborso",
"editRimborso": "Modifica Rimborso",
"descrizione": "Descrizione"
}
}

View File

@@ -7,13 +7,7 @@ import { AppLanguageProvider } from "./contexts/LanguageContext";
import Layout from "./components/Layout";
import Dashboard from "./pages/Dashboard";
import EventiPage from "./pages/EventiPage";
import EventoDetailPage from "./pages/EventoDetailPage";
import ClientiPage from "./pages/ClientiPage";
import LocationPage from "./pages/LocationPage";
import ArticoliPage from "./pages/ArticoliPage";
import RisorsePage from "./pages/RisorsePage";
import CalendarioPage from "./pages/CalendarioPage";
import ReportTemplatesPage from "./pages/ReportTemplatesPage";
import ReportEditorPage from "./pages/ReportEditorPage";
import ModulesAdminPage from "./pages/ModulesAdminPage";
@@ -24,6 +18,8 @@ import WarehouseRoutes from "./modules/warehouse/routes";
import PurchasesRoutes from "./modules/purchases/routes";
import SalesRoutes from "./modules/sales/routes";
import ProductionRoutes from "./modules/production/routes";
import EventsRoutes from "./modules/events/routes";
import HRRoutes from "./modules/hr/routes";
import { ModuleGuard } from "./components/ModuleGuard";
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
import { CollaborationProvider } from "./contexts/CollaborationContext";
@@ -62,13 +58,7 @@ function App() {
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="calendario" element={<CalendarioPage />} />
<Route path="eventi" element={<EventiPage />} />
<Route path="eventi/:id" element={<EventoDetailPage />} />
<Route path="clienti" element={<ClientiPage />} />
<Route path="location" element={<LocationPage />} />
<Route path="articoli" element={<ArticoliPage />} />
<Route path="risorse" element={<RisorsePage />} />
<Route
path="report-templates"
element={<ReportTemplatesPage />}
@@ -131,6 +121,24 @@ function App() {
</ModuleGuard>
}
/>
{/* Events Module */}
<Route
path="events/*"
element={
<ModuleGuard moduleCode="events">
<EventsRoutes />
</ModuleGuard>
}
/>
{/* HR Module */}
<Route
path="hr/*"
element={
<ModuleGuard moduleCode="hr">
<HRRoutes />
</ModuleGuard>
}
/>
</Route>
</Routes>
</TabProvider>

View File

@@ -36,6 +36,8 @@ import {
Timeline as TimelineIcon,
PrecisionManufacturing as ManufacturingIcon,
Category as CategoryIcon,
AttachMoney as AttachMoneyIcon,
Receipt as ReceiptIcon,
} from '@mui/icons-material';
import { useLocation } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
@@ -62,6 +64,8 @@ export default function Sidebar({ onClose }: { onClose?: () => void }) {
purchases: false,
sales: false,
production: false,
events: false,
hr: false,
admin: false,
});
@@ -85,10 +89,7 @@ export default function Sidebar({ onClose }: { onClose?: () => void }) {
icon: <DashboardIcon />,
children: [
{ id: 'dashboard', label: t('menu.dashboard'), icon: <DashboardIcon />, path: '/' },
{ id: 'calendar', label: t('menu.calendar'), icon: <CalendarIcon />, path: '/calendario' },
{ id: 'events', label: t('menu.events'), icon: <EventIcon />, path: '/eventi' },
{ id: 'clients', label: t('menu.clients'), icon: <PeopleIcon />, path: '/clienti' },
{ id: 'location', label: t('menu.location'), icon: <PlaceIcon />, path: '/location' },
{ id: 'articles', label: t('menu.articles'), icon: <InventoryIcon />, path: '/articoli' },
{ id: 'resources', label: t('menu.resources'), icon: <PersonIcon />, path: '/risorse' },
],
@@ -140,6 +141,30 @@ export default function Sidebar({ onClose }: { onClose?: () => void }) {
{ id: 'prod-mrp', label: 'MRP', icon: <ManufacturingIcon />, path: '/production/mrp' },
],
},
{
id: 'events',
label: t('menu.events'),
icon: <EventIcon />,
moduleCode: 'events',
children: [
{ id: 'ev-list', label: t('menu.events'), icon: <EventIcon />, path: '/events/list' },
{ id: 'ev-calendar', label: t('menu.calendar'), icon: <CalendarIcon />, path: '/events/calendar' },
{ id: 'ev-locations', label: t('menu.location'), icon: <PlaceIcon />, path: '/events/locations' },
],
},
{
id: 'hr',
label: t('modules.hr.title'),
icon: <PeopleIcon />,
moduleCode: 'hr',
children: [
{ id: 'hr-dipendenti', label: t('modules.hr.dipendenti'), icon: <PeopleIcon />, path: '/hr/dipendenti' },
{ id: 'hr-contratti', label: t('modules.hr.contratti'), icon: <AssignmentIcon />, path: '/hr/contratti' },
{ id: 'hr-assenze', label: t('modules.hr.assenze'), icon: <EventIcon />, path: '/hr/assenze' },
{ id: 'hr-pagamenti', label: t('modules.hr.pagamenti'), icon: <AttachMoneyIcon />, path: '/hr/pagamenti' },
{ id: 'hr-rimborsi', label: t('modules.hr.rimborsi'), icon: <ReceiptIcon />, path: '/hr/rimborsi' },
],
},
{
id: 'admin',
label: 'Amministrazione',

View File

@@ -18,7 +18,7 @@ import interactionPlugin from "@fullcalendar/interaction";
import { useNavigate } from "react-router-dom";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
import { eventiService } from "../services/eventiService";
import { eventiService } from "../../../services/eventiService";
export default function CalendarioPage() {
const navigate = useNavigate();
@@ -53,7 +53,7 @@ export default function CalendarioPage() {
const handleCreateEvent = () => {
// Naviga alla pagina nuovo evento con la data preselezionata
navigate("/eventi/0", { state: { dataEvento: newEventDialog.date } });
navigate("/events/list/0", { state: { dataEvento: newEventDialog.date } });
setNewEventDialog({ open: false, date: "", formattedDate: "" });
};
@@ -62,7 +62,7 @@ export default function CalendarioPage() {
};
const handleEventClick = (info: any) => {
navigate(`/eventi/${info.event.id}`);
navigate(`/events/list/${info.event.id}`);
};
const handleDatesSet = (info: any) => {

View File

@@ -0,0 +1,430 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Grid,
Paper,
Typography,
Box,
Card,
CardContent,
List,
ListItem,
ListItemText,
Chip,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Alert,
CircularProgress,
} from "@mui/material";
import {
Event as EventIcon,
People as PeopleIcon,
CheckCircle as ConfirmedIcon,
PendingActions as PendingIcon,
PlayArrow as GenerateIcon,
DeleteSweep as ClearIcon,
} from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import dayjs from "dayjs";
import { useTranslation, Trans } from "react-i18next";
import { eventiService } from "../services/eventiService";
import { demoService, DemoDataResult } from "../services/demoService";
import { StatoEvento } from "../types";
const StatCard = ({
title,
value,
icon,
color,
}: {
title: string;
value: number;
icon: React.ReactNode;
color: string;
}) => (
<Card sx={{ height: "100%" }}>
<CardContent>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Box>
<Typography color="textSecondary" gutterBottom variant="body2">
{title}
</Typography>
<Typography variant="h4">{value}</Typography>
</Box>
<Box sx={{ color, opacity: 0.7 }}>{icon}</Box>
</Box>
</CardContent>
</Card>
);
const getStatoLabel = (stato: StatoEvento, t: any) => {
switch (stato) {
case StatoEvento.Scheda:
return t("status.scheda");
case StatoEvento.Preventivo:
return t("status.preventivo");
case StatoEvento.Confermato:
return t("status.confermato");
default:
return t("common.unknown");
}
};
const getStatoColor = (stato: StatoEvento) => {
switch (stato) {
case StatoEvento.Scheda:
return "default";
case StatoEvento.Preventivo:
return "warning";
case StatoEvento.Confermato:
return "success";
default:
return "default";
}
};
export default function Dashboard() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t } = useTranslation();
const [demoDialog, setDemoDialog] = useState<"generate" | "clear" | null>(
null,
);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<DemoDataResult | null>(null);
const [error, setError] = useState<string | null>(null);
const { data: eventi = [] } = useQuery({
queryKey: ["eventi"],
queryFn: () => eventiService.getAll(),
});
const handleGenerateDemo = async () => {
setLoading(true);
setError(null);
try {
const res = await demoService.generateDemoData();
setResult(res);
queryClient.invalidateQueries();
} catch (err: any) {
setError(
err.response?.data?.error || t("dashboard.generateError"),
);
} finally {
setLoading(false);
}
};
const handleClearDemo = async () => {
setLoading(true);
setError(null);
try {
const res = await demoService.clearDemoData();
setResult(res);
queryClient.invalidateQueries();
} catch (err: any) {
setError(
err.response?.data?.error || t("dashboard.clearError"),
);
} finally {
setLoading(false);
}
};
const handleCloseDialog = () => {
setDemoDialog(null);
setResult(null);
setError(null);
};
const oggi = dayjs().startOf("day");
const prossimi30Giorni = oggi.add(30, "day");
const eventiProssimi = eventi
.filter(
(e) =>
dayjs(e.dataEvento).isAfter(oggi) &&
dayjs(e.dataEvento).isBefore(prossimi30Giorni),
)
.sort((a, b) => dayjs(a.dataEvento).diff(dayjs(b.dataEvento)));
const eventiConfermati = eventi.filter(
(e) => e.stato === StatoEvento.Confermato,
).length;
const eventiPreventivo = eventi.filter(
(e) => e.stato === StatoEvento.Preventivo,
).length;
const eventiOggi = eventi.filter((e) =>
dayjs(e.dataEvento).isSame(oggi, "day"),
).length;
return (
<Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 3,
}}
>
<Typography variant="h4">{t("dashboard.title")}</Typography>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
color="primary"
startIcon={<GenerateIcon />}
onClick={() => setDemoDialog("generate")}
>
{t("dashboard.generateDemoData")}
</Button>
<Button
variant="outlined"
color="error"
startIcon={<ClearIcon />}
onClick={() => setDemoDialog("clear")}
>
{t("dashboard.clearDatabase")}
</Button>
</Box>
</Box>
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title={t("dashboard.totalEvents")}
value={eventi.length}
icon={<EventIcon sx={{ fontSize: 48 }} />}
color="#1976d2"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title={t("dashboard.confirmed")}
value={eventiConfermati}
icon={<ConfirmedIcon sx={{ fontSize: 48 }} />}
color="#4caf50"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title={t("dashboard.inQuote")}
value={eventiPreventivo}
icon={<PendingIcon sx={{ fontSize: 48 }} />}
color="#ff9800"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title={t("dashboard.eventsToday")}
value={eventiOggi}
icon={<PeopleIcon sx={{ fontSize: 48 }} />}
color="#9c27b0"
/>
</Grid>
</Grid>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
{t("dashboard.upcomingEvents")}
</Typography>
<List>
{eventiProssimi.slice(0, 10).map((evento) => (
<ListItem
key={evento.id}
component="div"
onClick={() => navigate(`/eventi/${evento.id}`)}
sx={{
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
>
<ListItemText
primary={evento.descrizione || evento.codice}
secondary={
<Box
component="span"
sx={{ display: "flex", gap: 1, alignItems: "center" }}
>
<span>
{dayjs(evento.dataEvento).format("DD/MM/YYYY")}
</span>
<span>-</span>
<span>{evento.cliente?.ragioneSociale}</span>
</Box>
}
/>
<Chip
label={getStatoLabel(evento.stato, t)}
color={getStatoColor(evento.stato) as any}
size="small"
/>
</ListItem>
))}
{eventiProssimi.length === 0 && (
<ListItem>
<ListItemText primary={t("dashboard.noEvents")} />
</ListItem>
)}
</List>
</Paper>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
{t("dashboard.expiringQuotes")}
</Typography>
<List>
{eventi
.filter(
(e) =>
e.stato === StatoEvento.Preventivo &&
e.dataScadenzaPreventivo,
)
.sort((a, b) =>
dayjs(a.dataScadenzaPreventivo).diff(
dayjs(b.dataScadenzaPreventivo),
),
)
.slice(0, 5)
.map((evento) => (
<ListItem
key={evento.id}
component="div"
onClick={() => navigate(`/eventi/${evento.id}`)}
sx={{
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
>
<ListItemText
primary={evento.descrizione || evento.codice}
secondary={t("dashboard.expires", { date: dayjs(evento.dataScadenzaPreventivo).format("DD/MM/YYYY") })}
/>
<Chip
label={t("dashboard.guests", { count: evento.numeroOspiti || 0 })}
size="small"
variant="outlined"
/>
</ListItem>
))}
{eventi.filter((e) => e.stato === StatoEvento.Preventivo)
.length === 0 && (
<ListItem>
<ListItemText primary={t("dashboard.noQuotes")} />
</ListItem>
)}
</List>
</Paper>
</Grid>
</Grid>
{/* Dialog Genera Dati Demo */}
<Dialog open={demoDialog === "generate"} onClose={handleCloseDialog}>
<DialogTitle>{t("dashboard.generateDialogTitle")}</DialogTitle>
<DialogContent>
{!result && !error && (
<DialogContentText>
<Trans i18nKey="dashboard.generateDialogText" components={{ br: <br /> }} />
</DialogContentText>
)}
{loading && (
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<CircularProgress />
</Box>
)}
{result && (
<Alert severity="success" sx={{ mt: 1 }}>
{result.message}
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mt: 1 }}>
{error}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>
{result ? t("common.close") : t("common.cancel")}
</Button>
{!result && (
<Button
onClick={handleGenerateDemo}
variant="contained"
disabled={loading}
>
{t("common.generate")}
</Button>
)}
</DialogActions>
</Dialog>
{/* Dialog Pulisci Database */}
<Dialog open={demoDialog === "clear"} onClose={handleCloseDialog}>
<DialogTitle>{t("dashboard.clearDialogTitle")}</DialogTitle>
<DialogContent>
{!result && !error && (
<Alert severity="warning" sx={{ mb: 2 }}>
{t("dashboard.clearDialogWarning")}
</Alert>
)}
{!result && !error && (
<DialogContentText>
<Trans i18nKey="dashboard.clearDialogText" components={{ br: <br /> }} />
</DialogContentText>
)}
{loading && (
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<CircularProgress />
</Box>
)}
{result && (
<Alert severity="success" sx={{ mt: 1 }}>
{t("dashboard.clearSuccess", {
events: result.eventiCreati,
clients: result.clientiCreati,
locations: result.locationCreate,
resources: result.risorseCreate,
articles: result.articoliCreati
})}
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mt: 1 }}>
{error}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>
{result ? t("common.close") : t("common.cancel")}
</Button>
{!result && (
<Button
onClick={handleClearDemo}
variant="contained"
color="error"
disabled={loading}
>
{t("common.deleteAll")}
</Button>
)}
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -29,9 +29,9 @@ import {
} from '@mui/icons-material';
import dayjs from 'dayjs';
import { useTranslation } from 'react-i18next';
import { eventiService } from '../services/eventiService';
import { lookupService } from '../services/lookupService';
import { Evento, StatoEvento } from '../types';
import { eventiService } from '../../../services/eventiService';
import { lookupService } from '../../../services/lookupService';
import { Evento, StatoEvento } from '../../../types';
const getStatoLabel = (stato: StatoEvento, t: any) => {
switch (stato) {
@@ -150,10 +150,10 @@ export default function EventiPage() {
sortable: false,
renderCell: (params) => (
<Box>
<IconButton size="small" onClick={() => navigate(`/eventi/${params.row.id}`)}>
<IconButton size="small" onClick={() => navigate(`/events/list/${params.row.id}`)}>
<ViewIcon />
</IconButton>
<IconButton size="small" onClick={() => navigate(`/eventi/${params.row.id}`)}>
<IconButton size="small" onClick={() => navigate(`/events/list/${params.row.id}`)}>
<EditIcon />
</IconButton>
<IconButton size="small" onClick={() => duplicaMutation.mutate(params.row.id)}>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate, useLocation } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { signalRService } from "../services/signalr";
import { signalRService } from "../../../services/signalr";
import {
Box,
Typography,
@@ -42,16 +42,16 @@ import {
} from "@mui/icons-material";
import { useTranslation } from "react-i18next";
import dayjs from "dayjs";
import { eventiService } from "../services/eventiService";
import { lookupService } from "../services/lookupService";
import EventoCostiPanel from "../components/EventoCostiPanel";
import { eventiService } from "../../../services/eventiService";
import { lookupService } from "../../../services/lookupService";
import EventoCostiPanel from "../../../components/EventoCostiPanel";
import {
Evento,
StatoEvento,
EventoDettaglioOspiti,
EventoDettaglioPrelievo,
EventoDettaglioRisorsa,
} from "../types";
} from "../../../types";
interface TabPanelProps {
children?: React.ReactNode;
@@ -147,7 +147,7 @@ export default function EventoDetailPage() {
const createMutation = useMutation({
mutationFn: (data: Partial<Evento>) => eventiService.create(data),
onSuccess: (newEvento) => {
navigate(`/eventi/${newEvento.id}`);
navigate(`/events/list/${newEvento.id}`);
},
});
@@ -206,7 +206,7 @@ export default function EventoDetailPage() {
const duplicaMutation = useMutation({
mutationFn: () => eventiService.duplica(eventoId),
onSuccess: (newEvento) => {
navigate(`/eventi/${newEvento.id}`);
navigate(`/events/list/${newEvento.id}`);
},
});
@@ -362,7 +362,7 @@ export default function EventoDetailPage() {
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<IconButton
onClick={() => navigate("/eventi")}
onClick={() => navigate("/events/list")}
sx={{ color: statoInfo.textColor }}
>
<BackIcon />

View File

@@ -16,8 +16,8 @@ import {
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { locationService } from '../services/lookupService';
import { Location } from '../types';
import { locationService } from '../../../services/lookupService';
import { Location } from '../../../types';
export default function LocationPage() {
const queryClient = useQueryClient();

View File

@@ -0,0 +1,17 @@
import { Routes, Route, Navigate } from "react-router-dom";
import EventiPage from "./pages/EventiPage";
import EventoDetailPage from "./pages/EventoDetailPage";
import CalendarioPage from "./pages/CalendarioPage";
import LocationPage from "./pages/LocationPage";
export default function EventsRoutes() {
return (
<Routes>
<Route path="/" element={<Navigate to="list" replace />} />
<Route path="list" element={<EventiPage />} />
<Route path="list/:id" element={<EventoDetailPage />} />
<Route path="calendar" element={<CalendarioPage />} />
<Route path="locations" element={<LocationPage />} />
</Routes>
);
}

View File

@@ -0,0 +1,281 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Box,
Typography,
Button,
Paper,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Grid,
MenuItem,
} from '@mui/material';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import axios from 'axios';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import dayjs from 'dayjs';
// Interfaces
interface Dipendente {
id: number;
nome: string;
cognome: string;
}
interface Assenza {
id: number;
dipendenteId: number;
dipendente?: Dipendente;
dataInizio: string;
dataFine: string;
tipoAssenza: string;
stato: string;
note?: string;
}
const assenzeService = {
getAll: async (dipendenteId?: number) => {
const params = dipendenteId ? { dipendenteId } : {};
const response = await axios.get<Assenza[]>('/api/hr/assenze', { params });
return response.data;
},
create: async (data: Partial<Assenza>) => {
const response = await axios.post<Assenza>('/api/hr/assenze', data);
return response.data;
},
update: async (id: number, data: Partial<Assenza>) => {
const response = await axios.put(`/api/hr/assenze/${id}`, data);
return response.data;
},
delete: async (id: number) => {
await axios.delete(`/api/hr/assenze/${id}`);
},
};
const dipendentiService = {
getAll: async () => {
const response = await axios.get<Dipendente[]>('/api/hr/dipendenti');
return response.data;
},
};
export default function AssenzePage() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const [openDialog, setOpenDialog] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<Partial<Assenza>>({ stato: 'Richiesta' });
const { data: assenze = [], isLoading } = useQuery({
queryKey: ['assenze'],
queryFn: () => assenzeService.getAll(),
});
const { data: dipendenti = [] } = useQuery({
queryKey: ['dipendenti'],
queryFn: () => dipendentiService.getAll(),
});
const createMutation = useMutation({
mutationFn: (data: Partial<Assenza>) => assenzeService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['assenze'] });
handleCloseDialog();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<Assenza> }) => assenzeService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['assenze'] });
handleCloseDialog();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => assenzeService.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['assenze'] }),
});
const handleCloseDialog = () => {
setOpenDialog(false);
setEditingId(null);
setFormData({ stato: 'Richiesta' });
};
const handleEdit = (assenza: Assenza) => {
setFormData(assenza);
setEditingId(assenza.id);
setOpenDialog(true);
};
const handleSubmit = () => {
if (editingId) {
updateMutation.mutate({ id: editingId, data: formData });
} else {
createMutation.mutate(formData);
}
};
const columns: GridColDef[] = [
{
field: 'dipendente',
headerName: t('personale.dipendente'),
width: 200,
valueGetter: (params: any) => params?.nome && params?.cognome ? `${params.cognome} ${params.nome}` : '',
},
{ field: 'tipoAssenza', headerName: t('personale.tipoAssenza'), width: 150 },
{
field: 'dataInizio',
headerName: t('personale.dataInizio'),
width: 120,
valueFormatter: (params: any) => params?.value ? dayjs(params.value).format('DD/MM/YYYY') : '',
},
{
field: 'dataFine',
headerName: t('personale.dataFine'),
width: 120,
valueFormatter: (params: any) => params?.value ? dayjs(params.value).format('DD/MM/YYYY') : '',
},
{ field: 'stato', headerName: t('personale.stato'), width: 120 },
{
field: 'actions',
headerName: t('common.actions'),
width: 120,
sortable: false,
renderCell: (params) => (
<Box>
<IconButton size="small" onClick={() => handleEdit(params.row)}>
<EditIcon />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => {
if (confirm(t('common.deleteConfirm'))) {
deleteMutation.mutate(params.row.id);
}
}}
>
<DeleteIcon />
</IconButton>
</Box>
),
},
];
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4">{t('personale.assenzeTitle')}</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenDialog(true)}>
{t('personale.newAssenza')}
</Button>
</Box>
<Paper sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={assenze}
columns={columns}
loading={isLoading}
pageSizeOptions={[10, 25, 50]}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
}}
disableRowSelectionOnClick
/>
</Paper>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
<DialogTitle>{editingId ? t('personale.editAssenza') : t('personale.newAssenza')}</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={12}>
<TextField
fullWidth
select
label={t('personale.dipendente')}
value={formData.dipendenteId || ''}
onChange={(e) => setFormData({ ...formData, dipendenteId: Number(e.target.value) })}
required
>
{dipendenti?.map((d) => (
<MenuItem key={d.id} value={d.id}>
{d.nome} {d.cognome}
</MenuItem>
))}
</TextField>
</Grid>
<Grid size={6}>
<TextField
fullWidth
select
label={t('personale.tipoAssenza')}
value={formData.tipoAssenza || ''}
onChange={(e) => setFormData({ ...formData, tipoAssenza: e.target.value })}
required
>
<MenuItem value="Ferie">Ferie</MenuItem>
<MenuItem value="Malattia">Malattia</MenuItem>
<MenuItem value="Permesso">Permesso</MenuItem>
<MenuItem value="Altro">Altro</MenuItem>
</TextField>
</Grid>
<Grid size={6}>
<TextField
fullWidth
select
label={t('personale.stato')}
value={formData.stato || 'Richiesta'}
onChange={(e) => setFormData({ ...formData, stato: e.target.value })}
required
>
<MenuItem value="Richiesta">Richiesta</MenuItem>
<MenuItem value="Approvata">Approvata</MenuItem>
<MenuItem value="Rifiutata">Rifiutata</MenuItem>
</TextField>
</Grid>
<Grid size={6}>
<DatePicker
label={t('personale.dataInizio')}
value={formData.dataInizio ? dayjs(formData.dataInizio) : null}
onChange={(newValue) => setFormData({ ...formData, dataInizio: newValue ? newValue.toISOString() : undefined })}
slotProps={{ textField: { fullWidth: true, required: true } }}
/>
</Grid>
<Grid size={6}>
<DatePicker
label={t('personale.dataFine')}
value={formData.dataFine ? dayjs(formData.dataFine) : null}
onChange={(newValue) => setFormData({ ...formData, dataFine: newValue ? newValue.toISOString() : undefined })}
slotProps={{ textField: { fullWidth: true, required: true } }}
/>
</Grid>
<Grid size={12}>
<TextField
label={t('common.notes')}
fullWidth
multiline
rows={3}
value={formData.note || ''}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
<Button variant="contained" onClick={handleSubmit}>
{editingId ? t('common.save') : t('common.create')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,292 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Box,
Typography,
Button,
Paper,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Grid,
MenuItem,
} from '@mui/material';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import axios from 'axios';
import dayjs from 'dayjs';
// Interfaces
interface Dipendente {
id: number;
nome: string;
cognome: string;
}
interface Contratto {
id: number;
dipendenteId: number;
dipendente?: Dipendente;
dataInizio: string;
dataFine?: string;
tipoContratto: string;
livello?: string;
retribuzioneLorda: number;
oreSettimanali?: number;
note?: string;
attivo: boolean;
}
const contrattiService = {
getAll: async (dipendenteId?: number) => {
const params = dipendenteId ? { dipendenteId } : {};
const response = await axios.get<Contratto[]>('/api/hr/contratti', { params });
return response.data;
},
create: async (data: Partial<Contratto>) => {
const response = await axios.post<Contratto>('/api/hr/contratti', data);
return response.data;
},
update: async (id: number, data: Partial<Contratto>) => {
const response = await axios.put(`/api/hr/contratti/${id}`, data);
return response.data;
},
delete: async (id: number) => {
await axios.delete(`/api/hr/contratti/${id}`);
},
};
const dipendentiService = {
getAll: async () => {
const response = await axios.get<Dipendente[]>('/api/hr/dipendenti');
return response.data;
},
};
export default function ContrattiPage() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const [openDialog, setOpenDialog] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<Partial<Contratto>>({ attivo: true });
const { data: contratti = [], isLoading } = useQuery({
queryKey: ['contratti'],
queryFn: () => contrattiService.getAll(),
});
const { data: dipendenti = [] } = useQuery({
queryKey: ['dipendenti'],
queryFn: () => dipendentiService.getAll(),
});
const createMutation = useMutation({
mutationFn: (data: Partial<Contratto>) => contrattiService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contratti'] });
handleCloseDialog();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<Contratto> }) => contrattiService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contratti'] });
handleCloseDialog();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => contrattiService.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['contratti'] }),
});
const handleCloseDialog = () => {
setOpenDialog(false);
setEditingId(null);
setFormData({ attivo: true });
};
const handleEdit = (contratto: Contratto) => {
setFormData(contratto);
setEditingId(contratto.id);
setOpenDialog(true);
};
const handleSubmit = () => {
if (editingId) {
updateMutation.mutate({ id: editingId, data: formData });
} else {
createMutation.mutate(formData);
}
};
const columns: GridColDef[] = [
{
field: 'dipendente',
headerName: t('personale.dipendente'),
width: 200,
valueGetter: (params: any) => params?.nome && params?.cognome ? `${params.cognome} ${params.nome}` : '',
},
{ field: 'tipoContratto', headerName: t('personale.tipoContratto'), width: 150 },
{
field: 'dataInizio',
headerName: t('personale.dataInizio'),
width: 120,
valueFormatter: (params: any) => params?.value ? dayjs(params.value).format('DD/MM/YYYY') : '',
},
{
field: 'dataFine',
headerName: t('personale.dataFine'),
width: 120,
valueFormatter: (params: any) => params?.value ? dayjs(params.value).format('DD/MM/YYYY') : '',
},
{ field: 'retribuzioneLorda', headerName: t('personale.retribuzione'), width: 120, type: 'number' },
{ field: 'attivo', headerName: t('personale.attivo'), width: 100, type: 'boolean' },
{
field: 'actions',
headerName: t('common.actions'),
width: 120,
sortable: false,
renderCell: (params) => (
<Box>
<IconButton size="small" onClick={() => handleEdit(params.row)}>
<EditIcon />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => {
if (confirm(t('common.deleteConfirm'))) {
deleteMutation.mutate(params.row.id);
}
}}
>
<DeleteIcon />
</IconButton>
</Box>
),
},
];
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4">{t('personale.contrattiTitle')}</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenDialog(true)}>
{t('personale.newContratto')}
</Button>
</Box>
<Paper sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={contratti}
columns={columns}
loading={isLoading}
pageSizeOptions={[10, 25, 50]}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
}}
disableRowSelectionOnClick
/>
</Paper>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
<DialogTitle>{editingId ? t('personale.editContratto') : t('personale.newContratto')}</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={12}>
<TextField
fullWidth
select
label="Dipendente"
value={formData.dipendenteId || ''}
onChange={(e) => setFormData({ ...formData, dipendenteId: Number(e.target.value) })}
>
{dipendenti?.map((d) => (
<MenuItem key={d.id} value={d.id}>
{d.nome} {d.cognome}
</MenuItem>
))}
</TextField>
</Grid>
<Grid size={6}>
<TextField
fullWidth
label="Tipo Contratto"
value={formData.tipoContratto || ''}
onChange={(e) => setFormData({ ...formData, tipoContratto: e.target.value })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
label="Livello"
value={formData.livello || ''}
onChange={(e) => setFormData({ ...formData, livello: e.target.value })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
type="date"
label="Data Inizio"
InputLabelProps={{ shrink: true }}
value={formData.dataInizio ? new Date(formData.dataInizio).toISOString().split('T')[0] : ''}
onChange={(e) => setFormData({ ...formData, dataInizio: new Date(e.target.value).toISOString() })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
type="date"
label="Data Fine"
InputLabelProps={{ shrink: true }}
value={formData.dataFine ? new Date(formData.dataFine).toISOString().split('T')[0] : ''}
onChange={(e) => setFormData({ ...formData, dataFine: e.target.value ? new Date(e.target.value).toISOString() : undefined })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
type="number"
label="RAL"
value={formData.retribuzioneLorda || ''}
onChange={(e) => setFormData({ ...formData, retribuzioneLorda: Number(e.target.value) })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
type="number"
label="Ore Settimanali"
value={formData.oreSettimanali || ''}
onChange={(e) => setFormData({ ...formData, oreSettimanali: Number(e.target.value) })}
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
multiline
rows={3}
label="Note"
value={formData.note || ''}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
<Button variant="contained" onClick={handleSubmit}>
{editingId ? t('common.save') : t('common.create')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,277 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Box,
Typography,
Button,
Paper,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Grid,
} from '@mui/material';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import axios from 'axios';
// Interfaces
interface Dipendente {
id: number;
nome: string;
cognome: string;
codiceFiscale?: string;
email?: string;
telefono?: string;
ruolo?: string;
dataNascita?: string;
indirizzo?: string;
citta?: string;
cap?: string;
dataAssunzione?: string;
dipartimento?: string;
}
const dipendentiService = {
getAll: async (search?: string) => {
const params = search ? { search } : {};
const response = await axios.get<Dipendente[]>('/api/hr/dipendenti', { params });
return response.data;
},
get: async (id: number) => {
const response = await axios.get<Dipendente>(`/api/hr/dipendenti/${id}`);
return response.data;
},
create: async (data: Partial<Dipendente>) => {
const response = await axios.post<Dipendente>('/api/hr/dipendenti', data);
return response.data;
},
update: async (id: number, data: Partial<Dipendente>) => {
const response = await axios.put(`/api/hr/dipendenti/${id}`, data);
return response.data;
},
delete: async (id: number) => {
await axios.delete(`/api/hr/dipendenti/${id}`);
},
};
export default function DipendentiPage() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const [openDialog, setOpenDialog] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<Partial<Dipendente>>({});
const [search, setSearch] = useState('');
const { data: dipendenti = [], isLoading } = useQuery({
queryKey: ['dipendenti', search],
queryFn: () => dipendentiService.getAll(search),
});
const createMutation = useMutation({
mutationFn: (data: Partial<Dipendente>) => dipendentiService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dipendenti'] });
handleCloseDialog();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<Dipendente> }) => dipendentiService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dipendenti'] });
handleCloseDialog();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => dipendentiService.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dipendenti'] }),
});
const handleCloseDialog = () => {
setOpenDialog(false);
setEditingId(null);
setFormData({});
};
const handleEdit = (dipendente: Dipendente) => {
setFormData(dipendente);
setEditingId(dipendente.id);
setOpenDialog(true);
};
const handleSubmit = () => {
if (editingId) {
updateMutation.mutate({ id: editingId, data: formData });
} else {
createMutation.mutate(formData);
}
};
const columns: GridColDef[] = [
{ field: 'nome', headerName: t('personale.nome'), width: 150 },
{ field: 'cognome', headerName: t('personale.cognome'), width: 150 },
{ field: 'ruolo', headerName: t('personale.ruolo'), width: 150 },
{ field: 'email', headerName: t('personale.email'), width: 200 },
{ field: 'telefono', headerName: t('personale.telefono'), width: 150 },
{
field: 'actions',
headerName: t('common.actions'),
width: 150,
sortable: false,
renderCell: (params) => (
<Box>
<IconButton size="small" onClick={() => handleEdit(params.row)}>
<EditIcon />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => {
if (confirm(t('common.deleteConfirm'))) {
deleteMutation.mutate(params.row.id);
}
}}
>
<DeleteIcon />
</IconButton>
</Box>
),
},
];
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4">{t('personale.dipendentiTitle')}</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenDialog(true)}>
{t('personale.newDipendente')}
</Button>
</Box>
<Paper sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={dipendenti}
columns={columns}
loading={isLoading}
pageSizeOptions={[10, 25, 50]}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
}}
disableRowSelectionOnClick
/>
</Paper>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
<DialogTitle>{editingId ? t('personale.editDipendente') : t('personale.newDipendente')}</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={6}>
<TextField
fullWidth
label={t('personale.nome')}
required
value={formData.nome || ''}
onChange={(e) => setFormData({ ...formData, nome: e.target.value })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
label={t('personale.cognome')}
required
value={formData.cognome || ''}
onChange={(e) => setFormData({ ...formData, cognome: e.target.value })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
label={t('personale.codiceFiscale')}
value={formData.codiceFiscale || ''}
onChange={(e) => setFormData({ ...formData, codiceFiscale: e.target.value })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
label={t('personale.email')}
type="email"
value={formData.email || ''}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
label={t('personale.telefono')}
value={formData.telefono || ''}
onChange={(e) => setFormData({ ...formData, telefono: e.target.value })}
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label={t('personale.indirizzo')}
value={formData.indirizzo || ''}
onChange={(e) => setFormData({ ...formData, indirizzo: e.target.value })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
label={t('personale.citta')}
value={formData.citta || ''}
onChange={(e) => setFormData({ ...formData, citta: e.target.value })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
label={t('personale.cap')}
value={formData.cap || ''}
onChange={(e) => setFormData({ ...formData, cap: e.target.value })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
type="date"
label={t('personale.dataAssunzione')}
InputLabelProps={{ shrink: true }}
value={formData.dataAssunzione ? new Date(formData.dataAssunzione).toISOString().split('T')[0] : ''}
onChange={(e) => setFormData({ ...formData, dataAssunzione: new Date(e.target.value).toISOString() })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
label={t('personale.ruolo')}
value={formData.ruolo || ''}
onChange={(e) => setFormData({ ...formData, ruolo: e.target.value })}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
label={t('personale.dipartimento')}
value={formData.dipartimento || ''}
onChange={(e) => setFormData({ ...formData, dipartimento: e.target.value })}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
<Button variant="contained" onClick={handleSubmit}>
{editingId ? t('common.save') : t('common.create')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,256 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Box,
Typography,
Button,
Paper,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Grid,
MenuItem,
} from '@mui/material';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import axios from 'axios';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import dayjs from 'dayjs';
// Interfaces
interface Dipendente {
id: number;
nome: string;
cognome: string;
}
interface Pagamento {
id: number;
dipendenteId: number;
dipendente?: Dipendente;
data: string;
importo: number;
causale: string;
note?: string;
}
const pagamentiService = {
getAll: async (dipendenteId?: number) => {
const params = dipendenteId ? { dipendenteId } : {};
const response = await axios.get<Pagamento[]>('/api/hr/pagamenti', { params });
return response.data;
},
create: async (data: Partial<Pagamento>) => {
const response = await axios.post<Pagamento>('/api/hr/pagamenti', data);
return response.data;
},
update: async (id: number, data: Partial<Pagamento>) => {
const response = await axios.put(`/api/hr/pagamenti/${id}`, data);
return response.data;
},
delete: async (id: number) => {
await axios.delete(`/api/hr/pagamenti/${id}`);
},
};
const dipendentiService = {
getAll: async () => {
const response = await axios.get<Dipendente[]>('/api/hr/dipendenti');
return response.data;
},
};
export default function PagamentiPage() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const [openDialog, setOpenDialog] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<Partial<Pagamento>>({});
const { data: pagamenti = [], isLoading } = useQuery({
queryKey: ['pagamenti'],
queryFn: () => pagamentiService.getAll(),
});
const { data: dipendenti = [] } = useQuery({
queryKey: ['dipendenti'],
queryFn: () => dipendentiService.getAll(),
});
const createMutation = useMutation({
mutationFn: (data: Partial<Pagamento>) => pagamentiService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pagamenti'] });
handleCloseDialog();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<Pagamento> }) => pagamentiService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pagamenti'] });
handleCloseDialog();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => pagamentiService.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['pagamenti'] }),
});
const handleCloseDialog = () => {
setOpenDialog(false);
setEditingId(null);
setFormData({});
};
const handleEdit = (pagamento: Pagamento) => {
setFormData(pagamento);
setEditingId(pagamento.id);
setOpenDialog(true);
};
const handleSubmit = () => {
if (editingId) {
updateMutation.mutate({ id: editingId, data: formData });
} else {
createMutation.mutate(formData);
}
};
const columns: GridColDef[] = [
{
field: 'dipendente',
headerName: t('personale.dipendente'),
width: 200,
valueGetter: (params: any) => params?.nome && params?.cognome ? `${params.cognome} ${params.nome}` : '',
},
{
field: 'data',
headerName: t('personale.data'),
width: 120,
valueFormatter: (params: any) => params?.value ? dayjs(params.value).format('DD/MM/YYYY') : '',
},
{ field: 'importo', headerName: t('personale.importo'), width: 120, type: 'number' },
{ field: 'causale', headerName: t('personale.causale'), width: 200 },
{
field: 'actions',
headerName: t('common.actions'),
width: 120,
sortable: false,
renderCell: (params) => (
<Box>
<IconButton size="small" onClick={() => handleEdit(params.row)}>
<EditIcon />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => {
if (confirm(t('common.deleteConfirm'))) {
deleteMutation.mutate(params.row.id);
}
}}
>
<DeleteIcon />
</IconButton>
</Box>
),
},
];
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4">{t('personale.pagamentiTitle')}</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenDialog(true)}>
{t('personale.newPagamento')}
</Button>
</Box>
<Paper sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={pagamenti}
columns={columns}
loading={isLoading}
pageSizeOptions={[10, 25, 50]}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
}}
disableRowSelectionOnClick
/>
</Paper>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
<DialogTitle>{editingId ? t('personale.editPagamento') : t('personale.newPagamento')}</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={12}>
<TextField
fullWidth
select
label={t('personale.dipendente')}
value={formData.dipendenteId || ''}
onChange={(e) => setFormData({ ...formData, dipendenteId: Number(e.target.value) })}
required
>
{dipendenti?.map((d) => (
<MenuItem key={d.id} value={d.id}>
{d.nome} {d.cognome}
</MenuItem>
))}
</TextField>
</Grid>
<Grid size={6}>
<DatePicker
label={t('personale.data')}
value={formData.data ? dayjs(formData.data) : null}
onChange={(newValue) => setFormData({ ...formData, data: newValue ? newValue.toISOString() : undefined })}
slotProps={{ textField: { fullWidth: true, required: true } }}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
type="number"
label={t('personale.importo')}
value={formData.importo || ''}
onChange={(e) => setFormData({ ...formData, importo: Number(e.target.value) })}
required
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label={t('personale.causale')}
value={formData.causale || ''}
onChange={(e) => setFormData({ ...formData, causale: e.target.value })}
required
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
multiline
rows={3}
label={t('common.notes')}
value={formData.note || ''}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
<Button variant="contained" onClick={handleSubmit}>
{editingId ? t('common.save') : t('common.create')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,273 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Box,
Typography,
Button,
Paper,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Grid,
MenuItem,
} from '@mui/material';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import axios from 'axios';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import dayjs from 'dayjs';
// Interfaces
interface Dipendente {
id: number;
nome: string;
cognome: string;
}
interface Rimborso {
id: number;
dipendenteId: number;
dipendente?: Dipendente;
data: string;
importo: number;
descrizione: string;
stato: string;
note?: string;
}
const rimborsiService = {
getAll: async (dipendenteId?: number) => {
const params = dipendenteId ? { dipendenteId } : {};
const response = await axios.get<Rimborso[]>('/api/hr/rimborsi', { params });
return response.data;
},
create: async (data: Partial<Rimborso>) => {
const response = await axios.post<Rimborso>('/api/hr/rimborsi', data);
return response.data;
},
update: async (id: number, data: Partial<Rimborso>) => {
const response = await axios.put(`/api/hr/rimborsi/${id}`, data);
return response.data;
},
delete: async (id: number) => {
await axios.delete(`/api/hr/rimborsi/${id}`);
},
};
const dipendentiService = {
getAll: async () => {
const response = await axios.get<Dipendente[]>('/api/hr/dipendenti');
return response.data;
},
};
export default function RimborsiPage() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const [openDialog, setOpenDialog] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<Partial<Rimborso>>({ stato: 'Richiesto' });
const { data: rimborsi = [], isLoading } = useQuery({
queryKey: ['rimborsi'],
queryFn: () => rimborsiService.getAll(),
});
const { data: dipendenti = [] } = useQuery({
queryKey: ['dipendenti'],
queryFn: () => dipendentiService.getAll(),
});
const createMutation = useMutation({
mutationFn: (data: Partial<Rimborso>) => rimborsiService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rimborsi'] });
handleCloseDialog();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<Rimborso> }) => rimborsiService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rimborsi'] });
handleCloseDialog();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => rimborsiService.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rimborsi'] }),
});
const handleCloseDialog = () => {
setOpenDialog(false);
setEditingId(null);
setFormData({ stato: 'Richiesto' });
};
const handleEdit = (rimborso: Rimborso) => {
setFormData(rimborso);
setEditingId(rimborso.id);
setOpenDialog(true);
};
const handleSubmit = () => {
if (editingId) {
updateMutation.mutate({ id: editingId, data: formData });
} else {
createMutation.mutate(formData);
}
};
const columns: GridColDef[] = [
{
field: 'dipendente',
headerName: t('personale.dipendente'),
width: 200,
valueGetter: (params: any) => params?.nome && params?.cognome ? `${params.cognome} ${params.nome}` : '',
},
{
field: 'data',
headerName: t('personale.data'),
width: 120,
valueFormatter: (params: any) => params?.value ? dayjs(params.value).format('DD/MM/YYYY') : '',
},
{ field: 'importo', headerName: t('personale.importo'), width: 120, type: 'number' },
{ field: 'descrizione', headerName: t('personale.descrizione'), width: 200 },
{ field: 'stato', headerName: t('personale.stato'), width: 120 },
{
field: 'actions',
headerName: t('common.actions'),
width: 120,
sortable: false,
renderCell: (params) => (
<Box>
<IconButton size="small" onClick={() => handleEdit(params.row)}>
<EditIcon />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => {
if (confirm(t('common.deleteConfirm'))) {
deleteMutation.mutate(params.row.id);
}
}}
>
<DeleteIcon />
</IconButton>
</Box>
),
},
];
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4">{t('personale.rimborsiTitle')}</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenDialog(true)}>
{t('personale.newRimborso')}
</Button>
</Box>
<Paper sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={rimborsi}
columns={columns}
loading={isLoading}
pageSizeOptions={[10, 25, 50]}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
}}
disableRowSelectionOnClick
/>
</Paper>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
<DialogTitle>{editingId ? t('personale.editRimborso') : t('personale.newRimborso')}</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={12}>
<TextField
fullWidth
select
label={t('personale.dipendente')}
value={formData.dipendenteId || ''}
onChange={(e) => setFormData({ ...formData, dipendenteId: Number(e.target.value) })}
required
>
{dipendenti?.map((d) => (
<MenuItem key={d.id} value={d.id}>
{d.nome} {d.cognome}
</MenuItem>
))}
</TextField>
</Grid>
<Grid size={6}>
<DatePicker
label={t('personale.data')}
value={formData.data ? dayjs(formData.data) : null}
onChange={(newValue) => setFormData({ ...formData, data: newValue ? newValue.toISOString() : undefined })}
slotProps={{ textField: { fullWidth: true, required: true } }}
/>
</Grid>
<Grid size={6}>
<TextField
fullWidth
type="number"
label={t('personale.importo')}
value={formData.importo || ''}
onChange={(e) => setFormData({ ...formData, importo: Number(e.target.value) })}
required
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label={t('personale.descrizione')}
value={formData.descrizione || ''}
onChange={(e) => setFormData({ ...formData, descrizione: e.target.value })}
required
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
select
label={t('personale.stato')}
value={formData.stato || 'Richiesto'}
onChange={(e) => setFormData({ ...formData, stato: e.target.value })}
required
>
<MenuItem value="Richiesto">Richiesto</MenuItem>
<MenuItem value="Approvato">Approvato</MenuItem>
<MenuItem value="Rimborsato">Rimborsato</MenuItem>
<MenuItem value="Rifiutato">Rifiutato</MenuItem>
</TextField>
</Grid>
<Grid size={12}>
<TextField
fullWidth
multiline
rows={3}
label={t('common.notes')}
value={formData.note || ''}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
<Button variant="contained" onClick={handleSubmit}>
{editingId ? t('common.save') : t('common.create')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,19 @@
import { Routes, Route, Navigate } from "react-router-dom";
import DipendentiPage from "./pages/DipendentiPage";
import ContrattiPage from "./pages/ContrattiPage";
import AssenzePage from "./pages/AssenzePage";
import PagamentiPage from "./pages/PagamentiPage";
import RimborsiPage from "./pages/RimborsiPage";
export default function HRRoutes() {
return (
<Routes>
<Route path="/" element={<Navigate to="dipendenti" replace />} />
<Route path="dipendenti" element={<DipendentiPage />} />
<Route path="contratti" element={<ContrattiPage />} />
<Route path="assenze" element={<AssenzePage />} />
<Route path="pagamenti" element={<PagamentiPage />} />
<Route path="rimborsi" element={<RimborsiPage />} />
</Routes>
);
}

View File

@@ -1,430 +1,21 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Grid,
Paper,
Typography,
Box,
Card,
CardContent,
List,
ListItem,
ListItemText,
Chip,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Alert,
CircularProgress,
} from "@mui/material";
import {
Event as EventIcon,
People as PeopleIcon,
CheckCircle as ConfirmedIcon,
PendingActions as PendingIcon,
PlayArrow as GenerateIcon,
DeleteSweep as ClearIcon,
} from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import dayjs from "dayjs";
import { useTranslation, Trans } from "react-i18next";
import { eventiService } from "../services/eventiService";
import { demoService, DemoDataResult } from "../services/demoService";
import { StatoEvento } from "../types";
const StatCard = ({
title,
value,
icon,
color,
}: {
title: string;
value: number;
icon: React.ReactNode;
color: string;
}) => (
<Card sx={{ height: "100%" }}>
<CardContent>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Box>
<Typography color="textSecondary" gutterBottom variant="body2">
{title}
</Typography>
<Typography variant="h4">{value}</Typography>
</Box>
<Box sx={{ color, opacity: 0.7 }}>{icon}</Box>
</Box>
</CardContent>
</Card>
);
const getStatoLabel = (stato: StatoEvento, t: any) => {
switch (stato) {
case StatoEvento.Scheda:
return t("status.scheda");
case StatoEvento.Preventivo:
return t("status.preventivo");
case StatoEvento.Confermato:
return t("status.confermato");
default:
return t("common.unknown");
}
};
const getStatoColor = (stato: StatoEvento) => {
switch (stato) {
case StatoEvento.Scheda:
return "default";
case StatoEvento.Preventivo:
return "warning";
case StatoEvento.Confermato:
return "success";
default:
return "default";
}
};
import { Box, Typography, Grid, Paper } from '@mui/material';
export default function Dashboard() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t } = useTranslation();
const [demoDialog, setDemoDialog] = useState<"generate" | "clear" | null>(
null,
);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<DemoDataResult | null>(null);
const [error, setError] = useState<string | null>(null);
const { data: eventi = [] } = useQuery({
queryKey: ["eventi"],
queryFn: () => eventiService.getAll(),
});
const handleGenerateDemo = async () => {
setLoading(true);
setError(null);
try {
const res = await demoService.generateDemoData();
setResult(res);
queryClient.invalidateQueries();
} catch (err: any) {
setError(
err.response?.data?.error || t("dashboard.generateError"),
);
} finally {
setLoading(false);
}
};
const handleClearDemo = async () => {
setLoading(true);
setError(null);
try {
const res = await demoService.clearDemoData();
setResult(res);
queryClient.invalidateQueries();
} catch (err: any) {
setError(
err.response?.data?.error || t("dashboard.clearError"),
);
} finally {
setLoading(false);
}
};
const handleCloseDialog = () => {
setDemoDialog(null);
setResult(null);
setError(null);
};
const oggi = dayjs().startOf("day");
const prossimi30Giorni = oggi.add(30, "day");
const eventiProssimi = eventi
.filter(
(e) =>
dayjs(e.dataEvento).isAfter(oggi) &&
dayjs(e.dataEvento).isBefore(prossimi30Giorni),
)
.sort((a, b) => dayjs(a.dataEvento).diff(dayjs(b.dataEvento)));
const eventiConfermati = eventi.filter(
(e) => e.stato === StatoEvento.Confermato,
).length;
const eventiPreventivo = eventi.filter(
(e) => e.stato === StatoEvento.Preventivo,
).length;
const eventiOggi = eventi.filter((e) =>
dayjs(e.dataEvento).isSame(oggi, "day"),
).length;
return (
<Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 3,
}}
>
<Typography variant="h4">{t("dashboard.title")}</Typography>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
color="primary"
startIcon={<GenerateIcon />}
onClick={() => setDemoDialog("generate")}
>
{t("dashboard.generateDemoData")}
</Button>
<Button
variant="outlined"
color="error"
startIcon={<ClearIcon />}
onClick={() => setDemoDialog("clear")}
>
{t("dashboard.clearDatabase")}
</Button>
</Box>
</Box>
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title={t("dashboard.totalEvents")}
value={eventi.length}
icon={<EventIcon sx={{ fontSize: 48 }} />}
color="#1976d2"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title={t("dashboard.confirmed")}
value={eventiConfermati}
icon={<ConfirmedIcon sx={{ fontSize: 48 }} />}
color="#4caf50"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title={t("dashboard.inQuote")}
value={eventiPreventivo}
icon={<PendingIcon sx={{ fontSize: 48 }} />}
color="#ff9800"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title={t("dashboard.eventsToday")}
value={eventiOggi}
icon={<PeopleIcon sx={{ fontSize: 48 }} />}
color="#9c27b0"
/>
</Grid>
</Grid>
<Typography variant="h4" gutterBottom>
Dashboard
</Typography>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<Grid size={12}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
{t("dashboard.upcomingEvents")}
<Typography variant="h6">Welcome to Zentral</Typography>
<Typography variant="body1">
Select a module from the menu to get started.
</Typography>
<List>
{eventiProssimi.slice(0, 10).map((evento) => (
<ListItem
key={evento.id}
component="div"
onClick={() => navigate(`/eventi/${evento.id}`)}
sx={{
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
>
<ListItemText
primary={evento.descrizione || evento.codice}
secondary={
<Box
component="span"
sx={{ display: "flex", gap: 1, alignItems: "center" }}
>
<span>
{dayjs(evento.dataEvento).format("DD/MM/YYYY")}
</span>
<span>-</span>
<span>{evento.cliente?.ragioneSociale}</span>
</Box>
}
/>
<Chip
label={getStatoLabel(evento.stato, t)}
color={getStatoColor(evento.stato) as any}
size="small"
/>
</ListItem>
))}
{eventiProssimi.length === 0 && (
<ListItem>
<ListItemText primary={t("dashboard.noEvents")} />
</ListItem>
)}
</List>
</Paper>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
{t("dashboard.expiringQuotes")}
</Typography>
<List>
{eventi
.filter(
(e) =>
e.stato === StatoEvento.Preventivo &&
e.dataScadenzaPreventivo,
)
.sort((a, b) =>
dayjs(a.dataScadenzaPreventivo).diff(
dayjs(b.dataScadenzaPreventivo),
),
)
.slice(0, 5)
.map((evento) => (
<ListItem
key={evento.id}
component="div"
onClick={() => navigate(`/eventi/${evento.id}`)}
sx={{
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
>
<ListItemText
primary={evento.descrizione || evento.codice}
secondary={t("dashboard.expires", { date: dayjs(evento.dataScadenzaPreventivo).format("DD/MM/YYYY") })}
/>
<Chip
label={t("dashboard.guests", { count: evento.numeroOspiti || 0 })}
size="small"
variant="outlined"
/>
</ListItem>
))}
{eventi.filter((e) => e.stato === StatoEvento.Preventivo)
.length === 0 && (
<ListItem>
<ListItemText primary={t("dashboard.noQuotes")} />
</ListItem>
)}
</List>
</Paper>
</Grid>
</Grid>
{/* Dialog Genera Dati Demo */}
<Dialog open={demoDialog === "generate"} onClose={handleCloseDialog}>
<DialogTitle>{t("dashboard.generateDialogTitle")}</DialogTitle>
<DialogContent>
{!result && !error && (
<DialogContentText>
<Trans i18nKey="dashboard.generateDialogText" components={{ br: <br /> }} />
</DialogContentText>
)}
{loading && (
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<CircularProgress />
</Box>
)}
{result && (
<Alert severity="success" sx={{ mt: 1 }}>
{result.message}
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mt: 1 }}>
{error}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>
{result ? t("common.close") : t("common.cancel")}
</Button>
{!result && (
<Button
onClick={handleGenerateDemo}
variant="contained"
disabled={loading}
>
{t("common.generate")}
</Button>
)}
</DialogActions>
</Dialog>
{/* Dialog Pulisci Database */}
<Dialog open={demoDialog === "clear"} onClose={handleCloseDialog}>
<DialogTitle>{t("dashboard.clearDialogTitle")}</DialogTitle>
<DialogContent>
{!result && !error && (
<Alert severity="warning" sx={{ mb: 2 }}>
{t("dashboard.clearDialogWarning")}
</Alert>
)}
{!result && !error && (
<DialogContentText>
<Trans i18nKey="dashboard.clearDialogText" components={{ br: <br /> }} />
</DialogContentText>
)}
{loading && (
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<CircularProgress />
</Box>
)}
{result && (
<Alert severity="success" sx={{ mt: 1 }}>
{t("dashboard.clearSuccess", {
events: result.eventiCreati,
clients: result.clientiCreati,
locations: result.locationCreate,
resources: result.risorseCreate,
articles: result.articoliCreati
})}
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mt: 1 }}>
{error}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>
{result ? t("common.close") : t("common.cancel")}
</Button>
{!result && (
<Button
onClick={handleClearDemo}
variant="contained"
color="error"
disabled={loading}
>
{t("common.deleteAll")}
</Button>
)}
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -1,216 +0,0 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Box,
Typography,
Button,
Paper,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
} from '@mui/material';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { risorseService, lookupService } from '../services/lookupService';
import { Risorsa } from '../types';
export default function RisorsePage() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const [openDialog, setOpenDialog] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<Partial<Risorsa>>({ attivo: true });
const { data: risorse = [], isLoading } = useQuery({
queryKey: ['risorse'],
queryFn: () => risorseService.getAll(),
});
const { data: tipiRisorsa = [] } = useQuery({
queryKey: ['lookup', 'tipi-risorsa'],
queryFn: () => lookupService.getTipiRisorsa(),
});
const createMutation = useMutation({
mutationFn: (data: Partial<Risorsa>) => risorseService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['risorse'] });
handleCloseDialog();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<Risorsa> }) => risorseService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['risorse'] });
handleCloseDialog();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => risorseService.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['risorse'] }),
});
const handleCloseDialog = () => {
setOpenDialog(false);
setEditingId(null);
setFormData({ attivo: true });
};
const handleEdit = (risorsa: Risorsa) => {
setFormData(risorsa);
setEditingId(risorsa.id);
setOpenDialog(true);
};
const handleSubmit = () => {
if (editingId) {
updateMutation.mutate({ id: editingId, data: formData });
} else {
createMutation.mutate(formData);
}
};
const columns: GridColDef[] = [
{ field: 'nome', headerName: t('resources.name'), width: 150 },
{ field: 'cognome', headerName: t('resources.surname'), width: 150 },
{
field: 'tipoRisorsa',
headerName: t('resources.type'),
width: 150,
valueGetter: (value: any) => value?.descrizione || '',
},
{ field: 'telefono', headerName: t('resources.phone'), width: 130 },
{ field: 'email', headerName: t('resources.email'), flex: 1, minWidth: 200 },
{
field: 'actions',
headerName: t('common.actions'),
width: 120,
sortable: false,
renderCell: (params) => (
<Box>
<IconButton size="small" onClick={() => handleEdit(params.row)}>
<EditIcon />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => {
if (confirm(t('resources.deleteConfirm'))) {
deleteMutation.mutate(params.row.id);
}
}}
>
<DeleteIcon />
</IconButton>
</Box>
),
},
];
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4">{t('resources.title')}</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenDialog(true)}>
{t('resources.newResource')}
</Button>
</Box>
<Paper sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={risorse}
columns={columns}
loading={isLoading}
pageSizeOptions={[10, 25, 50]}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
}}
disableRowSelectionOnClick
/>
</Paper>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>{editingId ? t('resources.editResource') : t('resources.newResource')}</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label={t('resources.name')}
fullWidth
required
value={formData.nome || ''}
onChange={(e) => setFormData({ ...formData, nome: e.target.value })}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label={t('resources.surname')}
fullWidth
value={formData.cognome || ''}
onChange={(e) => setFormData({ ...formData, cognome: e.target.value })}
/>
</Grid>
<Grid size={{ xs: 12 }}>
<FormControl fullWidth>
<InputLabel>{t('resources.resourceType')}</InputLabel>
<Select
value={formData.tipoRisorsaId || ''}
label={t('resources.resourceType')}
onChange={(e) => setFormData({ ...formData, tipoRisorsaId: e.target.value as number })}
>
{tipiRisorsa.map((t) => (
<MenuItem key={t.id} value={t.id}>{t.descrizione}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label={t('resources.phone')}
fullWidth
value={formData.telefono || ''}
onChange={(e) => setFormData({ ...formData, telefono: e.target.value })}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label={t('resources.email')}
fullWidth
type="email"
value={formData.email || ''}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
label={t('common.notes')}
fullWidth
multiline
rows={3}
value={formData.note || ''}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
<Button variant="contained" onClick={handleSubmit}>
{editingId ? t('common.save') : t('common.create')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}