1use chrono::{Datelike, NaiveDate, Weekday};
60use rust_decimal::prelude::ToPrimitive;
61use rust_xlsxwriter::{
62 ConditionalFormatFormula, Format, FormatAlign, FormatBorder, Workbook, Worksheet,
63};
64use serde::{Deserialize, Serialize};
65use std::collections::HashMap;
66use utf8proj_core::{
67 Calendar, Diagnostic, DiagnosticCode, Project, RenderError, Renderer, Schedule, ScheduledTask,
68 Severity,
69};
70
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "lowercase")]
74pub enum ScheduleGranularity {
75 Daily,
77 #[default]
79 Weekly,
80}
81
82#[derive(Clone, Debug, Serialize, Deserialize)]
102pub struct ExcelConfig {
103 #[serde(default = "default_scale")]
105 pub scale: String,
106
107 #[serde(default = "default_currency")]
109 pub currency: String,
110
111 #[serde(default = "default_true")]
113 pub auto_fit: bool,
114
115 #[serde(default)]
117 pub weeks: Option<u32>,
118
119 #[serde(default)]
121 pub days: Option<u32>,
122
123 #[serde(default = "default_hours_per_day")]
125 pub hours_per_day: f64,
126
127 #[serde(default = "default_true")]
129 pub include_summary: bool,
130
131 #[serde(default = "default_true")]
133 pub show_dependencies: bool,
134}
135
136fn default_scale() -> String {
137 "weekly".to_string()
138}
139
140fn default_currency() -> String {
141 "EUR".to_string()
142}
143
144fn default_true() -> bool {
145 true
146}
147
148fn default_hours_per_day() -> f64 {
149 8.0
150}
151
152impl Default for ExcelConfig {
153 fn default() -> Self {
154 Self {
155 scale: default_scale(),
156 currency: default_currency(),
157 auto_fit: true,
158 weeks: None,
159 days: None,
160 hours_per_day: default_hours_per_day(),
161 include_summary: true,
162 show_dependencies: true,
163 }
164 }
165}
166
167impl ExcelConfig {
168 pub fn to_renderer(&self) -> ExcelRenderer {
170 let mut renderer = ExcelRenderer::new()
171 .currency(&self.currency)
172 .hours_per_day(self.hours_per_day);
173
174 if self.scale == "daily" {
176 renderer = renderer.daily();
177 if !self.auto_fit {
178 if let Some(days) = self.days {
179 renderer = renderer.days(days);
180 }
181 }
182 } else if !self.auto_fit {
183 if let Some(weeks) = self.weeks {
184 renderer = renderer.weeks(weeks);
185 }
186 }
187
188 if !self.auto_fit {
190 renderer = renderer.no_auto_fit();
191 }
192
193 if !self.include_summary {
195 renderer = renderer.no_summary();
196 }
197
198 if !self.show_dependencies {
199 renderer = renderer.no_dependencies();
200 }
201
202 renderer
203 }
204}
205
206#[derive(Clone, Debug)]
208pub struct ExcelRenderer {
209 pub currency: String,
211 pub schedule_weeks: u32,
213 pub hours_per_day: f64,
215 pub hours_per_week: f64,
217 pub include_summary: bool,
219 pub use_formulas: bool,
221 pub project_start: Option<NaiveDate>,
223 pub default_rate: f64,
225 pub show_dependencies: bool,
227 pub include_calendar_analysis: bool,
229 pub include_diagnostics: bool,
231 diagnostics: Vec<Diagnostic>,
233 pub granularity: ScheduleGranularity,
235 pub schedule_days: u32,
237 calendar: Option<Calendar>,
239 pub auto_fit: bool,
241}
242
243impl Default for ExcelRenderer {
244 fn default() -> Self {
245 Self {
246 currency: "€".into(),
247 schedule_weeks: 18,
248 hours_per_day: 8.0,
249 hours_per_week: 40.0,
250 include_summary: true,
251 use_formulas: true,
252 project_start: None,
253 default_rate: 400.0,
254 show_dependencies: true, include_calendar_analysis: false,
256 include_diagnostics: false,
257 diagnostics: Vec::new(),
258 granularity: ScheduleGranularity::Weekly,
259 schedule_days: 60,
260 calendar: None,
261 auto_fit: true, }
263 }
264}
265
266impl ExcelRenderer {
267 pub fn new() -> Self {
268 Self::default()
269 }
270
271 pub fn currency(mut self, currency: impl Into<String>) -> Self {
273 self.currency = currency.into();
274 self
275 }
276
277 pub fn weeks(mut self, weeks: u32) -> Self {
279 self.schedule_weeks = weeks;
280 self
281 }
282
283 pub fn hours_per_day(mut self, hours: f64) -> Self {
285 self.hours_per_day = hours;
286 self
287 }
288
289 pub fn no_summary(mut self) -> Self {
291 self.include_summary = false;
292 self
293 }
294
295 pub fn static_values(mut self) -> Self {
297 self.use_formulas = false;
298 self
299 }
300
301 pub fn default_rate(mut self, rate: f64) -> Self {
303 self.default_rate = rate;
304 self
305 }
306
307 pub fn no_dependencies(mut self) -> Self {
309 self.show_dependencies = false;
310 self
311 }
312
313 pub fn hours_per_week(mut self, hours: f64) -> Self {
315 self.hours_per_week = hours;
316 self
317 }
318
319 pub fn with_calendar_analysis(mut self) -> Self {
321 self.include_calendar_analysis = true;
322 self
323 }
324
325 pub fn with_diagnostics(mut self, diagnostics: Vec<Diagnostic>) -> Self {
327 self.include_diagnostics = true;
328 self.diagnostics = diagnostics;
329 self
330 }
331
332 pub fn daily(mut self) -> Self {
337 self.granularity = ScheduleGranularity::Daily;
338 self
339 }
340
341 pub fn days(mut self, days: u32) -> Self {
345 self.schedule_days = days;
346 self
347 }
348
349 pub fn with_calendar(mut self, calendar: Calendar) -> Self {
354 self.calendar = Some(calendar);
355 self
356 }
357
358 pub fn no_auto_fit(mut self) -> Self {
364 self.auto_fit = false;
365 self
366 }
367
368 pub fn calculate_auto_fit_weeks(&self, schedule: &Schedule, project_start: NaiveDate) -> u32 {
376 let max_finish = schedule
378 .tasks
379 .values()
380 .map(|t| t.finish)
381 .max()
382 .unwrap_or(project_start);
383
384 let effective_end = max_finish.max(schedule.project_end);
386
387 let days = (effective_end - project_start).num_days().max(0) as u32;
388 let weeks = (days + 6) / 7; let buffer = (weeks / 10).max(1); (weeks + buffer).max(1) }
392
393 pub fn calculate_auto_fit_days(&self, schedule: &Schedule, project_start: NaiveDate) -> u32 {
401 let max_finish = schedule
403 .tasks
404 .values()
405 .map(|t| t.finish)
406 .max()
407 .unwrap_or(project_start);
408
409 let effective_end = max_finish.max(schedule.project_end);
411
412 let days = (effective_end - project_start).num_days().max(0) as u32;
413 let buffer = (days / 10).max(5); (days + buffer).max(5) }
416
417 pub fn get_effective_weeks(&self, schedule: &Schedule, project_start: NaiveDate) -> u32 {
419 if self.auto_fit {
420 self.calculate_auto_fit_weeks(schedule, project_start)
421 } else {
422 self.schedule_weeks
423 }
424 }
425
426 pub fn get_effective_days(&self, schedule: &Schedule, project_start: NaiveDate) -> u32 {
428 if self.auto_fit {
429 self.calculate_auto_fit_days(schedule, project_start)
430 } else {
431 self.schedule_days
432 }
433 }
434
435 pub fn render_to_bytes(
437 &self,
438 project: &Project,
439 schedule: &Schedule,
440 ) -> Result<Vec<u8>, RenderError> {
441 let mut workbook = Workbook::new();
442
443 let formats = self.create_formats();
445
446 let resource_rates: HashMap<String, f64> = project
448 .resources
449 .iter()
450 .map(|r| {
451 let rate = r
452 .rate
453 .as_ref()
454 .and_then(|m| m.amount.to_f64())
455 .unwrap_or(self.default_rate);
456 (r.id.clone(), rate)
457 })
458 .collect();
459
460 let project_start = self.project_start.unwrap_or(project.start);
462
463 self.add_profiles_sheet(&mut workbook, project, schedule, &formats, &resource_rates)?;
465 self.add_schedule_sheet(&mut workbook, project, schedule, &formats, project_start)?;
466
467 if self.include_summary {
468 self.add_executive_summary(
469 &mut workbook,
470 project,
471 schedule,
472 &formats,
473 &resource_rates,
474 )?;
475 }
476
477 if self.include_calendar_analysis {
479 self.add_calendar_analysis_sheet(&mut workbook, project, schedule, &formats)?;
480 }
481
482 if self.include_diagnostics {
484 self.add_diagnostics_sheet(&mut workbook, project, &formats)?;
485 }
486
487 let buffer = workbook
489 .save_to_buffer()
490 .map_err(|e| RenderError::Format(format!("Failed to create Excel: {e}")))?;
491
492 Ok(buffer)
493 }
494
495 fn create_formats(&self) -> ExcelFormats {
497 let header = Format::new()
498 .set_bold()
499 .set_align(FormatAlign::Center)
500 .set_background_color(0x4472C4)
501 .set_font_color(0xFFFFFF)
502 .set_border(FormatBorder::Thin);
503
504 let currency = Format::new()
505 .set_num_format(&format!("#,##0.00 \"{}\"", self.currency))
506 .set_border(FormatBorder::Thin);
507
508 let number = Format::new()
509 .set_num_format("#,##0.0")
510 .set_border(FormatBorder::Thin);
511
512 let integer = Format::new()
513 .set_num_format("#,##0")
514 .set_border(FormatBorder::Thin);
515
516 let text = Format::new().set_border(FormatBorder::Thin);
517
518 let week_header = Format::new()
519 .set_bold()
520 .set_align(FormatAlign::Center)
521 .set_rotation(90)
522 .set_background_color(0x4472C4)
523 .set_font_color(0xFFFFFF)
524 .set_border(FormatBorder::Thin);
525
526 let total_row = Format::new()
527 .set_bold()
528 .set_background_color(0xE2EFDA)
529 .set_border(FormatBorder::Thin);
530
531 let total_currency = Format::new()
532 .set_bold()
533 .set_num_format(&format!("#,##0.00 \"{}\"", self.currency))
534 .set_background_color(0xE2EFDA)
535 .set_border(FormatBorder::Thin);
536
537 let row_even_text = Format::new().set_border(FormatBorder::Thin);
539
540 let row_even_number = Format::new()
541 .set_num_format("#,##0.0")
542 .set_border(FormatBorder::Thin);
543
544 let row_odd_text = Format::new()
545 .set_background_color(0xDDEBF7) .set_border(FormatBorder::Thin);
547
548 let row_odd_number = Format::new()
549 .set_num_format("#,##0.0")
550 .set_background_color(0xDDEBF7) .set_border(FormatBorder::Thin);
552
553 let milestone_text = Format::new()
555 .set_background_color(0xFFF2CC) .set_border(FormatBorder::Thin);
557
558 let milestone_number = Format::new()
559 .set_num_format("#,##0.0")
560 .set_background_color(0xFFF2CC) .set_border(FormatBorder::Thin);
562
563 let milestone_week = Format::new()
565 .set_align(FormatAlign::Center)
566 .set_background_color(0xFFE699) .set_border(FormatBorder::Thin);
568
569 let container_even_text = Format::new().set_bold().set_border(FormatBorder::Thin);
571
572 let container_odd_text = Format::new()
573 .set_bold()
574 .set_background_color(0xDDEBF7) .set_border(FormatBorder::Thin);
576
577 let gantt_even_empty = Format::new()
580 .set_align(FormatAlign::Center)
581 .set_border(FormatBorder::Thin); let gantt_odd_empty = Format::new()
584 .set_background_color(0xDDEBF7) .set_align(FormatAlign::Center)
586 .set_border(FormatBorder::Thin);
587
588 let weekend_header = Format::new()
590 .set_bold()
591 .set_align(FormatAlign::Center)
592 .set_background_color(0xA6A6A6) .set_font_color(0xFFFFFF)
594 .set_border(FormatBorder::Thin);
595
596 let weekend_cell = Format::new()
597 .set_align(FormatAlign::Center)
598 .set_background_color(0xD9D9D9) .set_border(FormatBorder::Thin);
600
601 let holiday_header = Format::new()
603 .set_bold()
604 .set_align(FormatAlign::Center)
605 .set_background_color(0xED7D31) .set_font_color(0xFFFFFF)
607 .set_border(FormatBorder::Thin);
608
609 let holiday_cell = Format::new()
610 .set_align(FormatAlign::Center)
611 .set_background_color(0xFCE4D6) .set_border(FormatBorder::Thin);
613
614 ExcelFormats {
615 header,
616 currency,
617 number,
618 integer,
619 text,
620 week_header,
621 total_row,
622 total_currency,
623 row_even_text,
624 row_even_number,
625 row_odd_text,
626 row_odd_number,
627 milestone_text,
628 milestone_number,
629 milestone_week,
630 container_even_text,
631 container_odd_text,
632 gantt_even_empty,
633 gantt_odd_empty,
634 weekend_header,
635 weekend_cell,
636 holiday_header,
637 holiday_cell,
638 }
639 }
640
641 fn add_profiles_sheet(
643 &self,
644 workbook: &mut Workbook,
645 project: &Project,
646 schedule: &Schedule,
647 formats: &ExcelFormats,
648 resource_rates: &HashMap<String, f64>,
649 ) -> Result<(), RenderError> {
650 let sheet = workbook.add_worksheet();
651 sheet
652 .set_name("Profiles and Costs")
653 .map_err(|e| RenderError::Format(e.to_string()))?;
654
655 let headers = [
657 "Profile ID",
658 "Profile",
659 &format!("Rate {}/d", self.currency),
660 "Days (pd)",
661 &format!("Cost {}", self.currency),
662 ];
663
664 for (col, header) in headers.iter().enumerate() {
665 sheet
666 .write_with_format(0, col as u16, *header, &formats.header)
667 .map_err(|e| RenderError::Format(e.to_string()))?;
668 }
669
670 sheet.set_column_width(0, 12).ok();
672 sheet.set_column_width(1, 30).ok();
673 sheet.set_column_width(2, 12).ok();
674 sheet.set_column_width(3, 10).ok();
675 sheet.set_column_width(4, 15).ok();
676
677 let mut resource_effort: HashMap<String, f64> = HashMap::new();
680 for scheduled in schedule.tasks.values() {
681 for assignment in &scheduled.assignments {
682 let effort = if let Some(effort_days) = assignment.effort_days {
683 effort_days
684 } else {
685 let assignment_days = (assignment.finish - assignment.start).num_days() as f64;
686 assignment_days * assignment.units as f64
687 };
688 *resource_effort
689 .entry(assignment.resource_id.clone())
690 .or_default() += effort;
691 }
692 }
693
694 let mut row = 1u32;
696 let mut total_cost = 0.0;
697
698 for resource in &project.resources {
699 let rate = resource_rates
700 .get(&resource.id)
701 .copied()
702 .unwrap_or(self.default_rate);
703 let days = resource_effort.get(&resource.id).copied().unwrap_or(0.0);
704 let cost = rate * days;
705 total_cost += cost;
706
707 sheet
708 .write_with_format(row, 0, &resource.id, &formats.text)
709 .map_err(|e| RenderError::Format(e.to_string()))?;
710 sheet
711 .write_with_format(row, 1, &resource.name, &formats.text)
712 .map_err(|e| RenderError::Format(e.to_string()))?;
713 sheet
714 .write_with_format(row, 2, rate, &formats.currency)
715 .map_err(|e| RenderError::Format(e.to_string()))?;
716 sheet
717 .write_with_format(row, 3, days, &formats.number)
718 .map_err(|e| RenderError::Format(e.to_string()))?;
719
720 if self.use_formulas {
721 let formula = format!("=C{}*D{}", row + 1, row + 1);
722 sheet
723 .write_formula_with_format(row, 4, formula.as_str(), &formats.currency)
724 .map_err(|e| RenderError::Format(e.to_string()))?;
725 } else {
726 sheet
727 .write_with_format(row, 4, cost, &formats.currency)
728 .map_err(|e| RenderError::Format(e.to_string()))?;
729 }
730
731 row += 1;
732 }
733
734 sheet
736 .write_with_format(row, 0, "TOTAL", &formats.total_row)
737 .map_err(|e| RenderError::Format(e.to_string()))?;
738 sheet
739 .write_with_format(row, 1, "", &formats.total_row)
740 .map_err(|e| RenderError::Format(e.to_string()))?;
741 sheet
742 .write_with_format(row, 2, "", &formats.total_row)
743 .map_err(|e| RenderError::Format(e.to_string()))?;
744
745 if self.use_formulas && row > 1 {
746 let sum_days = format!("=SUM(D2:D{})", row);
747 sheet
748 .write_formula_with_format(row, 3, sum_days.as_str(), &formats.total_row)
749 .map_err(|e| RenderError::Format(e.to_string()))?;
750 let sum_cost = format!("=SUM(E2:E{})", row);
751 sheet
752 .write_formula_with_format(row, 4, sum_cost.as_str(), &formats.total_currency)
753 .map_err(|e| RenderError::Format(e.to_string()))?;
754 } else {
755 let total_days: f64 = resource_effort.values().sum();
756 sheet
757 .write_with_format(row, 3, total_days, &formats.total_row)
758 .map_err(|e| RenderError::Format(e.to_string()))?;
759 sheet
760 .write_with_format(row, 4, total_cost, &formats.total_currency)
761 .map_err(|e| RenderError::Format(e.to_string()))?;
762 }
763
764 Ok(())
765 }
766
767 fn add_schedule_sheet(
769 &self,
770 workbook: &mut Workbook,
771 project: &Project,
772 schedule: &Schedule,
773 formats: &ExcelFormats,
774 project_start: NaiveDate,
775 ) -> Result<(), RenderError> {
776 match self.granularity {
778 ScheduleGranularity::Daily => {
779 return self.add_daily_schedule_sheet(
780 workbook,
781 project,
782 schedule,
783 formats,
784 project_start,
785 );
786 }
787 ScheduleGranularity::Weekly => {
788 }
790 }
791
792 let effective_weeks = self.get_effective_weeks(schedule, project_start);
794
795 let sheet = workbook.add_worksheet();
796 sheet
797 .set_name("Schedule")
798 .map_err(|e| RenderError::Format(e.to_string()))?;
799
800 let (week_start_col, effort_col, start_col, end_col) = if self.show_dependencies {
808 self.write_schedule_headers_with_deps(sheet, formats)?;
809 (11u16, 8u16, 9u16, 10u16) } else {
811 self.write_schedule_headers_simple(sheet, formats)?;
812 (7u16, 4u16, 5u16, 6u16) };
814
815 for week in 1..=effective_weeks {
817 let col = week_start_col + (week - 1) as u16;
818 sheet
819 .write_with_format(0, col, week as f64, &formats.week_header)
820 .map_err(|e| RenderError::Format(e.to_string()))?;
821 sheet.set_column_width(col, 4).ok();
822 }
823
824 sheet.set_row_height(0, 50).ok();
826
827 let wbs_order = Self::collect_wbs_order(&project.tasks, 0);
829
830 let tasks: Vec<(&ScheduledTask, usize)> = wbs_order
832 .iter()
833 .filter_map(|(task_id, level)| schedule.tasks.get(task_id).map(|st| (st, *level)))
834 .collect();
835
836 let all_full_ids: std::collections::HashSet<String> =
838 tasks.iter().map(|(st, _)| st.task_id.clone()).collect();
839
840 let simple_to_full_id: HashMap<String, String> = tasks
843 .iter()
844 .map(|(st, _)| {
845 let simple = st
846 .task_id
847 .rsplit('.')
848 .next()
849 .unwrap_or(&st.task_id)
850 .to_string();
851 (simple, st.task_id.clone())
852 })
853 .collect();
854
855 let mut task_row_map: HashMap<String, u32> = HashMap::new();
857 let mut current_row = 2u32; for (scheduled, _level) in &tasks {
859 task_row_map.insert(scheduled.task_id.clone(), current_row);
860 if scheduled.assignments.is_empty() {
861 current_row += 1;
862 } else {
863 current_row += scheduled.assignments.len() as u32;
864 }
865 }
866 let last_data_row = current_row - 1;
867
868 let mut row = 1u32;
870 let mut prev_task_id = String::new();
871 let mut is_odd = false;
872 for (scheduled, level) in &tasks {
873 if scheduled.task_id != prev_task_id {
875 is_odd = !is_odd;
876 prev_task_id = scheduled.task_id.clone();
877 }
878 let simple_id = scheduled
880 .task_id
881 .rsplit('.')
882 .next()
883 .unwrap_or(&scheduled.task_id);
884 let task = project.get_task(simple_id);
885
886 let is_container = task.map(|t| !t.children.is_empty()).unwrap_or(false);
890
891 let is_milestone = task.map(|t| t.milestone).unwrap_or(false);
893
894 let base_name = task
896 .map(|t| t.name.clone())
897 .unwrap_or_else(|| simple_id.to_string());
898 let indent = " ".repeat(*level);
899 let task_name = format!("{}{}", indent, base_name);
900
901 let (predecessor, dep_type, lag) = task
904 .and_then(|t| t.depends.first())
905 .map(|d| {
906 use utf8proj_core::DependencyType;
907 let dep_type = match d.dep_type {
908 DependencyType::StartToStart => "SS",
909 DependencyType::FinishToFinish => "FF",
910 DependencyType::StartToFinish => "SF",
911 DependencyType::FinishToStart => "FS",
912 };
913 let lag_days = d.lag.map(|l| l.as_days() as i32).unwrap_or(0);
914 let full_predecessor = if all_full_ids.contains(&d.predecessor) {
918 d.predecessor.clone()
920 } else if let Some(full) = simple_to_full_id.get(&d.predecessor) {
921 full.clone()
923 } else {
924 all_full_ids
926 .iter()
927 .find(|full_id| {
928 full_id.ends_with(&format!(".{}", d.predecessor))
929 || full_id.ends_with(&d.predecessor)
930 })
931 .cloned()
932 .unwrap_or_else(|| d.predecessor.clone())
933 };
934 (full_predecessor, dep_type, lag_days)
935 })
936 .unwrap_or_default();
937
938 let start_week = self.date_to_week(scheduled.start, project_start);
940 let end_week = self.date_to_week(scheduled.finish, project_start);
941
942 let duration_days = if is_container {
945 0.0
946 } else {
947 scheduled.duration.as_days()
948 };
949
950 if scheduled.assignments.is_empty() {
952 if self.show_dependencies {
953 self.write_schedule_row_with_deps(
954 sheet,
955 row,
956 &scheduled.task_id,
957 &task_name,
958 *level,
959 "",
960 &predecessor,
961 dep_type,
962 lag,
963 duration_days,
964 start_week,
965 end_week,
966 scheduled.is_critical,
967 is_milestone,
968 is_container,
969 formats,
970 week_start_col,
971 effort_col,
972 start_col,
973 end_col,
974 last_data_row,
975 is_odd,
976 effective_weeks,
977 )?;
978 } else {
979 self.write_schedule_row_simple(
980 sheet,
981 row,
982 &task_name,
983 *level,
984 "",
985 duration_days,
986 start_week,
987 end_week,
988 scheduled.is_critical,
989 is_milestone,
990 is_container,
991 formats,
992 week_start_col,
993 effort_col,
994 start_col,
995 end_col,
996 is_odd,
997 effective_weeks,
998 )?;
999 }
1000 row += 1;
1001 } else {
1002 let mut first_assignment = true;
1004 for assignment in &scheduled.assignments {
1005 let effort = if let Some(effort_days) = assignment.effort_days {
1007 effort_days
1008 } else {
1009 let assignment_days =
1010 (assignment.finish - assignment.start).num_days() as f64;
1011 assignment_days * assignment.units as f64
1012 };
1013
1014 let (pred, dtype, lag_val) = if first_assignment {
1016 (predecessor.clone(), dep_type, lag)
1017 } else {
1018 (String::new(), "", 0)
1019 };
1020
1021 if self.show_dependencies {
1022 self.write_schedule_row_with_deps(
1023 sheet,
1024 row,
1025 &scheduled.task_id,
1026 &task_name,
1027 *level,
1028 &assignment.resource_id,
1029 &pred,
1030 dtype,
1031 lag_val,
1032 effort,
1033 start_week,
1034 end_week,
1035 scheduled.is_critical,
1036 is_milestone,
1037 is_container,
1038 formats,
1039 week_start_col,
1040 effort_col,
1041 start_col,
1042 end_col,
1043 last_data_row,
1044 is_odd,
1045 effective_weeks,
1046 )?;
1047 } else {
1048 self.write_schedule_row_simple(
1049 sheet,
1050 row,
1051 &task_name,
1052 *level,
1053 &assignment.resource_id,
1054 effort,
1055 start_week,
1056 end_week,
1057 scheduled.is_critical,
1058 is_milestone,
1059 is_container,
1060 formats,
1061 week_start_col,
1062 effort_col,
1063 start_col,
1064 end_col,
1065 is_odd,
1066 effective_weeks,
1067 )?;
1068 }
1069 first_assignment = false;
1070 row += 1;
1071 }
1072 }
1073 }
1074
1075 self.write_schedule_totals(
1077 sheet,
1078 row,
1079 week_start_col,
1080 effort_col,
1081 formats,
1082 effective_weeks,
1083 )?;
1084
1085 let last_week_col = week_start_col + effective_weeks as u16 - 1;
1089 let last_data_row_for_cf = row - 1; if last_data_row_for_cf >= 1 {
1091 let gantt_filled_format = Format::new()
1093 .set_background_color(0x5B9BD5) .set_align(FormatAlign::Center)
1095 .set_border(FormatBorder::Thin);
1096
1097 let first_week_col_letter = Self::col_to_letter(week_start_col);
1100 let formula = format!(
1101 "=AND(ISNUMBER({}2),{}2>0)",
1102 first_week_col_letter, first_week_col_letter
1103 );
1104 let conditional_format = ConditionalFormatFormula::new()
1105 .set_rule(formula.as_str())
1106 .set_format(gantt_filled_format);
1107
1108 sheet
1110 .add_conditional_format(
1111 1, week_start_col,
1113 last_data_row_for_cf,
1114 last_week_col,
1115 &conditional_format,
1116 )
1117 .map_err(|e| RenderError::Format(e.to_string()))?;
1118 }
1119
1120 let freeze_cols = if self.show_dependencies { 10 } else { 6 };
1122 sheet.set_freeze_panes(1, freeze_cols).ok();
1123
1124 Ok(())
1125 }
1126
1127 fn add_daily_schedule_sheet(
1129 &self,
1130 workbook: &mut Workbook,
1131 project: &Project,
1132 schedule: &Schedule,
1133 formats: &ExcelFormats,
1134 project_start: NaiveDate,
1135 ) -> Result<(), RenderError> {
1136 let sheet = workbook.add_worksheet();
1137 sheet
1138 .set_name("Schedule")
1139 .map_err(|e| RenderError::Format(e.to_string()))?;
1140
1141 let calendar = self.calendar.clone().unwrap_or_else(|| {
1143 project.calendars.first().cloned().unwrap_or_default()
1145 });
1146
1147 let day_start_col = 11u16;
1150 let effort_col = 8u16;
1151 let start_col = 9u16;
1152 let end_col = 10u16;
1153
1154 self.write_daily_schedule_headers(sheet, formats, project_start, &calendar)?;
1156
1157 let wbs_order = Self::collect_wbs_order(&project.tasks, 0);
1159 let tasks: Vec<(&ScheduledTask, usize)> = wbs_order
1160 .iter()
1161 .filter_map(|(task_id, level)| schedule.tasks.get(task_id).map(|st| (st, *level)))
1162 .collect();
1163
1164 let all_full_ids: std::collections::HashSet<String> =
1166 tasks.iter().map(|(st, _)| st.task_id.clone()).collect();
1167
1168 let simple_to_full_id: HashMap<String, String> = tasks
1169 .iter()
1170 .map(|(st, _)| {
1171 let simple = st
1172 .task_id
1173 .rsplit('.')
1174 .next()
1175 .unwrap_or(&st.task_id)
1176 .to_string();
1177 (simple, st.task_id.clone())
1178 })
1179 .collect();
1180
1181 let _last_data_row: u32 = tasks
1183 .iter()
1184 .map(|(st, _)| {
1185 if st.assignments.is_empty() {
1186 1
1187 } else {
1188 st.assignments.len() as u32
1189 }
1190 })
1191 .sum::<u32>()
1192 + 1;
1193
1194 let mut row = 1u32;
1196 let mut prev_task_id = String::new();
1197 let mut is_odd = false;
1198
1199 for (scheduled, level) in &tasks {
1200 if scheduled.task_id != prev_task_id {
1202 is_odd = !is_odd;
1203 prev_task_id = scheduled.task_id.clone();
1204 }
1205
1206 let simple_id = scheduled
1207 .task_id
1208 .rsplit('.')
1209 .next()
1210 .unwrap_or(&scheduled.task_id);
1211 let task = project.get_task(simple_id);
1212 let is_container = task.map(|t| !t.children.is_empty()).unwrap_or(false);
1213 let is_milestone = task.map(|t| t.milestone).unwrap_or(false);
1214
1215 let base_name = task
1216 .map(|t| t.name.clone())
1217 .unwrap_or_else(|| simple_id.to_string());
1218 let indent = " ".repeat(*level);
1219 let task_name = format!("{}{}", indent, base_name);
1220
1221 let (predecessor, dep_type, lag) = task
1223 .and_then(|t| t.depends.first())
1224 .map(|d| {
1225 use utf8proj_core::DependencyType;
1226 let dep_type = match d.dep_type {
1227 DependencyType::StartToStart => "SS",
1228 DependencyType::FinishToFinish => "FF",
1229 DependencyType::StartToFinish => "SF",
1230 DependencyType::FinishToStart => "FS",
1231 };
1232 let lag_days = d.lag.map(|l| l.as_days() as i32).unwrap_or(0);
1233 let full_predecessor = if all_full_ids.contains(&d.predecessor) {
1234 d.predecessor.clone()
1235 } else if let Some(full) = simple_to_full_id.get(&d.predecessor) {
1236 full.clone()
1237 } else {
1238 all_full_ids
1239 .iter()
1240 .find(|full_id| {
1241 full_id.ends_with(&format!(".{}", d.predecessor))
1242 || full_id.ends_with(&d.predecessor)
1243 })
1244 .cloned()
1245 .unwrap_or_else(|| d.predecessor.clone())
1246 };
1247 (full_predecessor, dep_type, lag_days)
1248 })
1249 .unwrap_or_default();
1250
1251 let duration_days = if is_container {
1252 0.0
1253 } else {
1254 scheduled.duration.as_days()
1255 };
1256
1257 if scheduled.assignments.is_empty() {
1258 self.write_daily_schedule_row(
1259 sheet,
1260 row,
1261 &scheduled.task_id,
1262 &task_name,
1263 *level,
1264 "",
1265 &predecessor,
1266 dep_type,
1267 lag,
1268 duration_days,
1269 scheduled.start,
1270 scheduled.finish,
1271 scheduled.is_critical,
1272 is_milestone,
1273 is_container,
1274 formats,
1275 day_start_col,
1276 effort_col,
1277 start_col,
1278 end_col,
1279 project_start,
1280 &calendar,
1281 is_odd,
1282 )?;
1283 row += 1;
1284 } else {
1285 let mut first_assignment = true;
1286 for assignment in &scheduled.assignments {
1287 let effort = assignment.effort_days.unwrap_or_else(|| {
1288 let assignment_days =
1289 (assignment.finish - assignment.start).num_days() as f64;
1290 assignment_days * assignment.units as f64
1291 });
1292
1293 let (pred, dtype, lag_val) = if first_assignment {
1294 (predecessor.clone(), dep_type, lag)
1295 } else {
1296 (String::new(), "", 0)
1297 };
1298
1299 self.write_daily_schedule_row(
1300 sheet,
1301 row,
1302 &scheduled.task_id,
1303 &task_name,
1304 *level,
1305 &assignment.resource_id,
1306 &pred,
1307 dtype,
1308 lag_val,
1309 effort,
1310 scheduled.start,
1311 scheduled.finish,
1312 scheduled.is_critical,
1313 is_milestone,
1314 is_container,
1315 formats,
1316 day_start_col,
1317 effort_col,
1318 start_col,
1319 end_col,
1320 project_start,
1321 &calendar,
1322 is_odd,
1323 )?;
1324 first_assignment = false;
1325 row += 1;
1326 }
1327 }
1328 }
1329
1330 let last_day_col = day_start_col + self.schedule_days as u16 - 1;
1332 let last_data_row_for_cf = row - 1;
1333 if last_data_row_for_cf >= 1 {
1334 let gantt_filled_format = Format::new()
1335 .set_background_color(0x5B9BD5)
1336 .set_align(FormatAlign::Center)
1337 .set_border(FormatBorder::Thin);
1338
1339 let first_day_col_letter = Self::col_to_letter(day_start_col);
1340 let formula = format!(
1341 "=AND(ISNUMBER({}2),{}2>0)",
1342 first_day_col_letter, first_day_col_letter
1343 );
1344 let conditional_format = ConditionalFormatFormula::new()
1345 .set_rule(formula.as_str())
1346 .set_format(gantt_filled_format);
1347
1348 sheet
1349 .add_conditional_format(
1350 1,
1351 day_start_col,
1352 last_data_row_for_cf,
1353 last_day_col,
1354 &conditional_format,
1355 )
1356 .map_err(|e| RenderError::Format(e.to_string()))?;
1357 }
1358
1359 sheet.set_freeze_panes(1, 10).ok();
1361
1362 Ok(())
1363 }
1364
1365 fn write_daily_schedule_headers(
1367 &self,
1368 sheet: &mut Worksheet,
1369 formats: &ExcelFormats,
1370 project_start: NaiveDate,
1371 calendar: &Calendar,
1372 ) -> Result<(), RenderError> {
1373 let headers = [
1375 "Task ID",
1376 "Activity",
1377 "Lvl",
1378 "M",
1379 "Profile",
1380 "Depends\nOn",
1381 "Type",
1382 "Lag\n(d)",
1383 "Effort\n(pd)",
1384 "Start",
1385 "End",
1386 ];
1387 for (col, header) in headers.iter().enumerate() {
1388 sheet
1389 .write_with_format(0, col as u16, *header, &formats.header)
1390 .map_err(|e| RenderError::Format(e.to_string()))?;
1391 }
1392
1393 sheet.set_column_width(0, 12).ok(); sheet.set_column_width(1, 25).ok(); sheet.set_column_width(2, 3).ok(); sheet.set_column_width(3, 3).ok(); sheet.set_column_width(4, 12).ok(); sheet.set_column_width(5, 10).ok(); sheet.set_column_width(6, 5).ok(); sheet.set_column_width(7, 5).ok(); sheet.set_column_width(8, 6).ok(); sheet.set_column_width(9, 8).ok(); sheet.set_column_width(10, 8).ok(); let day_start_col = 11u16;
1408 for day in 0..self.schedule_days {
1409 let col = day_start_col + day as u16;
1410 let date = project_start + chrono::Duration::days(day as i64);
1411
1412 let weekday_abbrev = match date.weekday() {
1414 Weekday::Mon => "M",
1415 Weekday::Tue => "T",
1416 Weekday::Wed => "W",
1417 Weekday::Thu => "T",
1418 Weekday::Fri => "F",
1419 Weekday::Sat => "S",
1420 Weekday::Sun => "S",
1421 };
1422 let header_text = format!("{}\n{}/{}", weekday_abbrev, date.day(), date.month());
1423
1424 let holiday = calendar.holidays.iter().find(|h| h.contains(date));
1426 let is_weekend = !calendar
1427 .working_days
1428 .contains(&(date.weekday().num_days_from_sunday() as u8));
1429
1430 let header_fmt = if holiday.is_some() {
1432 &formats.holiday_header
1433 } else if is_weekend {
1434 &formats.weekend_header
1435 } else {
1436 &formats.week_header
1437 };
1438
1439 sheet
1440 .write_with_format(0, col, &header_text, header_fmt)
1441 .map_err(|e| RenderError::Format(e.to_string()))?;
1442 sheet.set_column_width(col, 4).ok();
1443 }
1444
1445 sheet.set_row_height(0, 40).ok();
1447
1448 Ok(())
1449 }
1450
1451 #[allow(clippy::too_many_arguments)]
1453 fn write_daily_schedule_row(
1454 &self,
1455 sheet: &mut Worksheet,
1456 row: u32,
1457 task_id: &str,
1458 task_name: &str,
1459 level: usize,
1460 profile: &str,
1461 predecessor: &str,
1462 dep_type: &str,
1463 lag: i32,
1464 person_days: f64,
1465 task_start: NaiveDate,
1466 task_finish: NaiveDate,
1467 _is_critical: bool,
1468 is_milestone: bool,
1469 is_container: bool,
1470 formats: &ExcelFormats,
1471 day_start_col: u16,
1472 effort_col: u16,
1473 start_col: u16,
1474 end_col: u16,
1475 project_start: NaiveDate,
1476 calendar: &Calendar,
1477 is_odd: bool,
1478 ) -> Result<(), RenderError> {
1479 let (text_fmt, number_fmt) = if is_milestone {
1481 (&formats.milestone_text, &formats.milestone_number)
1482 } else if is_odd {
1483 (&formats.row_odd_text, &formats.row_odd_number)
1484 } else {
1485 (&formats.row_even_text, &formats.row_even_number)
1486 };
1487
1488 let activity_fmt = if is_container {
1489 if is_odd {
1490 &formats.container_odd_text
1491 } else {
1492 &formats.container_even_text
1493 }
1494 } else {
1495 text_fmt
1496 };
1497
1498 sheet
1500 .write_with_format(row, 0, task_id, text_fmt)
1501 .map_err(|e| RenderError::Format(e.to_string()))?;
1502
1503 sheet
1505 .write_with_format(row, 1, task_name, activity_fmt)
1506 .map_err(|e| RenderError::Format(e.to_string()))?;
1507
1508 sheet
1510 .write_with_format(row, 2, level as f64, number_fmt)
1511 .map_err(|e| RenderError::Format(e.to_string()))?;
1512
1513 let milestone_marker = if is_milestone { "◆" } else { "" };
1515 sheet
1516 .write_with_format(row, 3, milestone_marker, text_fmt)
1517 .map_err(|e| RenderError::Format(e.to_string()))?;
1518
1519 sheet
1521 .write_with_format(row, 4, profile, text_fmt)
1522 .map_err(|e| RenderError::Format(e.to_string()))?;
1523
1524 sheet
1526 .write_with_format(row, 5, predecessor, text_fmt)
1527 .map_err(|e| RenderError::Format(e.to_string()))?;
1528
1529 let dep_type_val = if predecessor.is_empty() { "" } else { dep_type };
1531 sheet
1532 .write_with_format(row, 6, dep_type_val, text_fmt)
1533 .map_err(|e| RenderError::Format(e.to_string()))?;
1534
1535 if !predecessor.is_empty() {
1537 sheet
1538 .write_with_format(row, 7, lag as f64, number_fmt)
1539 .map_err(|e| RenderError::Format(e.to_string()))?;
1540 } else {
1541 sheet
1542 .write_with_format(row, 7, "", text_fmt)
1543 .map_err(|e| RenderError::Format(e.to_string()))?;
1544 }
1545
1546 sheet
1548 .write_with_format(row, effort_col, person_days, number_fmt)
1549 .map_err(|e| RenderError::Format(e.to_string()))?;
1550
1551 let start_str = task_start.format("%d/%m").to_string();
1553 sheet
1554 .write_with_format(row, start_col, &start_str, text_fmt)
1555 .map_err(|e| RenderError::Format(e.to_string()))?;
1556
1557 let end_str = task_finish.format("%d/%m").to_string();
1559 sheet
1560 .write_with_format(row, end_col, &end_str, text_fmt)
1561 .map_err(|e| RenderError::Format(e.to_string()))?;
1562
1563 self.write_daily_columns(
1565 sheet,
1566 row,
1567 task_start,
1568 task_finish,
1569 is_milestone,
1570 is_container,
1571 is_odd,
1572 formats,
1573 day_start_col,
1574 project_start,
1575 calendar,
1576 person_days,
1577 )?;
1578
1579 Ok(())
1580 }
1581
1582 #[allow(clippy::too_many_arguments)]
1587 fn write_daily_columns(
1588 &self,
1589 sheet: &mut Worksheet,
1590 row: u32,
1591 task_start: NaiveDate,
1592 task_finish: NaiveDate,
1593 is_milestone: bool,
1594 is_container: bool,
1595 is_odd: bool,
1596 formats: &ExcelFormats,
1597 day_start_col: u16,
1598 project_start: NaiveDate,
1599 calendar: &Calendar,
1600 person_days: f64,
1601 ) -> Result<(), RenderError> {
1602 let total_hours = (person_days * self.hours_per_day).round() as u32;
1604 let working_days_count = self.count_working_days(task_start, task_finish, calendar);
1605
1606 let (base_hours, remainder) = if working_days_count > 0 {
1609 let base = total_hours / working_days_count;
1610 let rem = total_hours % working_days_count;
1611 (base, rem)
1612 } else {
1613 (0, 0)
1614 };
1615
1616 let mut working_day_index = 0u32;
1618
1619 for day in 0..self.schedule_days {
1620 let col = day_start_col + day as u16;
1621 let date = project_start + chrono::Duration::days(day as i64);
1622
1623 let holiday = calendar.holidays.iter().find(|h| h.contains(date));
1625 let is_weekend = !calendar
1626 .working_days
1627 .contains(&(date.weekday().num_days_from_sunday() as u8));
1628 let in_task_range = date >= task_start && date <= task_finish;
1629
1630 let cell_fmt = if holiday.is_some() {
1632 &formats.holiday_cell
1633 } else if is_weekend {
1634 &formats.weekend_cell
1635 } else if is_milestone {
1636 &formats.milestone_week
1637 } else if is_odd {
1638 &formats.gantt_odd_empty
1639 } else {
1640 &formats.gantt_even_empty
1641 };
1642
1643 if is_container {
1645 sheet
1646 .write_with_format(row, col, "", cell_fmt)
1647 .map_err(|e| RenderError::Format(e.to_string()))?;
1648 continue;
1649 }
1650
1651 if holiday.is_some() || is_weekend {
1653 sheet
1654 .write_with_format(row, col, "", cell_fmt)
1655 .map_err(|e| RenderError::Format(e.to_string()))?;
1656 continue;
1657 }
1658
1659 if in_task_range {
1661 if is_milestone {
1662 sheet
1663 .write_with_format(row, col, "◆", cell_fmt)
1664 .map_err(|e| RenderError::Format(e.to_string()))?;
1665 } else if total_hours > 0 {
1666 let hours = if working_day_index < remainder {
1668 base_hours + 1
1669 } else {
1670 base_hours
1671 };
1672 if hours > 0 {
1673 sheet
1674 .write_with_format(row, col, hours as f64, cell_fmt)
1675 .map_err(|e| RenderError::Format(e.to_string()))?;
1676 } else {
1677 sheet
1678 .write_with_format(row, col, "", cell_fmt)
1679 .map_err(|e| RenderError::Format(e.to_string()))?;
1680 }
1681 working_day_index += 1;
1682 } else {
1683 sheet
1684 .write_with_format(row, col, "", cell_fmt)
1685 .map_err(|e| RenderError::Format(e.to_string()))?;
1686 }
1687 } else {
1688 sheet
1689 .write_with_format(row, col, "", cell_fmt)
1690 .map_err(|e| RenderError::Format(e.to_string()))?;
1691 }
1692 }
1693
1694 Ok(())
1695 }
1696
1697 fn count_working_days(&self, start: NaiveDate, end: NaiveDate, calendar: &Calendar) -> u32 {
1699 let mut count = 0;
1700 let mut date = start;
1701 while date <= end {
1702 if calendar.is_working_day(date) {
1703 count += 1;
1704 }
1705 date += chrono::Duration::days(1);
1706 }
1707 count
1708 }
1709
1710 fn write_schedule_headers_simple(
1712 &self,
1713 sheet: &mut Worksheet,
1714 formats: &ExcelFormats,
1715 ) -> Result<(), RenderError> {
1716 let headers = [
1717 "Activity",
1718 "Lvl",
1719 "M",
1720 "Profile",
1721 "pd",
1722 "Start\nweek",
1723 "End\nweek",
1724 ];
1725 for (col, header) in headers.iter().enumerate() {
1726 sheet
1727 .write_with_format(0, col as u16, *header, &formats.header)
1728 .map_err(|e| RenderError::Format(e.to_string()))?;
1729 }
1730
1731 sheet.set_column_width(0, 25).ok(); sheet.set_column_width(1, 3).ok(); sheet.set_column_width(2, 3).ok(); sheet.set_column_width(3, 15).ok(); sheet.set_column_width(4, 6).ok(); sheet.set_column_width(5, 6).ok(); sheet.set_column_width(6, 6).ok(); Ok(())
1741 }
1742
1743 fn write_schedule_headers_with_deps(
1745 &self,
1746 sheet: &mut Worksheet,
1747 formats: &ExcelFormats,
1748 ) -> Result<(), RenderError> {
1749 let headers = [
1750 "Task ID",
1751 "Activity",
1752 "Lvl",
1753 "M",
1754 "Profile",
1755 "Depends\nOn",
1756 "Type",
1757 "Lag\n(d)",
1758 "Effort\n(pd)",
1759 "Start\nweek",
1760 "End\nweek",
1761 ];
1762 for (col, header) in headers.iter().enumerate() {
1763 sheet
1764 .write_with_format(0, col as u16, *header, &formats.header)
1765 .map_err(|e| RenderError::Format(e.to_string()))?;
1766 }
1767
1768 sheet.set_column_width(0, 12).ok(); sheet.set_column_width(1, 25).ok(); sheet.set_column_width(2, 3).ok(); sheet.set_column_width(3, 3).ok(); sheet.set_column_width(4, 12).ok(); sheet.set_column_width(5, 10).ok(); sheet.set_column_width(6, 5).ok(); sheet.set_column_width(7, 5).ok(); sheet.set_column_width(8, 7).ok(); sheet.set_column_width(9, 6).ok(); sheet.set_column_width(10, 6).ok(); Ok(())
1782 }
1783
1784 #[allow(clippy::too_many_arguments)]
1786 fn write_schedule_row_simple(
1787 &self,
1788 sheet: &mut Worksheet,
1789 row: u32,
1790 task_name: &str,
1791 level: usize,
1792 profile: &str,
1793 person_days: f64,
1794 start_week: u32,
1795 end_week: u32,
1796 is_critical: bool,
1797 is_milestone: bool,
1798 is_container: bool,
1799 formats: &ExcelFormats,
1800 week_start_col: u16,
1801 effort_col: u16,
1802 start_col: u16,
1803 end_col: u16,
1804 is_odd: bool,
1805 schedule_weeks: u32, ) -> Result<(), RenderError> {
1807 let (text_fmt, number_fmt) = if is_milestone {
1809 (&formats.milestone_text, &formats.milestone_number)
1810 } else if is_odd {
1811 (&formats.row_odd_text, &formats.row_odd_number)
1812 } else {
1813 (&formats.row_even_text, &formats.row_even_number)
1814 };
1815
1816 let activity_fmt = if is_container {
1818 if is_odd {
1819 &formats.container_odd_text
1820 } else {
1821 &formats.container_even_text
1822 }
1823 } else {
1824 text_fmt
1825 };
1826
1827 sheet
1829 .write_with_format(row, 0, task_name, activity_fmt)
1830 .map_err(|e| RenderError::Format(e.to_string()))?;
1831
1832 sheet
1834 .write_with_format(row, 1, level as f64, number_fmt)
1835 .map_err(|e| RenderError::Format(e.to_string()))?;
1836
1837 let milestone_marker = if is_milestone { "◆" } else { "" };
1839 sheet
1840 .write_with_format(row, 2, milestone_marker, text_fmt)
1841 .map_err(|e| RenderError::Format(e.to_string()))?;
1842
1843 sheet
1845 .write_with_format(row, 3, profile, text_fmt)
1846 .map_err(|e| RenderError::Format(e.to_string()))?;
1847
1848 sheet
1850 .write_with_format(row, effort_col, person_days, number_fmt)
1851 .map_err(|e| RenderError::Format(e.to_string()))?;
1852
1853 sheet
1855 .write_with_format(row, start_col, start_week as f64, number_fmt)
1856 .map_err(|e| RenderError::Format(e.to_string()))?;
1857
1858 sheet
1860 .write_with_format(row, end_col, end_week as f64, number_fmt)
1861 .map_err(|e| RenderError::Format(e.to_string()))?;
1862
1863 let milestone_col = 2u16;
1865 self.write_week_columns(
1866 sheet,
1867 row,
1868 start_week,
1869 end_week,
1870 is_critical,
1871 is_milestone,
1872 is_container,
1873 is_odd,
1874 formats,
1875 week_start_col,
1876 milestone_col,
1877 effort_col,
1878 start_col,
1879 end_col,
1880 person_days,
1881 schedule_weeks,
1882 )?;
1883
1884 Ok(())
1885 }
1886
1887 #[allow(clippy::too_many_arguments)]
1889 fn write_schedule_row_with_deps(
1890 &self,
1891 sheet: &mut Worksheet,
1892 row: u32,
1893 task_id: &str,
1894 task_name: &str,
1895 level: usize,
1896 profile: &str,
1897 predecessor: &str,
1898 dep_type: &str,
1899 lag: i32,
1900 person_days: f64,
1901 start_week: u32,
1902 end_week: u32,
1903 is_critical: bool,
1904 is_milestone: bool,
1905 is_container: bool,
1906 formats: &ExcelFormats,
1907 week_start_col: u16,
1908 effort_col: u16,
1909 start_col: u16,
1910 end_col: u16,
1911 last_data_row: u32,
1912 is_odd: bool,
1913 schedule_weeks: u32, ) -> Result<(), RenderError> {
1915 let excel_row = row + 1; let (text_fmt, number_fmt) = if is_milestone {
1919 (&formats.milestone_text, &formats.milestone_number)
1920 } else if is_odd {
1921 (&formats.row_odd_text, &formats.row_odd_number)
1922 } else {
1923 (&formats.row_even_text, &formats.row_even_number)
1924 };
1925
1926 let activity_fmt = if is_container {
1928 if is_odd {
1929 &formats.container_odd_text
1930 } else {
1931 &formats.container_even_text
1932 }
1933 } else {
1934 text_fmt
1935 };
1936
1937 sheet
1939 .write_with_format(row, 0, task_id, text_fmt)
1940 .map_err(|e| RenderError::Format(e.to_string()))?;
1941
1942 sheet
1944 .write_with_format(row, 1, task_name, activity_fmt)
1945 .map_err(|e| RenderError::Format(e.to_string()))?;
1946
1947 sheet
1949 .write_with_format(row, 2, level as f64, number_fmt)
1950 .map_err(|e| RenderError::Format(e.to_string()))?;
1951
1952 let milestone_marker = if is_milestone { "◆" } else { "" };
1954 sheet
1955 .write_with_format(row, 3, milestone_marker, text_fmt)
1956 .map_err(|e| RenderError::Format(e.to_string()))?;
1957
1958 sheet
1960 .write_with_format(row, 4, profile, text_fmt)
1961 .map_err(|e| RenderError::Format(e.to_string()))?;
1962
1963 sheet
1965 .write_with_format(row, 5, predecessor, text_fmt)
1966 .map_err(|e| RenderError::Format(e.to_string()))?;
1967
1968 let dep_type_val = if predecessor.is_empty() { "" } else { dep_type };
1970 sheet
1971 .write_with_format(row, 6, dep_type_val, text_fmt)
1972 .map_err(|e| RenderError::Format(e.to_string()))?;
1973
1974 if !predecessor.is_empty() {
1976 sheet
1977 .write_with_format(row, 7, lag as f64, number_fmt)
1978 .map_err(|e| RenderError::Format(e.to_string()))?;
1979 } else {
1980 sheet
1981 .write_with_format(row, 7, "", text_fmt)
1982 .map_err(|e| RenderError::Format(e.to_string()))?;
1983 }
1984
1985 sheet
1987 .write_with_format(row, effort_col, person_days, number_fmt)
1988 .map_err(|e| RenderError::Format(e.to_string()))?;
1989
1990 if self.use_formulas && !predecessor.is_empty() {
1995 let formula = format!(
1996 "=IF(F{}=\"\",{},IF(G{}=\"FS\",VLOOKUP(F{},$A$2:$K${},11,0)+1+H{},\
1997 IF(G{}=\"SS\",VLOOKUP(F{},$A$2:$K${},10,0)+H{},\
1998 IF(G{}=\"FF\",VLOOKUP(F{},$A$2:$K${},11,0)-CEILING(I{}*{}/{},1)+1+H{},\
1999 IF(G{}=\"SF\",VLOOKUP(F{},$A$2:$K${},10,0)-CEILING(I{}*{}/{},1)+1+H{},\
2000 {})))))",
2001 excel_row,
2002 start_week,
2003 excel_row,
2004 excel_row,
2005 last_data_row,
2006 excel_row,
2007 excel_row,
2008 excel_row,
2009 last_data_row,
2010 excel_row,
2011 excel_row,
2012 excel_row,
2013 last_data_row,
2014 excel_row,
2015 self.hours_per_day,
2016 self.hours_per_week,
2017 excel_row,
2018 excel_row,
2019 excel_row,
2020 last_data_row,
2021 excel_row,
2022 self.hours_per_day,
2023 self.hours_per_week,
2024 excel_row,
2025 start_week
2026 );
2027 sheet
2028 .write_formula_with_format(row, start_col, formula.as_str(), number_fmt)
2029 .map_err(|e| RenderError::Format(e.to_string()))?;
2030 } else {
2031 sheet
2032 .write_with_format(row, start_col, start_week as f64, number_fmt)
2033 .map_err(|e| RenderError::Format(e.to_string()))?;
2034 }
2035
2036 if self.use_formulas {
2038 let start_col_letter = Self::col_to_letter(start_col);
2039 let effort_col_letter = Self::col_to_letter(effort_col);
2040 let formula = format!(
2041 "={}{}+MAX(CEILING({}{}*{}/{},1)-1,0)",
2042 start_col_letter,
2043 excel_row,
2044 effort_col_letter,
2045 excel_row,
2046 self.hours_per_day,
2047 self.hours_per_week
2048 );
2049 sheet
2050 .write_formula_with_format(row, end_col, formula.as_str(), number_fmt)
2051 .map_err(|e| RenderError::Format(e.to_string()))?;
2052 } else {
2053 sheet
2054 .write_with_format(row, end_col, end_week as f64, number_fmt)
2055 .map_err(|e| RenderError::Format(e.to_string()))?;
2056 }
2057
2058 let milestone_col = 3u16;
2060 self.write_week_columns(
2061 sheet,
2062 row,
2063 start_week,
2064 end_week,
2065 is_critical,
2066 is_milestone,
2067 is_container,
2068 is_odd,
2069 formats,
2070 week_start_col,
2071 milestone_col,
2072 effort_col,
2073 start_col,
2074 end_col,
2075 person_days,
2076 schedule_weeks,
2077 )?;
2078
2079 Ok(())
2080 }
2081
2082 #[allow(clippy::too_many_arguments)]
2089 fn write_week_columns(
2090 &self,
2091 sheet: &mut Worksheet,
2092 row: u32,
2093 start_week: u32,
2094 end_week: u32,
2095 _is_critical: bool, is_milestone: bool,
2097 is_container: bool,
2098 is_odd: bool,
2099 formats: &ExcelFormats,
2100 week_start_col: u16,
2101 milestone_col: u16, effort_col: u16,
2103 start_col: u16,
2104 end_col: u16,
2105 person_days: f64,
2106 schedule_weeks: u32, ) -> Result<(), RenderError> {
2108 let excel_row = row + 1;
2109 let weeks_span = (end_week.saturating_sub(start_week) + 1).max(1);
2110 let hours_per_week_val = (person_days * self.hours_per_day) / weeks_span as f64;
2111
2112 let milestone_col_letter = Self::col_to_letter(milestone_col);
2113 let effort_col_letter = Self::col_to_letter(effort_col);
2114 let start_col_letter = Self::col_to_letter(start_col);
2115 let end_col_letter = Self::col_to_letter(end_col);
2116
2117 let cell_fmt = if is_milestone {
2120 &formats.milestone_week
2121 } else if is_odd {
2122 &formats.gantt_odd_empty
2123 } else {
2124 &formats.gantt_even_empty
2125 };
2126
2127 for week in 1..=schedule_weeks {
2128 let col = week_start_col + (week - 1) as u16;
2129 let in_range = week >= start_week && week <= end_week;
2130 let col_letter = Self::col_to_letter(col);
2131
2132 if is_container {
2134 sheet
2135 .write_with_format(row, col, "", cell_fmt)
2136 .map_err(|e| RenderError::Format(e.to_string()))?;
2137 continue;
2138 }
2139
2140 if self.use_formulas {
2141 let hours_formula = format!(
2149 "({}{}*{})/(${}{}-${}{}+1)",
2150 effort_col_letter,
2151 excel_row,
2152 self.hours_per_day,
2153 end_col_letter,
2154 excel_row,
2155 start_col_letter,
2156 excel_row
2157 );
2158 let in_range_condition = format!(
2159 "{}$1>=${}{},{}$1<=${}{}",
2160 col_letter, start_col_letter, excel_row, col_letter, end_col_letter, excel_row
2161 );
2162 let formula = format!(
2163 "=IF(${}{}=\"◆\",\
2164 IF(AND({}),\"◆\",\"\"),\
2165 IF(AND({},{}>0),ROUND({},0),\"\"))",
2166 milestone_col_letter,
2167 excel_row,
2168 in_range_condition,
2169 in_range_condition,
2170 hours_formula,
2171 hours_formula
2172 );
2173 sheet
2174 .write_formula_with_format(row, col, formula.as_str(), cell_fmt)
2175 .map_err(|e| RenderError::Format(e.to_string()))?;
2176 } else {
2177 if is_milestone {
2179 let value = if in_range { "◆" } else { "" };
2180 sheet
2181 .write_with_format(row, col, value, cell_fmt)
2182 .map_err(|e| RenderError::Format(e.to_string()))?;
2183 } else {
2184 let hours = if in_range {
2185 hours_per_week_val.round()
2186 } else {
2187 0.0
2188 };
2189 if hours > 0.0 {
2190 sheet
2191 .write_with_format(row, col, hours, cell_fmt)
2192 .map_err(|e| RenderError::Format(e.to_string()))?;
2193 } else {
2194 sheet
2195 .write_with_format(row, col, "", cell_fmt)
2196 .map_err(|e| RenderError::Format(e.to_string()))?;
2197 }
2198 }
2199 }
2200 }
2201
2202 Ok(())
2203 }
2204
2205 fn write_schedule_totals(
2207 &self,
2208 sheet: &mut Worksheet,
2209 row: u32,
2210 week_start_col: u16,
2211 effort_col: u16,
2212 formats: &ExcelFormats,
2213 schedule_weeks: u32, ) -> Result<(), RenderError> {
2215 if row <= 1 {
2216 return Ok(());
2217 }
2218
2219 sheet
2221 .write_with_format(row, 0, "TOTAL", &formats.total_row)
2222 .map_err(|e| RenderError::Format(e.to_string()))?;
2223
2224 for col_idx in 1..effort_col {
2226 sheet
2227 .write_with_format(row, col_idx, "", &formats.total_row)
2228 .map_err(|e| RenderError::Format(e.to_string()))?;
2229 }
2230
2231 if self.use_formulas {
2233 let effort_letter = Self::col_to_letter(effort_col);
2234 let formula = format!("=SUM({}2:{}{})", effort_letter, effort_letter, row);
2235 sheet
2236 .write_formula_with_format(row, effort_col, formula.as_str(), &formats.total_row)
2237 .map_err(|e| RenderError::Format(e.to_string()))?;
2238 } else {
2239 sheet
2240 .write_with_format(row, effort_col, 0.0, &formats.total_row)
2241 .map_err(|e| RenderError::Format(e.to_string()))?;
2242 }
2243
2244 for col_idx in (effort_col + 1)..week_start_col {
2246 sheet
2247 .write_with_format(row, col_idx, "", &formats.total_row)
2248 .map_err(|e| RenderError::Format(e.to_string()))?;
2249 }
2250
2251 for week in 0..schedule_weeks {
2253 let week_col = week_start_col + week as u16;
2254 if self.use_formulas {
2255 let col_letter = Self::col_to_letter(week_col);
2256 let formula = format!("=SUM({}2:{}{})", col_letter, col_letter, row);
2257 sheet
2258 .write_formula_with_format(row, week_col, formula.as_str(), &formats.total_row)
2259 .map_err(|e| RenderError::Format(e.to_string()))?;
2260 } else {
2261 sheet
2262 .write_with_format(row, week_col, 0.0, &formats.total_row)
2263 .map_err(|e| RenderError::Format(e.to_string()))?;
2264 }
2265 }
2266
2267 Ok(())
2268 }
2269
2270 fn add_executive_summary(
2272 &self,
2273 workbook: &mut Workbook,
2274 project: &Project,
2275 schedule: &Schedule,
2276 formats: &ExcelFormats,
2277 resource_rates: &HashMap<String, f64>,
2278 ) -> Result<(), RenderError> {
2279 let sheet = workbook.add_worksheet();
2280 sheet
2281 .set_name("Executive Summary")
2282 .map_err(|e| RenderError::Format(e.to_string()))?;
2283
2284 sheet
2286 .write_with_format(0, 0, "PROJECT SUMMARY", &formats.header)
2287 .map_err(|e| RenderError::Format(e.to_string()))?;
2288 sheet
2289 .merge_range(0, 0, 0, 1, "PROJECT SUMMARY", &formats.header)
2290 .ok();
2291
2292 sheet
2293 .write_with_format(2, 0, "Project Name:", &formats.text)
2294 .map_err(|e| RenderError::Format(e.to_string()))?;
2295 sheet
2296 .write_with_format(2, 1, &project.name, &formats.text)
2297 .map_err(|e| RenderError::Format(e.to_string()))?;
2298
2299 sheet
2300 .write_with_format(3, 0, "Start Date:", &formats.text)
2301 .map_err(|e| RenderError::Format(e.to_string()))?;
2302 sheet
2303 .write_with_format(
2304 3,
2305 1,
2306 project.start.format("%Y-%m-%d").to_string(),
2307 &formats.text,
2308 )
2309 .map_err(|e| RenderError::Format(e.to_string()))?;
2310
2311 sheet
2312 .write_with_format(4, 0, "End Date:", &formats.text)
2313 .map_err(|e| RenderError::Format(e.to_string()))?;
2314 sheet
2315 .write_with_format(
2316 4,
2317 1,
2318 schedule.project_end.format("%Y-%m-%d").to_string(),
2319 &formats.text,
2320 )
2321 .map_err(|e| RenderError::Format(e.to_string()))?;
2322
2323 sheet
2324 .write_with_format(5, 0, "Duration (days):", &formats.text)
2325 .map_err(|e| RenderError::Format(e.to_string()))?;
2326 sheet
2327 .write_with_format(5, 1, schedule.project_duration.as_days(), &formats.number)
2328 .map_err(|e| RenderError::Format(e.to_string()))?;
2329
2330 sheet
2331 .write_with_format(6, 0, "Total Tasks:", &formats.text)
2332 .map_err(|e| RenderError::Format(e.to_string()))?;
2333 sheet
2334 .write_with_format(6, 1, schedule.tasks.len() as f64, &formats.integer)
2335 .map_err(|e| RenderError::Format(e.to_string()))?;
2336
2337 sheet
2338 .write_with_format(7, 0, "Critical Tasks:", &formats.text)
2339 .map_err(|e| RenderError::Format(e.to_string()))?;
2340 sheet
2341 .write_with_format(7, 1, schedule.critical_path.len() as f64, &formats.integer)
2342 .map_err(|e| RenderError::Format(e.to_string()))?;
2343
2344 sheet
2346 .write_with_format(9, 0, "COST SUMMARY", &formats.header)
2347 .map_err(|e| RenderError::Format(e.to_string()))?;
2348 sheet
2349 .merge_range(9, 0, 9, 1, "COST SUMMARY", &formats.header)
2350 .ok();
2351
2352 let mut resource_effort: HashMap<String, f64> = HashMap::new();
2355 for scheduled in schedule.tasks.values() {
2356 for assignment in &scheduled.assignments {
2357 let effort = if let Some(effort_days) = assignment.effort_days {
2358 effort_days
2359 } else {
2360 let assignment_days = (assignment.finish - assignment.start).num_days() as f64;
2361 assignment_days * assignment.units as f64
2362 };
2363 *resource_effort
2364 .entry(assignment.resource_id.clone())
2365 .or_default() += effort;
2366 }
2367 }
2368
2369 let total_effort: f64 = resource_effort.values().sum();
2370 let total_cost: f64 = resource_effort
2371 .iter()
2372 .map(|(id, effort)| {
2373 resource_rates.get(id).copied().unwrap_or(self.default_rate) * effort
2374 })
2375 .sum();
2376
2377 sheet
2378 .write_with_format(11, 0, "Total Effort (pd):", &formats.text)
2379 .map_err(|e| RenderError::Format(e.to_string()))?;
2380 sheet
2381 .write_with_format(11, 1, total_effort, &formats.number)
2382 .map_err(|e| RenderError::Format(e.to_string()))?;
2383
2384 sheet
2385 .write_with_format(
2386 12,
2387 0,
2388 &format!("Total Cost ({}):", self.currency),
2389 &formats.text,
2390 )
2391 .map_err(|e| RenderError::Format(e.to_string()))?;
2392 sheet
2393 .write_with_format(12, 1, total_cost, &formats.currency)
2394 .map_err(|e| RenderError::Format(e.to_string()))?;
2395
2396 sheet.set_column_width(0, 20).ok();
2398 sheet.set_column_width(1, 25).ok();
2399
2400 Ok(())
2401 }
2402
2403 fn add_calendar_analysis_sheet(
2405 &self,
2406 workbook: &mut Workbook,
2407 project: &Project,
2408 schedule: &Schedule,
2409 formats: &ExcelFormats,
2410 ) -> Result<(), RenderError> {
2411 let sheet = workbook.add_worksheet();
2412 sheet
2413 .set_name("Calendar Analysis")
2414 .map_err(|e| RenderError::Format(e.to_string()))?;
2415
2416 let headers = [
2418 "Task ID",
2419 "Task Name",
2420 "Calendar",
2421 "Duration (days)",
2422 "Working Days",
2423 "Weekends",
2424 "Holidays",
2425 "Non-Working %",
2426 "Diagnostics",
2427 ];
2428
2429 for (col, header) in headers.iter().enumerate() {
2430 sheet
2431 .write_with_format(0, col as u16, *header, &formats.header)
2432 .map_err(|e| RenderError::Format(e.to_string()))?;
2433 }
2434
2435 sheet.set_column_width(0, 15).ok(); sheet.set_column_width(1, 25).ok(); sheet.set_column_width(2, 12).ok(); sheet.set_column_width(3, 12).ok(); sheet.set_column_width(4, 12).ok(); sheet.set_column_width(5, 10).ok(); sheet.set_column_width(6, 10).ok(); sheet.set_column_width(7, 12).ok(); sheet.set_column_width(8, 30).ok(); let project_calendar = project
2448 .calendars
2449 .iter()
2450 .find(|c| c.id == project.calendar)
2451 .cloned()
2452 .unwrap_or_else(Calendar::default);
2453
2454 let wbs_order = Self::collect_wbs_order(&project.tasks, 0);
2456
2457 let mut row = 1u32;
2458 for (task_path, _level) in &wbs_order {
2459 if let Some(scheduled) = schedule.tasks.get(task_path) {
2461 let simple_id = task_path.rsplit('.').next().unwrap_or(task_path);
2462 let task = project.get_task(simple_id);
2463 let task_name = task.map(|t| t.name.as_str()).unwrap_or(simple_id);
2464
2465 let calendar = &project_calendar;
2467
2468 let (working_days, weekend_days, holiday_days) =
2470 self.calculate_calendar_impact_for_task(scheduled, calendar);
2471
2472 let total_span = (scheduled.finish - scheduled.start).num_days().max(1) as f64;
2473 let non_working_pct = ((weekend_days + holiday_days) as f64 / total_span) * 100.0;
2474
2475 let task_diags = self.filter_task_diagnostics(task_path);
2477 let diag_str = task_diags
2478 .iter()
2479 .map(|d| d.as_str())
2480 .collect::<Vec<_>>()
2481 .join(", ");
2482
2483 sheet
2485 .write_with_format(row, 0, task_path, &formats.text)
2486 .map_err(|e| RenderError::Format(e.to_string()))?;
2487 sheet
2488 .write_with_format(row, 1, task_name, &formats.text)
2489 .map_err(|e| RenderError::Format(e.to_string()))?;
2490 sheet
2491 .write_with_format(row, 2, &calendar.id, &formats.text)
2492 .map_err(|e| RenderError::Format(e.to_string()))?;
2493 sheet
2494 .write_with_format(row, 3, scheduled.duration.as_days(), &formats.number)
2495 .map_err(|e| RenderError::Format(e.to_string()))?;
2496 sheet
2497 .write_with_format(row, 4, working_days as f64, &formats.integer)
2498 .map_err(|e| RenderError::Format(e.to_string()))?;
2499 sheet
2500 .write_with_format(row, 5, weekend_days as f64, &formats.integer)
2501 .map_err(|e| RenderError::Format(e.to_string()))?;
2502 sheet
2503 .write_with_format(row, 6, holiday_days as f64, &formats.integer)
2504 .map_err(|e| RenderError::Format(e.to_string()))?;
2505
2506 let pct_format = if non_working_pct > 30.0 {
2508 Format::new()
2509 .set_num_format("0.0%")
2510 .set_background_color(0xFFCCCC)
2511 .set_border(FormatBorder::Thin)
2512 } else if non_working_pct > 15.0 {
2513 Format::new()
2514 .set_num_format("0.0%")
2515 .set_background_color(0xFFFFCC)
2516 .set_border(FormatBorder::Thin)
2517 } else {
2518 Format::new()
2519 .set_num_format("0.0%")
2520 .set_background_color(0xCCFFCC)
2521 .set_border(FormatBorder::Thin)
2522 };
2523 sheet
2524 .write_with_format(row, 7, non_working_pct / 100.0, &pct_format)
2525 .map_err(|e| RenderError::Format(e.to_string()))?;
2526
2527 let diag_format = if !task_diags.is_empty() {
2529 Format::new()
2530 .set_background_color(0xFFEEDD)
2531 .set_border(FormatBorder::Thin)
2532 } else {
2533 formats.text.clone()
2534 };
2535 sheet
2536 .write_with_format(row, 8, &diag_str, &diag_format)
2537 .map_err(|e| RenderError::Format(e.to_string()))?;
2538
2539 row += 1;
2540 }
2541 }
2542
2543 sheet.set_freeze_panes(1, 0).ok();
2545
2546 Ok(())
2547 }
2548
2549 fn calculate_calendar_impact_for_task(
2551 &self,
2552 scheduled: &ScheduledTask,
2553 calendar: &Calendar,
2554 ) -> (u32, u32, u32) {
2555 let mut working_days = 0u32;
2556 let mut weekend_days = 0u32;
2557 let mut holiday_days = 0u32;
2558
2559 let mut current = scheduled.start;
2560 while current <= scheduled.finish {
2561 let weekday = current.weekday().num_days_from_sunday() as u8;
2562
2563 let is_holiday = calendar
2565 .holidays
2566 .iter()
2567 .any(|h| current >= h.start && current <= h.end);
2568
2569 if is_holiday {
2570 holiday_days += 1;
2571 } else if !calendar.working_days.contains(&weekday) {
2572 weekend_days += 1;
2573 } else {
2574 working_days += 1;
2575 }
2576
2577 current = current.succ_opt().unwrap_or(current);
2578 if current == scheduled.finish && current == scheduled.start {
2579 break; }
2581 }
2582
2583 (working_days, weekend_days, holiday_days)
2584 }
2585
2586 fn filter_task_diagnostics(&self, task_id: &str) -> Vec<DiagnosticCode> {
2588 self.diagnostics
2589 .iter()
2590 .filter(|d| Self::is_diagnostic_for_task(d, task_id))
2591 .map(|d| d.code.clone())
2592 .collect()
2593 }
2594
2595 fn is_diagnostic_for_task(diagnostic: &Diagnostic, task_id: &str) -> bool {
2597 let quoted_id = format!("'{}'", task_id);
2598 match diagnostic.code {
2599 DiagnosticCode::C010NonWorkingDay | DiagnosticCode::C011CalendarMismatch => {
2600 diagnostic.message.contains("ed_id)
2601 }
2602 DiagnosticCode::H004TaskUnconstrained => diagnostic.message.contains("ed_id),
2603 DiagnosticCode::W001AbstractAssignment | DiagnosticCode::H001MixedAbstraction => {
2604 diagnostic.message.contains("ed_id)
2605 }
2606 DiagnosticCode::W014ContainerDependency => diagnostic.message.contains("ed_id),
2607 _ => false,
2608 }
2609 }
2610
2611 fn add_diagnostics_sheet(
2613 &self,
2614 workbook: &mut Workbook,
2615 _project: &Project,
2616 formats: &ExcelFormats,
2617 ) -> Result<(), RenderError> {
2618 let sheet = workbook.add_worksheet();
2619 sheet
2620 .set_name("Diagnostics")
2621 .map_err(|e| RenderError::Format(e.to_string()))?;
2622
2623 let headers = ["Code", "Severity", "Message", "Hint"];
2625 for (col, header) in headers.iter().enumerate() {
2626 sheet
2627 .write_with_format(0, col as u16, *header, &formats.header)
2628 .map_err(|e| RenderError::Format(e.to_string()))?;
2629 }
2630
2631 sheet.set_column_width(0, 8).ok(); sheet.set_column_width(1, 10).ok(); sheet.set_column_width(2, 60).ok(); sheet.set_column_width(3, 40).ok(); let mut sorted_diags: Vec<&Diagnostic> = self.diagnostics.iter().collect();
2639 sorted_diags.sort_by_key(|d| match d.severity {
2640 Severity::Error => 0,
2641 Severity::Warning => 1,
2642 Severity::Hint => 2,
2643 Severity::Info => 3,
2644 });
2645
2646 for (i, diag) in sorted_diags.iter().enumerate() {
2648 let row = (i + 1) as u32;
2649
2650 let severity_format = match diag.severity {
2652 Severity::Error => Format::new()
2653 .set_background_color(0xFFCCCC)
2654 .set_border(FormatBorder::Thin),
2655 Severity::Warning => Format::new()
2656 .set_background_color(0xFFFFCC)
2657 .set_border(FormatBorder::Thin),
2658 Severity::Hint => Format::new()
2659 .set_background_color(0xCCFFFF)
2660 .set_border(FormatBorder::Thin),
2661 Severity::Info => Format::new()
2662 .set_background_color(0xCCCCFF)
2663 .set_border(FormatBorder::Thin),
2664 };
2665
2666 let severity_str = match diag.severity {
2667 Severity::Error => "Error",
2668 Severity::Warning => "Warning",
2669 Severity::Hint => "Hint",
2670 Severity::Info => "Info",
2671 };
2672
2673 sheet
2674 .write_with_format(row, 0, diag.code.as_str(), &severity_format)
2675 .map_err(|e| RenderError::Format(e.to_string()))?;
2676 sheet
2677 .write_with_format(row, 1, severity_str, &severity_format)
2678 .map_err(|e| RenderError::Format(e.to_string()))?;
2679 sheet
2680 .write_with_format(row, 2, &diag.message, &formats.text)
2681 .map_err(|e| RenderError::Format(e.to_string()))?;
2682 let hint_str = diag.hints.first().map(|s| s.as_str()).unwrap_or("");
2683 sheet
2684 .write_with_format(row, 3, hint_str, &formats.text)
2685 .map_err(|e| RenderError::Format(e.to_string()))?;
2686 }
2687
2688 let summary_row = (sorted_diags.len() + 3) as u32;
2690 sheet
2691 .write_with_format(summary_row, 0, "SUMMARY", &formats.header)
2692 .map_err(|e| RenderError::Format(e.to_string()))?;
2693 sheet
2694 .merge_range(summary_row, 0, summary_row, 1, "SUMMARY", &formats.header)
2695 .ok();
2696
2697 let error_count = self
2698 .diagnostics
2699 .iter()
2700 .filter(|d| matches!(d.severity, Severity::Error))
2701 .count();
2702 let warning_count = self
2703 .diagnostics
2704 .iter()
2705 .filter(|d| matches!(d.severity, Severity::Warning))
2706 .count();
2707 let hint_count = self
2708 .diagnostics
2709 .iter()
2710 .filter(|d| matches!(d.severity, Severity::Hint))
2711 .count();
2712 let calendar_count = self
2713 .diagnostics
2714 .iter()
2715 .filter(|d| d.code.as_str().starts_with("C"))
2716 .count();
2717
2718 sheet
2719 .write_with_format(summary_row + 1, 0, "Errors:", &formats.text)
2720 .map_err(|e| RenderError::Format(e.to_string()))?;
2721 sheet
2722 .write_with_format(summary_row + 1, 1, error_count as f64, &formats.integer)
2723 .map_err(|e| RenderError::Format(e.to_string()))?;
2724
2725 sheet
2726 .write_with_format(summary_row + 2, 0, "Warnings:", &formats.text)
2727 .map_err(|e| RenderError::Format(e.to_string()))?;
2728 sheet
2729 .write_with_format(summary_row + 2, 1, warning_count as f64, &formats.integer)
2730 .map_err(|e| RenderError::Format(e.to_string()))?;
2731
2732 sheet
2733 .write_with_format(summary_row + 3, 0, "Hints:", &formats.text)
2734 .map_err(|e| RenderError::Format(e.to_string()))?;
2735 sheet
2736 .write_with_format(summary_row + 3, 1, hint_count as f64, &formats.integer)
2737 .map_err(|e| RenderError::Format(e.to_string()))?;
2738
2739 sheet
2740 .write_with_format(summary_row + 4, 0, "Calendar Issues:", &formats.text)
2741 .map_err(|e| RenderError::Format(e.to_string()))?;
2742 sheet
2743 .write_with_format(summary_row + 4, 1, calendar_count as f64, &formats.integer)
2744 .map_err(|e| RenderError::Format(e.to_string()))?;
2745
2746 sheet.set_freeze_panes(1, 0).ok();
2748
2749 Ok(())
2750 }
2751
2752 fn date_to_week(&self, date: NaiveDate, project_start: NaiveDate) -> u32 {
2754 let days = (date - project_start).num_days().max(0) as u32;
2755 (days / 7) + 1
2756 }
2757
2758 fn col_to_letter(col: u16) -> String {
2760 let mut result = String::new();
2761 let mut n = col as u32;
2762 loop {
2763 result.insert(0, (b'A' + (n % 26) as u8) as char);
2764 if n < 26 {
2765 break;
2766 }
2767 n = n / 26 - 1;
2768 }
2769 result
2770 }
2771
2772 fn collect_wbs_order(tasks: &[utf8proj_core::Task], level: usize) -> Vec<(String, usize)> {
2777 Self::collect_wbs_order_with_prefix(tasks, "", level)
2778 }
2779
2780 fn collect_wbs_order_with_prefix(
2782 tasks: &[utf8proj_core::Task],
2783 parent_path: &str,
2784 level: usize,
2785 ) -> Vec<(String, usize)> {
2786 let mut result = Vec::new();
2787 for task in tasks {
2788 let full_id = if parent_path.is_empty() {
2790 task.id.clone()
2791 } else {
2792 format!("{}.{}", parent_path, task.id)
2793 };
2794
2795 result.push((full_id.clone(), level));
2797
2798 if !task.children.is_empty() {
2800 result.extend(Self::collect_wbs_order_with_prefix(
2801 &task.children,
2802 &full_id,
2803 level + 1,
2804 ));
2805 }
2806 }
2807 result
2808 }
2809}
2810
2811struct ExcelFormats {
2813 header: Format,
2814 currency: Format,
2815 number: Format,
2816 integer: Format,
2817 text: Format,
2818 week_header: Format,
2819 total_row: Format,
2820 total_currency: Format,
2821 row_even_text: Format,
2823 row_even_number: Format,
2824 row_odd_text: Format,
2825 row_odd_number: Format,
2826 milestone_text: Format,
2828 milestone_number: Format,
2829 milestone_week: Format,
2830 container_even_text: Format,
2832 container_odd_text: Format,
2833 gantt_even_empty: Format,
2835 gantt_odd_empty: Format,
2836 weekend_header: Format,
2838 weekend_cell: Format,
2839 holiday_header: Format,
2841 holiday_cell: Format,
2842}
2843
2844impl Renderer for ExcelRenderer {
2846 type Output = Vec<u8>;
2847
2848 fn render(&self, project: &Project, schedule: &Schedule) -> Result<Vec<u8>, RenderError> {
2849 if schedule.tasks.is_empty() {
2850 return Err(RenderError::InvalidData("No tasks to render".into()));
2851 }
2852 self.render_to_bytes(project, schedule)
2853 }
2854}
2855
2856#[cfg(test)]
2857mod tests {
2858 use super::*;
2859 use chrono::NaiveDate;
2860 use rust_decimal_macros::dec;
2861 use utf8proj_core::{Assignment, Duration, Money, Resource, ScheduledTask, Task, TaskStatus};
2862
2863 fn create_test_project() -> Project {
2864 let mut project = Project::new("Test Project");
2865 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
2866
2867 project.resources.push(
2869 Resource::new("PM")
2870 .name("Project Manager")
2871 .rate(Money::new(dec!(500), "EUR")),
2872 );
2873 project.resources.push(
2874 Resource::new("DEV")
2875 .name("Developer")
2876 .rate(Money::new(dec!(400), "EUR")),
2877 );
2878 project.resources.push(
2879 Resource::new("TEST")
2880 .name("Tester")
2881 .rate(Money::new(dec!(350), "EUR")),
2882 );
2883
2884 project.tasks.push(
2886 Task::new("design")
2887 .name("Design Phase")
2888 .effort(Duration::days(5))
2889 .assign("PM"),
2890 );
2891 project.tasks.push(
2892 Task::new("implement")
2893 .name("Implementation")
2894 .effort(Duration::days(20))
2895 .assign("DEV")
2896 .depends_on("design"),
2897 );
2898 project.tasks.push(
2899 Task::new("test")
2900 .name("Testing")
2901 .effort(Duration::days(10))
2902 .assign("TEST")
2903 .depends_on("implement"),
2904 );
2905
2906 project
2907 }
2908
2909 fn create_test_schedule() -> Schedule {
2910 let mut tasks = HashMap::new();
2911
2912 let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
2913 let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
2914 tasks.insert(
2915 "design".to_string(),
2916 ScheduledTask {
2917 task_id: "design".to_string(),
2918 start: start1,
2919 finish: finish1,
2920 duration: Duration::days(5),
2921 assignments: vec![Assignment {
2922 resource_id: "PM".to_string(),
2923 start: start1,
2924 finish: finish1,
2925 units: 1.0,
2926 cost: None,
2927 cost_range: None,
2928 is_abstract: false,
2929 effort_days: None,
2930 }],
2931 slack: Duration::zero(),
2932 is_critical: true,
2933 early_start: start1,
2934 early_finish: finish1,
2935 late_start: start1,
2936 late_finish: finish1,
2937 forecast_start: start1,
2938 forecast_finish: finish1,
2939 remaining_duration: Duration::days(5),
2940 percent_complete: 0,
2941 status: TaskStatus::NotStarted,
2942 cost_range: None,
2943 has_abstract_assignments: false,
2944 baseline_start: start1,
2945 baseline_finish: finish1,
2946 start_variance_days: 0,
2947 finish_variance_days: 0,
2948 },
2949 );
2950
2951 let start2 = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
2952 let finish2 = NaiveDate::from_ymd_opt(2025, 1, 31).unwrap();
2953 tasks.insert(
2954 "implement".to_string(),
2955 ScheduledTask {
2956 task_id: "implement".to_string(),
2957 start: start2,
2958 finish: finish2,
2959 duration: Duration::days(20),
2960 assignments: vec![Assignment {
2961 resource_id: "DEV".to_string(),
2962 start: start2,
2963 finish: finish2,
2964 units: 1.0,
2965 cost: None,
2966 cost_range: None,
2967 is_abstract: false,
2968 effort_days: None,
2969 }],
2970 slack: Duration::zero(),
2971 is_critical: true,
2972 early_start: start2,
2973 early_finish: finish2,
2974 late_start: start2,
2975 late_finish: finish2,
2976 forecast_start: start2,
2977 forecast_finish: finish2,
2978 remaining_duration: Duration::days(20),
2979 percent_complete: 0,
2980 status: TaskStatus::NotStarted,
2981 cost_range: None,
2982 has_abstract_assignments: false,
2983 baseline_start: start2,
2984 baseline_finish: finish2,
2985 start_variance_days: 0,
2986 finish_variance_days: 0,
2987 },
2988 );
2989
2990 let start3 = NaiveDate::from_ymd_opt(2025, 2, 3).unwrap();
2991 let finish3 = NaiveDate::from_ymd_opt(2025, 2, 14).unwrap();
2992 tasks.insert(
2993 "test".to_string(),
2994 ScheduledTask {
2995 task_id: "test".to_string(),
2996 start: start3,
2997 finish: finish3,
2998 duration: Duration::days(10),
2999 assignments: vec![Assignment {
3000 resource_id: "TEST".to_string(),
3001 start: start3,
3002 finish: finish3,
3003 units: 1.0,
3004 cost: None,
3005 cost_range: None,
3006 is_abstract: false,
3007 effort_days: None,
3008 }],
3009 slack: Duration::zero(),
3010 is_critical: true,
3011 early_start: start3,
3012 early_finish: finish3,
3013 late_start: start3,
3014 late_finish: finish3,
3015 forecast_start: start3,
3016 forecast_finish: finish3,
3017 remaining_duration: Duration::days(10),
3018 percent_complete: 0,
3019 status: TaskStatus::NotStarted,
3020 cost_range: None,
3021 has_abstract_assignments: false,
3022 baseline_start: start3,
3023 baseline_finish: finish3,
3024 start_variance_days: 0,
3025 finish_variance_days: 0,
3026 },
3027 );
3028
3029 let project_end = NaiveDate::from_ymd_opt(2025, 2, 14).unwrap();
3030 Schedule {
3031 tasks,
3032 critical_path: vec![
3033 "design".to_string(),
3034 "implement".to_string(),
3035 "test".to_string(),
3036 ],
3037 project_duration: Duration::days(35),
3038 project_end,
3039 total_cost: None,
3040 total_cost_range: None,
3041 project_progress: 0,
3042 project_baseline_finish: project_end,
3043 project_forecast_finish: project_end,
3044 project_variance_days: 0,
3045 planned_value: 0,
3046 earned_value: 0,
3047 spi: 1.0,
3048 }
3049 }
3050
3051 #[test]
3052 fn excel_renderer_creation() {
3053 let renderer = ExcelRenderer::new();
3054 assert_eq!(renderer.currency, "€");
3055 assert_eq!(renderer.schedule_weeks, 18);
3056 assert!(renderer.use_formulas);
3057 }
3058
3059 #[test]
3060 fn excel_renderer_with_options() {
3061 let renderer = ExcelRenderer::new()
3062 .currency("$")
3063 .weeks(24)
3064 .hours_per_day(7.5)
3065 .no_summary()
3066 .static_values();
3067
3068 assert_eq!(renderer.currency, "$");
3069 assert_eq!(renderer.schedule_weeks, 24);
3070 assert_eq!(renderer.hours_per_day, 7.5);
3071 assert!(!renderer.include_summary);
3072 assert!(!renderer.use_formulas);
3073 }
3074
3075 #[test]
3076 fn excel_produces_valid_output() {
3077 let renderer = ExcelRenderer::new();
3078 let project = create_test_project();
3079 let schedule = create_test_schedule();
3080
3081 let result = renderer.render(&project, &schedule);
3082 assert!(result.is_ok());
3083
3084 let bytes = result.unwrap();
3085 assert!(bytes.len() > 100);
3087 assert_eq!(&bytes[0..2], b"PK");
3088 }
3089
3090 #[test]
3091 fn excel_empty_schedule_fails() {
3092 let renderer = ExcelRenderer::new();
3093 let project = Project::new("Empty");
3094 let project_end = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
3095 let schedule = Schedule {
3096 tasks: HashMap::new(),
3097 critical_path: vec![],
3098 project_duration: Duration::zero(),
3099 project_end,
3100 total_cost: None,
3101 total_cost_range: None,
3102 project_progress: 0,
3103 project_baseline_finish: project_end,
3104 project_forecast_finish: project_end,
3105 project_variance_days: 0,
3106 planned_value: 0,
3107 earned_value: 0,
3108 spi: 1.0,
3109 };
3110
3111 let result = renderer.render(&project, &schedule);
3112 assert!(result.is_err());
3113 }
3114
3115 #[test]
3116 fn col_to_letter_works() {
3117 assert_eq!(ExcelRenderer::col_to_letter(0), "A");
3118 assert_eq!(ExcelRenderer::col_to_letter(25), "Z");
3119 assert_eq!(ExcelRenderer::col_to_letter(26), "AA");
3120 assert_eq!(ExcelRenderer::col_to_letter(27), "AB");
3121 assert_eq!(ExcelRenderer::col_to_letter(51), "AZ");
3122 assert_eq!(ExcelRenderer::col_to_letter(52), "BA");
3123 }
3124
3125 #[test]
3126 fn date_to_week_calculation() {
3127 let renderer = ExcelRenderer::new();
3128 let start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
3129
3130 assert_eq!(renderer.date_to_week(start, start), 1);
3132
3133 let day6 = NaiveDate::from_ymd_opt(2025, 1, 12).unwrap();
3135 assert_eq!(renderer.date_to_week(day6, start), 1);
3136
3137 let day7 = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
3139 assert_eq!(renderer.date_to_week(day7, start), 2);
3140
3141 let day14 = NaiveDate::from_ymd_opt(2025, 1, 20).unwrap();
3143 assert_eq!(renderer.date_to_week(day14, start), 3);
3144 }
3145
3146 #[test]
3147 fn excel_with_static_values() {
3148 let renderer = ExcelRenderer::new().static_values();
3149 let project = create_test_project();
3150 let schedule = create_test_schedule();
3151
3152 let result = renderer.render(&project, &schedule);
3153 assert!(result.is_ok());
3154 }
3155
3156 #[test]
3157 fn excel_without_summary() {
3158 let renderer = ExcelRenderer::new().no_summary();
3159 let project = create_test_project();
3160 let schedule = create_test_schedule();
3161
3162 let result = renderer.render(&project, &schedule);
3163 assert!(result.is_ok());
3164 }
3165
3166 #[test]
3167 fn excel_with_different_currency() {
3168 let renderer = ExcelRenderer::new().currency("USD");
3169 let project = create_test_project();
3170 let schedule = create_test_schedule();
3171
3172 let result = renderer.render(&project, &schedule);
3173 assert!(result.is_ok());
3174 }
3175
3176 #[test]
3177 fn excel_with_dependencies_enabled() {
3178 let renderer = ExcelRenderer::new();
3180 assert!(renderer.show_dependencies);
3181
3182 let project = create_test_project();
3183 let schedule = create_test_schedule();
3184
3185 let result = renderer.render(&project, &schedule);
3186 assert!(result.is_ok());
3187
3188 let bytes = result.unwrap();
3189 assert!(bytes.len() > 100);
3191 assert_eq!(&bytes[0..2], b"PK");
3192 }
3193
3194 #[test]
3195 fn excel_with_dependencies_disabled() {
3196 let renderer = ExcelRenderer::new().no_dependencies();
3197 assert!(!renderer.show_dependencies);
3198
3199 let project = create_test_project();
3200 let schedule = create_test_schedule();
3201
3202 let result = renderer.render(&project, &schedule);
3203 assert!(result.is_ok());
3204
3205 let bytes = result.unwrap();
3206 assert!(bytes.len() > 100);
3208 assert_eq!(&bytes[0..2], b"PK");
3209 }
3210
3211 #[test]
3212 fn excel_hours_per_week_setting() {
3213 let renderer = ExcelRenderer::new().hours_per_day(8.0).hours_per_week(35.0); assert_eq!(renderer.hours_per_week, 35.0);
3216
3217 let project = create_test_project();
3218 let schedule = create_test_schedule();
3219
3220 let result = renderer.render(&project, &schedule);
3221 assert!(result.is_ok());
3222 }
3223
3224 #[test]
3225 fn excel_dependency_formulas_cascade() {
3226 let renderer = ExcelRenderer::new();
3229 let project = create_test_project();
3230 let schedule = create_test_schedule();
3231
3232 let result = renderer.render(&project, &schedule);
3233 assert!(result.is_ok());
3234 }
3235}