29_timesheet-management #64
2 changed files with 544 additions and 0 deletions
542
src/timesheet/generator.rs
Normal file
542
src/timesheet/generator.rs
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{Datelike, NaiveDate, Weekday};
|
||||
|
||||
use crate::error::StreamdError;
|
||||
use crate::models::{SpecialDayType, Timesheet};
|
||||
|
||||
use super::config::{RepositoryConfig, TimesheetConfig};
|
||||
use super::report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport};
|
||||
use super::validation::find_overlapping_timecards;
|
||||
|
||||
/// Load repository configuration from .streamd.toml file.
|
||||
pub fn load_repository_config(base_folder: &Path) -> Result<RepositoryConfig, StreamdError> {
|
||||
let config_path = base_folder.join(".streamd.toml");
|
||||
|
||||
if !config_path.exists() {
|
||||
return Ok(RepositoryConfig::default());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)?;
|
||||
let config: RepositoryConfig = toml::from_str(&content)?;
|
||||
|
||||
// Validate timesheet config if present
|
||||
if let Some(ref timesheet) = config.timesheet {
|
||||
timesheet.validate()?;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Calculate total hours worked from timecards.
|
||||
fn calculate_timecard_hours(timesheet: &Timesheet) -> f64 {
|
||||
timesheet
|
||||
.timecards
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
let duration = tc.to_time - tc.from_time;
|
||||
duration.num_minutes() as f64 / 60.0
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Determine the day type based on timesheet data.
|
||||
fn determine_day_type(date: NaiveDate, timesheet: Option<&Timesheet>, has_period: bool) -> DayType {
|
||||
let weekday = date.weekday();
|
||||
|
||||
// Check weekend first
|
||||
if weekday == Weekday::Sat || weekday == Weekday::Sun {
|
||||
return DayType::Weekend;
|
||||
}
|
||||
|
||||
// Check if outside any period
|
||||
if !has_period {
|
||||
return DayType::OutsidePeriod;
|
||||
}
|
||||
|
||||
// Check special day types from timesheet
|
||||
if let Some(ts) = timesheet {
|
||||
if let Some(special) = ts.special_day_type {
|
||||
match special {
|
||||
SpecialDayType::Vacation => return DayType::Vacation,
|
||||
SpecialDayType::Holiday => return DayType::Holiday,
|
||||
SpecialDayType::Undertime => return DayType::FlexDay,
|
||||
SpecialDayType::Weekend => return DayType::Weekend,
|
||||
}
|
||||
}
|
||||
|
||||
if ts.is_sick_leave {
|
||||
return DayType::SickLeave;
|
||||
}
|
||||
|
||||
// Has timecards or special type
|
||||
return DayType::Regular;
|
||||
}
|
||||
|
||||
// No timesheet entry for a weekday = Missing
|
||||
DayType::Missing
|
||||
}
|
||||
|
||||
/// Calculate expected hours for a day based on period config and day type.
|
||||
fn calculate_expected_hours(day_type: DayType, hours_per_day: f64, _date: NaiveDate) -> f64 {
|
||||
match day_type {
|
||||
DayType::Regular => hours_per_day,
|
||||
DayType::SickLeave => hours_per_day,
|
||||
DayType::Vacation => hours_per_day,
|
||||
DayType::Holiday => 0.0,
|
||||
DayType::FlexDay => hours_per_day,
|
||||
DayType::Weekend => 0.0,
|
||||
DayType::Missing => hours_per_day,
|
||||
DayType::OutsidePeriod => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate actual hours for a day based on day type rules.
|
||||
fn calculate_actual_hours(day_type: DayType, timecard_hours: f64, expected_hours: f64) -> f64 {
|
||||
match day_type {
|
||||
DayType::Regular => timecard_hours,
|
||||
DayType::SickLeave => expected_hours.max(timecard_hours),
|
||||
DayType::Vacation => expected_hours + timecard_hours,
|
||||
DayType::Holiday => timecard_hours,
|
||||
DayType::FlexDay => 0.0,
|
||||
DayType::Weekend => timecard_hours,
|
||||
DayType::Missing => 0.0,
|
||||
DayType::OutsidePeriod => timecard_hours,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a timesheet report from timesheets and configuration.
|
||||
pub fn generate_report(
|
||||
timesheets: &[Timesheet],
|
||||
config: &TimesheetConfig,
|
||||
) -> Result<TimesheetReport, StreamdError> {
|
||||
if config.periods.is_empty() {
|
||||
return Ok(TimesheetReport::new());
|
||||
}
|
||||
|
||||
// Index timesheets by date for quick lookup
|
||||
let timesheets_by_date: HashMap<NaiveDate, &Timesheet> =
|
||||
timesheets.iter().map(|ts| (ts.date, ts)).collect();
|
||||
|
||||
// Find the date range to report on
|
||||
let earliest_period_start = config.periods.iter().map(|p| p.start).min().unwrap();
|
||||
let latest_period_end = config.periods.iter().map(|p| p.end).max().unwrap();
|
||||
|
||||
// Limit to today
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let end_date = latest_period_end.min(today);
|
||||
|
||||
// Group by month and generate reports
|
||||
let mut month_reports: Vec<MonthReport> = Vec::new();
|
||||
let mut all_warnings: Vec<ReportWarning> = Vec::new();
|
||||
let mut cumulative_balance: f64 = 0.0;
|
||||
|
||||
// Iterate through all dates in the range
|
||||
let mut current_date = earliest_period_start;
|
||||
let mut current_month: Option<(i32, u32)> = None;
|
||||
let mut current_month_days: Vec<DayReport> = Vec::new();
|
||||
|
||||
while current_date <= end_date {
|
||||
let year = current_date.year();
|
||||
let month = current_date.month();
|
||||
|
||||
// Check if we've moved to a new month
|
||||
if current_month != Some((year, month)) {
|
||||
// Save the previous month if it had days
|
||||
if let Some((prev_year, prev_month)) = current_month {
|
||||
if !current_month_days.is_empty() {
|
||||
let month_report =
|
||||
MonthReport::new(prev_year, prev_month).with_days(current_month_days);
|
||||
cumulative_balance += month_report.diff();
|
||||
month_reports.push(month_report);
|
||||
}
|
||||
}
|
||||
current_month = Some((year, month));
|
||||
current_month_days = Vec::new();
|
||||
}
|
||||
|
||||
// Find if this date falls within a period
|
||||
let period = config.find_period(current_date);
|
||||
let has_period = period.is_some();
|
||||
let hours_per_day = period.map(|p| p.hours_per_day()).unwrap_or(0.0);
|
||||
|
||||
// Get timesheet for this date
|
||||
let timesheet = timesheets_by_date.get(¤t_date).copied();
|
||||
let timecard_hours = timesheet.map(calculate_timecard_hours).unwrap_or(0.0);
|
||||
|
||||
// Determine day type
|
||||
let day_type = determine_day_type(current_date, timesheet, has_period);
|
||||
|
||||
// Skip weekends with no work and days outside periods with no work
|
||||
let should_include = match day_type {
|
||||
DayType::Weekend => timecard_hours > 0.0,
|
||||
DayType::OutsidePeriod => timecard_hours > 0.0,
|
||||
_ => has_period, // Only include days within periods
|
||||
};
|
||||
|
||||
if should_include {
|
||||
// Calculate expected and actual hours
|
||||
let expected_hours = calculate_expected_hours(day_type, hours_per_day, current_date);
|
||||
let actual_hours = calculate_actual_hours(day_type, timecard_hours, expected_hours);
|
||||
|
||||
let mut day_report =
|
||||
DayReport::new(current_date, expected_hours, actual_hours, day_type);
|
||||
|
||||
// Collect warnings
|
||||
let mut day_warnings: Vec<DayWarning> = Vec::new();
|
||||
|
||||
// Warning: Missing without explanation
|
||||
if day_type == DayType::Missing {
|
||||
day_warnings.push(DayWarning::MissingWithoutExplanation);
|
||||
all_warnings.push(ReportWarning::new(
|
||||
current_date,
|
||||
DayWarning::MissingWithoutExplanation,
|
||||
));
|
||||
}
|
||||
|
||||
// Warning: Overlapping timecards
|
||||
if let Some(ts) = timesheet {
|
||||
let overlaps = find_overlapping_timecards(&ts.timecards);
|
||||
for (first, second) in overlaps {
|
||||
let warning = DayWarning::OverlappingTimecards { first, second };
|
||||
day_warnings.push(warning.clone());
|
||||
all_warnings.push(ReportWarning::new(current_date, warning));
|
||||
}
|
||||
}
|
||||
|
||||
// Warning: Work outside period
|
||||
if day_type == DayType::OutsidePeriod && timecard_hours > 0.0 {
|
||||
let warning = DayWarning::OutsidePeriod {
|
||||
hours_worked: timecard_hours,
|
||||
};
|
||||
day_warnings.push(warning.clone());
|
||||
all_warnings.push(ReportWarning::new(current_date, warning));
|
||||
}
|
||||
|
||||
day_report = day_report.with_warnings(day_warnings);
|
||||
current_month_days.push(day_report);
|
||||
}
|
||||
|
||||
// Move to next day
|
||||
current_date = current_date.succ_opt().unwrap_or(current_date);
|
||||
if current_date == end_date && current_date <= latest_period_end {
|
||||
// We've hit end_date, process this day then stop
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last month
|
||||
if let Some((year, month)) = current_month {
|
||||
if !current_month_days.is_empty() {
|
||||
let month_report = MonthReport::new(year, month).with_days(current_month_days);
|
||||
cumulative_balance += month_report.diff();
|
||||
month_reports.push(month_report);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort months in descending order (most recent first)
|
||||
month_reports.sort_by(|a, b| {
|
||||
let a_date = (a.year, a.month);
|
||||
let b_date = (b.year, b.month);
|
||||
b_date.cmp(&a_date)
|
||||
});
|
||||
|
||||
Ok(TimesheetReport::new()
|
||||
.with_months(month_reports)
|
||||
.with_cumulative_balance(cumulative_balance)
|
||||
.with_warnings(all_warnings))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::Timecard;
|
||||
use crate::timesheet::Period;
|
||||
use chrono::NaiveTime;
|
||||
|
||||
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(year, month, day).unwrap()
|
||||
}
|
||||
|
||||
fn time(hour: u32, min: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(hour, min, 0).unwrap()
|
||||
}
|
||||
|
||||
fn make_timesheet(date: NaiveDate, cards: Vec<(u32, u32, u32, u32)>) -> Timesheet {
|
||||
Timesheet {
|
||||
date,
|
||||
is_sick_leave: false,
|
||||
special_day_type: None,
|
||||
timecards: cards
|
||||
.into_iter()
|
||||
.map(|(fh, fm, th, tm)| Timecard::new(time(fh, fm), time(th, tm)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_config(start: NaiveDate, end: NaiveDate, hours_per_week: f64) -> TimesheetConfig {
|
||||
TimesheetConfig {
|
||||
periods: vec![Period {
|
||||
start,
|
||||
end,
|
||||
hours_per_week,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_timecard_hours() {
|
||||
let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 12, 0), (13, 0, 17, 0)]);
|
||||
let hours = calculate_timecard_hours(&ts);
|
||||
assert!((hours - 7.0).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_timecard_hours_with_minutes() {
|
||||
let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 12, 30), (13, 0, 17, 15)]);
|
||||
let hours = calculate_timecard_hours(&ts);
|
||||
assert!((hours - 7.75).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_day_type_regular() {
|
||||
// Monday with timesheet
|
||||
let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]);
|
||||
let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true);
|
||||
assert_eq!(day_type, DayType::Regular);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_day_type_weekend() {
|
||||
// Saturday
|
||||
let day_type = determine_day_type(date(2026, 3, 7), None, true);
|
||||
assert_eq!(day_type, DayType::Weekend);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_day_type_missing() {
|
||||
// Monday with no timesheet
|
||||
let day_type = determine_day_type(date(2026, 3, 2), None, true);
|
||||
assert_eq!(day_type, DayType::Missing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_day_type_vacation() {
|
||||
let ts = Timesheet {
|
||||
date: date(2026, 3, 2),
|
||||
is_sick_leave: false,
|
||||
special_day_type: Some(SpecialDayType::Vacation),
|
||||
timecards: vec![],
|
||||
};
|
||||
let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true);
|
||||
assert_eq!(day_type, DayType::Vacation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_day_type_sick_leave() {
|
||||
let ts = Timesheet {
|
||||
date: date(2026, 3, 2),
|
||||
is_sick_leave: true,
|
||||
special_day_type: None,
|
||||
timecards: vec![],
|
||||
};
|
||||
let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true);
|
||||
assert_eq!(day_type, DayType::SickLeave);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_day_type_holiday() {
|
||||
let ts = Timesheet {
|
||||
date: date(2026, 3, 2),
|
||||
is_sick_leave: false,
|
||||
special_day_type: Some(SpecialDayType::Holiday),
|
||||
timecards: vec![],
|
||||
};
|
||||
let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true);
|
||||
assert_eq!(day_type, DayType::Holiday);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_day_type_flex_day() {
|
||||
let ts = Timesheet {
|
||||
date: date(2026, 3, 2),
|
||||
is_sick_leave: false,
|
||||
special_day_type: Some(SpecialDayType::Undertime),
|
||||
timecards: vec![],
|
||||
};
|
||||
let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true);
|
||||
assert_eq!(day_type, DayType::FlexDay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_day_type_outside_period() {
|
||||
let day_type = determine_day_type(date(2026, 3, 2), None, false);
|
||||
assert_eq!(day_type, DayType::OutsidePeriod);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expected_hours_regular() {
|
||||
let hours = calculate_expected_hours(DayType::Regular, 7.6, date(2026, 3, 2));
|
||||
assert!((hours - 7.6).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expected_hours_holiday() {
|
||||
let hours = calculate_expected_hours(DayType::Holiday, 7.6, date(2026, 3, 2));
|
||||
assert!((hours - 0.0).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expected_hours_weekend() {
|
||||
let hours = calculate_expected_hours(DayType::Weekend, 7.6, date(2026, 3, 7));
|
||||
assert!((hours - 0.0).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actual_hours_regular() {
|
||||
let hours = calculate_actual_hours(DayType::Regular, 8.0, 7.6);
|
||||
assert!((hours - 8.0).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actual_hours_sick_leave_max() {
|
||||
// Sick leave: max(expected, worked)
|
||||
let hours = calculate_actual_hours(DayType::SickLeave, 3.0, 7.6);
|
||||
assert!((hours - 7.6).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actual_hours_sick_leave_worked_more() {
|
||||
// Sick leave where worked > expected
|
||||
let hours = calculate_actual_hours(DayType::SickLeave, 9.0, 7.6);
|
||||
assert!((hours - 9.0).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actual_hours_vacation() {
|
||||
// Vacation: expected + worked
|
||||
let hours = calculate_actual_hours(DayType::Vacation, 2.0, 7.6);
|
||||
assert!((hours - 9.6).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actual_hours_flex_day() {
|
||||
// Flex day: always 0
|
||||
let hours = calculate_actual_hours(DayType::FlexDay, 5.0, 7.6);
|
||||
assert!((hours - 0.0).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_report_empty_config() {
|
||||
let config = TimesheetConfig { periods: vec![] };
|
||||
let report = generate_report(&[], &config).unwrap();
|
||||
assert!(report.months.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_report_single_day() {
|
||||
// Monday 2026-03-02
|
||||
let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])];
|
||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0);
|
||||
|
||||
let report = generate_report(×heets, &config).unwrap();
|
||||
|
||||
assert_eq!(report.months.len(), 1);
|
||||
assert_eq!(report.months[0].days.len(), 1);
|
||||
|
||||
let day = &report.months[0].days[0];
|
||||
assert_eq!(day.date, date(2026, 3, 2));
|
||||
assert!((day.expected_hours - 8.0).abs() < 0.0001);
|
||||
assert!((day.actual_hours - 8.0).abs() < 0.0001);
|
||||
assert_eq!(day.day_type, DayType::Regular);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_report_detects_missing_day() {
|
||||
// Period covers Mon-Tue, but only Mon has timesheet
|
||||
let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])];
|
||||
// March 2 is Monday, March 3 is Tuesday
|
||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0);
|
||||
|
||||
let report = generate_report(×heets, &config).unwrap();
|
||||
|
||||
assert_eq!(report.months[0].days.len(), 2);
|
||||
|
||||
// First day (Mon) should be regular
|
||||
assert_eq!(report.months[0].days[0].day_type, DayType::Regular);
|
||||
|
||||
// Second day (Tue) should be missing
|
||||
assert_eq!(report.months[0].days[1].day_type, DayType::Missing);
|
||||
assert!(report.months[0].days[1].has_warnings());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_report_weekend_excluded_if_no_work() {
|
||||
// Period covers Mon-Sun, but only Mon has timesheet
|
||||
let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])];
|
||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0);
|
||||
|
||||
let report = generate_report(×heets, &config).unwrap();
|
||||
|
||||
// Should only include Mon-Fri (5 days), not Sat-Sun
|
||||
let days = &report.months[0].days;
|
||||
for day in days {
|
||||
let weekday = day.date.weekday();
|
||||
assert!(weekday != Weekday::Sat && weekday != Weekday::Sun);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_report_weekend_included_if_work() {
|
||||
// Work on Saturday
|
||||
let timesheets = vec![
|
||||
make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]),
|
||||
make_timesheet(date(2026, 3, 7), vec![(10, 0, 14, 0)]), // Saturday
|
||||
];
|
||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0);
|
||||
|
||||
let report = generate_report(×heets, &config).unwrap();
|
||||
|
||||
// Should include Saturday
|
||||
let has_saturday = report.months[0]
|
||||
.days
|
||||
.iter()
|
||||
.any(|d| d.date == date(2026, 3, 7));
|
||||
assert!(has_saturday);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_report_overlapping_timecards_warning() {
|
||||
let ts = Timesheet {
|
||||
date: date(2026, 3, 2),
|
||||
is_sick_leave: false,
|
||||
special_day_type: None,
|
||||
timecards: vec![
|
||||
Timecard::new(time(9, 0), time(12, 30)),
|
||||
Timecard::new(time(12, 0), time(13, 0)),
|
||||
],
|
||||
};
|
||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0);
|
||||
|
||||
let report = generate_report(&[ts], &config).unwrap();
|
||||
|
||||
assert!(report.has_warnings());
|
||||
assert!(report.months[0].days[0].has_warnings());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_report_cumulative_balance() {
|
||||
// Two days: one with 8h (expected 8h), one with 10h (expected 8h)
|
||||
let timesheets = vec![
|
||||
make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]), // 8h
|
||||
make_timesheet(date(2026, 3, 3), vec![(8, 0, 18, 0)]), // 10h
|
||||
];
|
||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0);
|
||||
|
||||
let report = generate_report(×heets, &config).unwrap();
|
||||
|
||||
// Balance should be +2h (18h actual - 16h expected)
|
||||
assert!((report.cumulative_balance - 2.0).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
mod config;
|
||||
mod configuration;
|
||||
mod extract;
|
||||
mod generator;
|
||||
mod point_types;
|
||||
mod report;
|
||||
mod validation;
|
||||
|
|
@ -8,6 +9,7 @@ mod validation;
|
|||
pub use config::{Period, RepositoryConfig, TimesheetConfig};
|
||||
pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG};
|
||||
pub use extract::extract_timesheets;
|
||||
pub use generator::{generate_report, load_repository_config};
|
||||
pub use point_types::TimesheetPointType;
|
||||
pub use report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport};
|
||||
pub use validation::find_overlapping_timecards;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue