Form di richiesta ferie: validazione e calcolo giorni

richiesta-ferie-permessi

13 minuti di lettura

Un form per richiedere ferie sembra semplice: si scelgono una causale e alcune date, si aggiunge una nota e si preme “Invia”. Nella pratica, la schermata deve coordinare molto di più.

Deve capire se l’utente sta chiedendo ferie o permessi, se sta inserendo una giornata intera o alcune ore, se il limite della giornata è già stato caricato, se sta creando la richiesta per sé o per un dipendente, e se il payload può davvero essere inviato al backend.

Nel package feature-leaves, LeavesRequestFormScreen non contiene tutti i calcoli: orchestratore e componenti delegano la logica a useLeavesRequestForm, usePermessiValidation, useTimeCalculations e agli altri hook specializzati.


Il problema di un form HR

Una form generica tende ad avere un modello lineare: input, controllo campi, POST. Una form ferie/permessi ha invece una sequenza condizionata: scelta della causale, regole della causale, calendario o ore, configurazione del lavoratore, validazione, costruzione del payload, invio e risultato.

La causale scelta determina quali controlli sono necessari. Per esempio, un permesso orario ha bisogno di durata e ora di inizio; una ferie può richiedere un intervallo; un permesso tutelato può dipendere da un limite caricato dal profilo. Il primo errore da evitare è mostrare tutti i campi contemporaneamente. Il secondo è calcolare la validità direttamente nel componente grafico, dove diventa difficile da testare.


Il flusso di LeavesRequestFormScreen

La schermata recupera i parametri dalla rotta:

export default function LeavesRequestFormScreen({ route }) {
  const { autoOpenSelector, configData } = route.params || {};
  // stato locale e hook del form
}

Poi chiama useLeavesRequestForm, e riceve stato, callback e configurazioni:

const {
  tipoRichiesta,
  note,
  setNote,
  isFormValid,
  setIsFormValid,
  isSending,
  successModalVisible,
  handleSend,
  handleTipoRichiestaConfirm,
} = useLeavesRequestForm({
  autoOpenSelector,
  configData,
  navigate,
  goBack,
});

La schermata coordina il flusso, mentre i componenti figli comunicano validità e dati. Il pulsante viene mostrato solo quando il tipo è presente, il form è valido, e l’oggetto di supporto è coerente.

Questa è una barriera UX, non una misura di sicurezza: il server deve validare nuovamente la richiesta.


Selezionare la causale

TipoRichiestaSelector deve essere il primo passo del flusso. Il suo valore non serve solo a cambiare un’etichetta: decide quale form montare e quali regole applicare. Un cambio causale dovrebbe valutare se cancellare le date precedenti, cancellare ore e ora di fine, azzerare errori del vecchio form, ricalcolare il limite, o richiedere nuove opzioni al backend. Se l’utente passa da ferie a permesso, e il sistema conserva ore nascoste, il payload può contenere dati incoerenti.

Il selector non deve conoscere il backend: le opzioni arrivano dal contratto onGetTypeSelectorOptions attraverso il Context.


Separare stato, calcoli e rendering

Nel form convivono tre categorie di logica: lo stato — date selezionate, ore, note, causale, dipendente —, i calcoli — durata, ora di fine, massimo giornaliero, giorni validi —, e il rendering — calendari, picker, messaggi, pulsanti, modali. Separarle rende molto più semplice capire perché un form non è valido.

Gli hook presenti nel package seguono questa direzione: usePermessiFormState gestisce i valori, useTimeCalculations calcola l’ora finale, usePermessiValidation determina la validità, usePermessiApi prepara i dati collegati all’invio, e useLeavesRequestForm orchestra il flusso più ampio. Un componente grafico dovrebbe ricevere isValid e callback già pronti, non ricostruire tutte le regole da solo.


Validare i permessi

usePermessiValidation distingue giornata intera e richiesta oraria:

export function usePermessiValidation({
  fullDay,
  selectedDates,
  numOre,
  maxWorkhours,
  onValidityChange,
}) {
  useEffect(() => {
    let currentIsValid = false;

    if (fullDay) {
      currentIsValid = selectedDates.length === 1 && maxWorkhours !== 0;
    } else {
      const hasDates = selectedDates.length === 1;
      const hasHours = numOre !== '';
      const hasPositiveWorkhours = maxWorkhours !== 0;
      currentIsValid = hasDates && hasHours && hasPositiveWorkhours;
    }

    if (maxWorkhours === null && selectedDates.length > 0) {
      currentIsValid = false;
    }

    onValidityChange?.(currentIsValid);
  }, [selectedDates, fullDay, numOre, maxWorkhours, onValidityChange]);
}

Tre dettagli meritano attenzione. Il primo è che, nel flusso attuale, il permesso viene validato quando è selezionata una sola data: non bisogna applicare automaticamente la stessa regola alle ferie, che possono seguire un intervallo. Il secondo è che un limite pari a zero rende la richiesta non valida, e il messaggio dovrebbe spiegare che non risultano ore disponibili secondo la configurazione ricevuta. Il terzo, forse il più insidioso, è che null non significa zero: significa che il dato non è ancora disponibile. Il form resta invalido finché la configurazione non arriva, evitando così di inviare una richiesta mentre il sistema non sa quale limite applicare.


Calcolare ore e ora di fine

useTimeCalculations riceve ora di inizio e durata:

export function useTimeCalculations(oraInizio, numOre, setOraFine) {
  useEffect(() => {
    if (oraInizio && numOre !== '' && parseFloat(numOre) > 0) {
      const [h, m] = oraInizio.split(':').map(Number);
      const startMinutes = h * 60 + m;
      const durationMinutes = parseInt(numOre, 10);
      const totalMinutes = startMinutes + durationMinutes;
      const endHour = Math.floor(totalMinutes / 60) % 24;
      const endMinute = totalMinutes % 60;

      setOraFine(
        `${String(endHour).padStart(2, '0')}:${String(endMinute).padStart(2, '0')}`,
      );
    } else {
      setOraFine('');
    }
  }, [oraInizio, numOre, setOraFine]);
}

La logica è leggibile: converte l’orario in minuti, aggiunge la durata, e ricostruisce il formato HH:mm. C’è però un punto da chiarire prima della pubblicazione: il nome numOre suggerisce ore, mentre durationMinutes e parseInt indicano una durata trattata come intero di minuti. Se sono ammessi valori decimali, parseInt('1.5', 10) produce 1, e il risultato diventa errato.

È quindi necessario definire il contratto una volta per tutte: numOre è ore intere, oppure minuti, oppure ore decimali. UI, hook e backend devono usare la stessa convenzione — in un sistema reale è preferibile convertire tutto in minuti, o in una durata numerica con precisione documentata.

Il codice usa % 24 per mantenere l’ora nel formato giornaliero. Se il caso d’uso non permette di attraversare la mezzanotte, la validazione deve bloccare il valore e mostrare un messaggio; se lo permette, il payload deve conservare anche la data dell’ora finale.


Contare giorni e calendario

Il numero di giorni tra due date non coincide sempre con il numero di giorni di ferie da scalare. Occorre sapere se si contano giorni di calendario o giorni lavorativi, e come vengono gestiti weekend, festività, chiusure aziendali, turni, part-time e orario distribuito su giorni diversi. Una funzione concettuale può essere:

function countWorkingDays(start, end, calendar) {
  let total = 0;

  for (const day of eachDay(start, end)) {
    if (calendar.isWorkingDay(day)) total += 1;
  }

  return total;
}

Il calendario non deve essere ricreato nella schermata: deve arrivare da una configurazione affidabile, altrimenti il dispositivo potrebbe calcolare un numero diverso da quello del payroll. Le date senza orario dovrebbero essere trattate come date di calendario, non convertite inutilmente in UTC — una stringa come 2026-08-10 deve mantenere il significato di giornata aziendale, indipendentemente dal fuso del telefono. Il formato inviato al contratto deve essere stabile e documentato, normalmente YYYY-MM-DD per le date, e un formato esplicito per gli orari.


Creare una richiesta per un altro dipendente

La schermata supporta il caso del supervisore che crea una richiesta per un dipendente, e questo aggiunge una responsabilità importante: distinguere l’utente autenticato dal destinatario della richiesta. Il flusso dovrebbe mostrare EmployeePicker solo al ruolo autorizzato, richiedere la selezione prima di abilitare il form, caricare configurazione e limiti del dipendente selezionato, mostrare chiaramente per chi si sta creando la richiesta, inviare al backend l’identificativo corretto, e non modificare mai l’identità dell’utente autenticato.

Un errore di contesto qui è più grave di un semplice errore di UI: può creare una richiesta associata alla persona sbagliata.


Inviare il payload tramite contratto

Il package riceve onSendLeaveRequest dal LeavesContext. La schermata non deve conoscere l’endpoint:

const { onSendLeaveRequest } = useLeavesContext();

await onSendLeaveRequest(payload);

Nel contratto app la callback viene collegata al backend. Il payload dovrebbe contenere valori normalizzati:

const payload = {
  typeId: tipoRichiesta,
  startDate: formatDate(startDate),
  endDate: formatDate(endDate),
  startTime: oraInizio || null,
  endTime: oraFine || null,
  quantity: normalizedQuantity,
  note: note.trim(),
  employeeId: selectedEmployeeInfo?.userId ?? currentUserId,
};

Il server deve verificare nuovamente autorizzazioni, sovrapposizioni, saldo e regole. Dopo il successo, la schermata può mostrare LeaveSuccessModal, e aggiornare le liste o i contatori. Durante la costruzione del payload e l’invio bisogna disabilitare il form: un tap ripetuto può creare due richieste identiche, soprattutto su reti lente. Il backend dovrebbe comunque supportare idempotenza, o quantomeno riconoscere un duplicato, perché la sola disabilitazione client non è sufficiente.


Errori comuni

Validare nel componente grafico rende la logica difficile da testare, e finisce per duplicarsi tra ferie e permessi. Confondere null con zero è un errore concettuale: un limite non ancora caricato non significa che il lavoratore abbia zero ore. Usare parseInt per ore decimali tronca il valore, e produce un’ora finale errata. Usare il conteggio calendariale per ogni contratto ignora che weekend, turni e part-time possono modificare il risultato. Passare configurazione vecchia dopo il cambio dipendente rischia di applicare i limiti del supervisore, o del dipendente precedente. Considerare sufficiente la validazione client, infine, è l’errore più pericoloso di tutti: la risposta server deve restare sempre l’autorità finale.


Domande frequenti

La validazione client è sufficiente?

No. È un feedback immediato. Il server deve ricalcolare regole, saldo, autorizzazioni e sovrapposizioni.

Ore e giorni possono stare nello stesso campo?

È preferibile conservarli con una proprietà unit esplicita. La conversione implicita crea ambiguità.

Perché calcolare l’ora di fine in un hook?

Per tenere la logica fuori dal componente, e renderla riutilizzabile e testabile.

Come gestisco il cambio causale?

Azzerando o rivalidando i dati incompatibili con la nuova causale, senza lasciare valori nascosti nel payload.

Il dipendente può inviare una richiesta per un collega?

Solo quando il ruolo e il processo lo consentono. Il server deve comunque verificare l’autorizzazione.

Conclusione

Un form HR affidabile separa selezione, stato, calcoli, validazione e invio. LeavesRequestFormScreen orchestra il flusso; gli hook specializzati gestiscono le regole; il contratto collega il package al backend senza accoppiarlo alla shell.

Il risultato è più prevedibile per l’utente, e più semplice da verificare per il team. Soprattutto, rende visibili i punti che richiedono una decisione di dominio: unità, calendario, limiti e fonte del dato.

Nel prossimo articolo spieghiamo come gestire i contatori, non perdertelo!