Skip to main content

utf8proj_render/
excel.rs

1//! Excel costing report renderer
2//!
3//! Generates XLSX files with multiple sheets for corporate project costing:
4//! - Profiles and Costs: Resource rates and totals
5//! - Summary: Activities × Profiles matrix with effort allocation
6//! - Schedule: Week-based Gantt chart with hour distribution formulas
7//!
8//! ## Dependency Support
9//!
10//! When `show_dependencies` is enabled, the Schedule sheet includes:
11//! - Task ID column for VLOOKUP references
12//! - Depends On column showing predecessor task ID
13//! - Dependency Type (FS/SS/FF/SF) with dropdown validation
14//! - Lag in days (positive = delay, negative = lead)
15//! - **Formula-driven Start/End weeks** that cascade when predecessors change
16//!
17//! ## Dependency Types
18//!
19//! - **FS (Finish-to-Start)**: Successor starts after predecessor finishes (most common)
20//! - **SS (Start-to-Start)**: Successor starts when predecessor starts
21//! - **FF (Finish-to-Finish)**: Successor finishes when predecessor finishes
22//! - **SF (Start-to-Finish)**: Successor finishes when predecessor starts
23//!
24//! ## What-If Analysis Support
25//!
26//! The Schedule sheet uses Excel **conditional formatting** for dynamic Gantt visualization:
27//! - Week cells show blue background when value > 0 (task has hours in that week)
28//! - Week cells show alternating white/light-blue when value = 0 (no hours)
29//! - This enables **live what-if analysis**: change effort/dependencies and colors update
30//!
31//! Unlike static formatting (baked in at render time), conditional formatting allows
32//! the Gantt bar visualization to respond dynamically when users modify:
33//! - Task effort (pd column)
34//! - Dependencies (Depends On column)
35//! - Lag values
36//!
37//! ## Example Output Structure
38//!
39//! ```text
40//! Sheet: Profiles and Costs
41//! | Profile ID | Profile              | Rate €/d | Days | Cost €   |
42//! |------------|----------------------|----------|------|----------|
43//! | PM         | Project Manager      | 500      | 10   | 5000     |
44//! | DEV        | Developer            | 400      | 50   | 20000    |
45//!
46//! Sheet: Schedule (with dependencies)
47//! | ID     | Activity | Profile | Depends | Type | Lag | pd | Start | End | W1 | W2 | ...
48//! |--------|----------|---------|---------|------|-----|----| ------|-----|----|----| ...
49//! | design | Design   | DEV     |         |      |     | 5  | 1     | =F  | 20 | 20 | ...
50//! | impl   | Implement| DEV     | design  | FS   | 0   | 10 | =F    | =F  | 0  | 0  | ...
51//! ```
52//!
53//! The Start Week formula for dependent tasks:
54//! ```text
55//! =IF(D2="", 1, IF(E2="FS", VLOOKUP(D2,TaskTable,9,0)+1+F2, ...))
56//! ```
57//! This creates a **live schedule** - change a task's effort and all successors update!
58
59use 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/// Schedule time granularity for Excel export
72#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "lowercase")]
74pub enum ScheduleGranularity {
75    /// One column per calendar day (shows weekends/holidays)
76    Daily,
77    /// One column per week (current behavior)
78    #[default]
79    Weekly,
80}
81
82/// Configuration for Excel export (RFC-0009)
83///
84/// This struct is designed for JSON serialization to support WASM/browser usage.
85/// All fields have sensible defaults, so `ExcelConfig::default()` works well.
86///
87/// # Example
88///
89/// ```rust,ignore
90/// use utf8proj_render::ExcelConfig;
91///
92/// let config = ExcelConfig {
93///     scale: "daily".to_string(),
94///     currency: "USD".to_string(),
95///     auto_fit: true,
96///     ..Default::default()
97/// };
98///
99/// let renderer = config.to_renderer();
100/// ```
101#[derive(Clone, Debug, Serialize, Deserialize)]
102pub struct ExcelConfig {
103    /// Scale: "daily" or "weekly" (default: "weekly")
104    #[serde(default = "default_scale")]
105    pub scale: String,
106
107    /// Currency symbol (default: "EUR")
108    #[serde(default = "default_currency")]
109    pub currency: String,
110
111    /// Auto-fit timeframe to project duration (default: true)
112    #[serde(default = "default_true")]
113    pub auto_fit: bool,
114
115    /// Number of weeks (only used if auto_fit=false and scale=weekly)
116    #[serde(default)]
117    pub weeks: Option<u32>,
118
119    /// Number of days (only used if auto_fit=false and scale=daily)
120    #[serde(default)]
121    pub days: Option<u32>,
122
123    /// Working hours per day (default: 8.0)
124    #[serde(default = "default_hours_per_day")]
125    pub hours_per_day: f64,
126
127    /// Include executive summary sheet (default: true)
128    #[serde(default = "default_true")]
129    pub include_summary: bool,
130
131    /// Show dependency columns for formula-driven scheduling (default: true)
132    #[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    /// Convert this configuration into an ExcelRenderer
169    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        // Apply scale
175        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        // Apply auto-fit setting
189        if !self.auto_fit {
190            renderer = renderer.no_auto_fit();
191        }
192
193        // Apply other settings
194        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/// Excel costing report renderer
207#[derive(Clone, Debug)]
208pub struct ExcelRenderer {
209    /// Currency symbol
210    pub currency: String,
211    /// Number of weeks to show in schedule
212    pub schedule_weeks: u32,
213    /// Working hours per day
214    pub hours_per_day: f64,
215    /// Working hours per week (for duration calculations)
216    pub hours_per_week: f64,
217    /// Whether to include Executive Summary sheet
218    pub include_summary: bool,
219    /// Whether to include formulas (vs static values)
220    pub use_formulas: bool,
221    /// Project start date for schedule calculations
222    pub project_start: Option<NaiveDate>,
223    /// Default rate for resources without explicit rate
224    pub default_rate: f64,
225    /// Whether to show dependency columns and use formula-driven scheduling
226    pub show_dependencies: bool,
227    /// Whether to include Calendar Analysis sheet
228    pub include_calendar_analysis: bool,
229    /// Whether to include Diagnostics sheet
230    pub include_diagnostics: bool,
231    /// Diagnostics to include in the export (if include_diagnostics is true)
232    diagnostics: Vec<Diagnostic>,
233    /// Schedule time granularity (daily or weekly)
234    pub granularity: ScheduleGranularity,
235    /// Number of days to show in daily schedule (default: 60)
236    pub schedule_days: u32,
237    /// Calendar for working days/holidays (used in daily mode)
238    calendar: Option<Calendar>,
239    /// Auto-fit timeframe to project duration (default: true)
240    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, // Enable by default for live scheduling
255            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, // Auto-fit to project duration by default
262        }
263    }
264}
265
266impl ExcelRenderer {
267    pub fn new() -> Self {
268        Self::default()
269    }
270
271    /// Set currency symbol
272    pub fn currency(mut self, currency: impl Into<String>) -> Self {
273        self.currency = currency.into();
274        self
275    }
276
277    /// Set number of weeks in schedule
278    pub fn weeks(mut self, weeks: u32) -> Self {
279        self.schedule_weeks = weeks;
280        self
281    }
282
283    /// Set working hours per day
284    pub fn hours_per_day(mut self, hours: f64) -> Self {
285        self.hours_per_day = hours;
286        self
287    }
288
289    /// Disable Executive Summary sheet
290    pub fn no_summary(mut self) -> Self {
291        self.include_summary = false;
292        self
293    }
294
295    /// Use static values instead of formulas
296    pub fn static_values(mut self) -> Self {
297        self.use_formulas = false;
298        self
299    }
300
301    /// Set default rate for resources
302    pub fn default_rate(mut self, rate: f64) -> Self {
303        self.default_rate = rate;
304        self
305    }
306
307    /// Disable dependency columns (simpler output, no formula-driven scheduling)
308    pub fn no_dependencies(mut self) -> Self {
309        self.show_dependencies = false;
310        self
311    }
312
313    /// Set working hours per week (default 40)
314    pub fn hours_per_week(mut self, hours: f64) -> Self {
315        self.hours_per_week = hours;
316        self
317    }
318
319    /// Include Calendar Analysis sheet showing weekend/holiday impact per task
320    pub fn with_calendar_analysis(mut self) -> Self {
321        self.include_calendar_analysis = true;
322        self
323    }
324
325    /// Include Diagnostics sheet with all project diagnostics
326    pub fn with_diagnostics(mut self, diagnostics: Vec<Diagnostic>) -> Self {
327        self.include_diagnostics = true;
328        self.diagnostics = diagnostics;
329        self
330    }
331
332    /// Use daily granularity (one column per calendar day)
333    ///
334    /// Daily mode shows weekends and holidays with distinct styling,
335    /// making it ideal for short-term operational planning (1-3 months).
336    pub fn daily(mut self) -> Self {
337        self.granularity = ScheduleGranularity::Daily;
338        self
339    }
340
341    /// Set number of days to show in daily schedule (default: 60)
342    ///
343    /// Only used when `daily()` is enabled.
344    pub fn days(mut self, days: u32) -> Self {
345        self.schedule_days = days;
346        self
347    }
348
349    /// Set calendar for working days and holidays
350    ///
351    /// Used in daily mode to determine weekend/holiday styling.
352    /// If not set, defaults to Mon-Fri working days with no holidays.
353    pub fn with_calendar(mut self, calendar: Calendar) -> Self {
354        self.calendar = Some(calendar);
355        self
356    }
357
358    /// Disable auto-fit and use explicit weeks/days values
359    ///
360    /// By default, the renderer auto-fits the timeframe to cover the project
361    /// duration plus a buffer. Call this to use the fixed `schedule_weeks`
362    /// or `schedule_days` values instead.
363    pub fn no_auto_fit(mut self) -> Self {
364        self.auto_fit = false;
365        self
366    }
367
368    /// Calculate auto-fit weeks to cover project duration
369    ///
370    /// Returns the number of weeks needed to cover the full project
371    /// plus a 10% buffer (minimum 1 week).
372    ///
373    /// Uses the actual max task finish date (not schedule.project_end) to ensure
374    /// all tasks are covered, even if there's a discrepancy.
375    pub fn calculate_auto_fit_weeks(&self, schedule: &Schedule, project_start: NaiveDate) -> u32 {
376        // Find the actual max finish date from all tasks
377        let max_finish = schedule
378            .tasks
379            .values()
380            .map(|t| t.finish)
381            .max()
382            .unwrap_or(project_start);
383
384        // Use the later of schedule.project_end and max task finish
385        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; // Round up to complete weeks
389        let buffer = (weeks / 10).max(1); // 10% buffer, minimum 1 week
390        (weeks + buffer).max(1) // Ensure at least 1 week
391    }
392
393    /// Calculate auto-fit days to cover project duration
394    ///
395    /// Returns the number of days needed to cover the full project
396    /// plus a 10% buffer (minimum 5 days).
397    ///
398    /// Uses the actual max task finish date (not schedule.project_end) to ensure
399    /// all tasks are covered, even if there's a discrepancy.
400    pub fn calculate_auto_fit_days(&self, schedule: &Schedule, project_start: NaiveDate) -> u32 {
401        // Find the actual max finish date from all tasks
402        let max_finish = schedule
403            .tasks
404            .values()
405            .map(|t| t.finish)
406            .max()
407            .unwrap_or(project_start);
408
409        // Use the later of schedule.project_end and max task finish
410        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); // 10% buffer, minimum 5 days
414        (days + buffer).max(5) // Ensure at least 5 days
415    }
416
417    /// Get effective weeks (auto-fit or manual)
418    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    /// Get effective days (auto-fit or manual)
427    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    /// Generate Excel workbook bytes
436    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        // Create formats
444        let formats = self.create_formats();
445
446        // Build resource rate lookup (convert Money to f64)
447        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        // Get project start
461        let project_start = self.project_start.unwrap_or(project.start);
462
463        // Add sheets
464        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        // Add Calendar Analysis sheet if enabled
478        if self.include_calendar_analysis {
479            self.add_calendar_analysis_sheet(&mut workbook, project, schedule, &formats)?;
480        }
481
482        // Add Diagnostics sheet if enabled
483        if self.include_diagnostics {
484            self.add_diagnostics_sheet(&mut workbook, project, &formats)?;
485        }
486
487        // Save to buffer
488        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    /// Create reusable formats
496    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        // Alternating row colors for Schedule sheet (light blue/white banding per task)
538        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) // Light blue
546            .set_border(FormatBorder::Thin);
547
548        let row_odd_number = Format::new()
549            .set_num_format("#,##0.0")
550            .set_background_color(0xDDEBF7) // Light blue
551            .set_border(FormatBorder::Thin);
552
553        // Milestone row formats (light gold tint for semantic distinction)
554        let milestone_text = Format::new()
555            .set_background_color(0xFFF2CC) // Light gold
556            .set_border(FormatBorder::Thin);
557
558        let milestone_number = Format::new()
559            .set_num_format("#,##0.0")
560            .set_background_color(0xFFF2CC) // Light gold
561            .set_border(FormatBorder::Thin);
562
563        // Milestone week cell (gold diamond marker)
564        let milestone_week = Format::new()
565            .set_align(FormatAlign::Center)
566            .set_background_color(0xFFE699) // Slightly darker gold for emphasis
567            .set_border(FormatBorder::Thin);
568
569        // Container task formats (bold to distinguish phases from leaf tasks)
570        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) // Light blue
575            .set_border(FormatBorder::Thin);
576
577        // Week column empty formats for alternating row banding
578        // Filled color (blue) is applied via conditional formatting for what-if analysis
579        let gantt_even_empty = Format::new()
580            .set_align(FormatAlign::Center)
581            .set_border(FormatBorder::Thin); // White (matches even row)
582
583        let gantt_odd_empty = Format::new()
584            .set_background_color(0xDDEBF7) // Light blue (matches odd row)
585            .set_align(FormatAlign::Center)
586            .set_border(FormatBorder::Thin);
587
588        // Daily schedule: weekend formats (medium gray)
589        let weekend_header = Format::new()
590            .set_bold()
591            .set_align(FormatAlign::Center)
592            .set_background_color(0xA6A6A6) // Medium gray
593            .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) // Light gray
599            .set_border(FormatBorder::Thin);
600
601        // Daily schedule: holiday formats (gold/orange)
602        let holiday_header = Format::new()
603            .set_bold()
604            .set_align(FormatAlign::Center)
605            .set_background_color(0xED7D31) // Orange
606            .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) // Light orange/peach
612            .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    /// Add Profiles and Costs sheet
642    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        // Headers
656        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        // Column widths
671        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        // Calculate resource effort totals from schedule
678        // Use explicit effort_days if available, otherwise calculate from duration × units
679        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        // Write resource rows
695        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        // Total row
735        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    /// Add Schedule (Gantt) sheet with optional dependency support
768    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        // Branch based on granularity
777        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                // Continue with weekly implementation below
789            }
790        }
791
792        // Calculate effective weeks (applies auto-fit if enabled)
793        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        // Column layout depends on show_dependencies
801        // With deps: Task ID, Activity, M, Profile, Depends On, Type, Lag, Effort, Start, End, W1...
802        // Without:   Activity, M, Profile, pd, Start, End, W1...
803
804        // Column layout with Lvl column added after Activity:
805        // With deps: Task ID(0), Activity(1), Lvl(2), M(3), Profile(4), Depends(5), Type(6), Lag(7), Effort(8), Start(9), End(10), Weeks(11+)
806        // Without:   Activity(0), Lvl(1), M(2), Profile(3), pd(4), Start(5), End(6), Weeks(7+)
807        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) // Week columns start at L (col 11)
810        } else {
811            self.write_schedule_headers_simple(sheet, formats)?;
812            (7u16, 4u16, 5u16, 6u16) // Week columns start at H (col 7)
813        };
814
815        // Week column headers
816        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        // Set row height for header (rotated text)
825        sheet.set_row_height(0, 50).ok();
826
827        // Collect tasks in WBS order (depth-first traversal of project hierarchy)
828        let wbs_order = Self::collect_wbs_order(&project.tasks, 0);
829
830        // Map scheduled tasks to WBS order
831        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        // Build set of all full task IDs for predecessor resolution
837        let all_full_ids: std::collections::HashSet<String> =
838            tasks.iter().map(|(st, _)| st.task_id.clone()).collect();
839
840        // Build mapping from simple task IDs to full path IDs for VLOOKUP resolution
841        // e.g., "gnu_analysis" -> "os_migration.gnu_val.gnu_analysis"
842        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        // Build task row mapping for VLOOKUP (task_id -> row number)
856        let mut task_row_map: HashMap<String, u32> = HashMap::new();
857        let mut current_row = 2u32; // Excel rows are 1-indexed, data starts at row 2
858        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        // Write task rows
869        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            // Toggle alternating row color when task changes
874            if scheduled.task_id != prev_task_id {
875                is_odd = !is_odd;
876                prev_task_id = scheduled.task_id.clone();
877            }
878            // Extract the simple task ID from the path (e.g., "task_5.task_6.task_7" -> "task_7")
879            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            // Check if this is a container task (has children)
887            // Container tasks have derived duration (span of children) which should NOT
888            // be counted as effort to avoid double-counting
889            let is_container = task.map(|t| !t.children.is_empty()).unwrap_or(false);
890
891            // Check if this is a milestone (explicit milestone: true attribute)
892            let is_milestone = task.map(|t| t.milestone).unwrap_or(false);
893
894            // Get base task name and add indentation for hierarchy
895            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            // Get first predecessor (if any) for dependency column
902            // Resolve simple predecessor ID to full path for VLOOKUP compatibility
903            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                    // Resolve predecessor ID to full path for VLOOKUP compatibility
915                    // Handle: simple IDs ("gnu_analysis"), partial paths ("gnu_val.gnu_analysis"),
916                    // and full paths ("os_migration.gnu_val.gnu_analysis")
917                    let full_predecessor = if all_full_ids.contains(&d.predecessor) {
918                        // Already a full path
919                        d.predecessor.clone()
920                    } else if let Some(full) = simple_to_full_id.get(&d.predecessor) {
921                        // Simple ID -> full path
922                        full.clone()
923                    } else {
924                        // Partial path - find full path that ends with this suffix
925                        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            // Calculate week numbers relative to project start
939            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            // For container tasks, effort is 0 (their duration is derived from children)
943            // Only leaf tasks contribute actual effort
944            let duration_days = if is_container {
945                0.0
946            } else {
947                scheduled.duration.as_days()
948            };
949
950            // If task has assignments, create a row per assignment
951            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                // One row per assignment
1003                let mut first_assignment = true;
1004                for assignment in &scheduled.assignments {
1005                    // Use explicit effort_days if available, otherwise calculate from duration × units
1006                    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                    // Only show dependency info on first row for this task
1015                    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        // Total row for each week column
1076        self.write_schedule_totals(
1077            sheet,
1078            row,
1079            week_start_col,
1080            effort_col,
1081            formats,
1082            effective_weeks,
1083        )?;
1084
1085        // Add conditional formatting for week columns: blue fill when numeric value > 0
1086        // Uses ISNUMBER check to exclude milestones ("◆") and empty cells ("")
1087        // This enables dynamic what-if analysis - colors update when effort/dependencies change
1088        let last_week_col = week_start_col + effective_weeks as u16 - 1;
1089        let last_data_row_for_cf = row - 1; // Exclude totals row from conditional formatting
1090        if last_data_row_for_cf >= 1 {
1091            // Create format for filled cells (blue background for Gantt bar)
1092            let gantt_filled_format = Format::new()
1093                .set_background_color(0x5B9BD5) // Standard blue for Gantt bar
1094                .set_align(FormatAlign::Center)
1095                .set_border(FormatBorder::Thin);
1096
1097            // Formula-based conditional format: apply blue fill only when cell is numeric AND > 0
1098            // This excludes milestones ("◆" text) and empty cells ("") from blue formatting
1099            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            // Apply to entire week column range (rows 1 to last_data_row, columns week_start to last_week)
1109            sheet
1110                .add_conditional_format(
1111                    1, // Start row (after header)
1112                    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        // Freeze first row and fixed columns
1121        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    /// Add Daily Schedule sheet with calendar awareness (weekends/holidays)
1128    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        // Get calendar for working day detection
1142        let calendar = self.calendar.clone().unwrap_or_else(|| {
1143            // Try to get from project, otherwise use default
1144            project.calendars.first().cloned().unwrap_or_default()
1145        });
1146
1147        // Column layout (same as weekly with dependencies):
1148        // Task ID(0), Activity(1), Lvl(2), M(3), Profile(4), Depends(5), Type(6), Lag(7), Effort(8), Start(9), End(10), Days(11+)
1149        let day_start_col = 11u16;
1150        let effort_col = 8u16;
1151        let start_col = 9u16;
1152        let end_col = 10u16;
1153
1154        // Write base headers (same as weekly with deps)
1155        self.write_daily_schedule_headers(sheet, formats, project_start, &calendar)?;
1156
1157        // Collect tasks in WBS order
1158        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        // Build predecessor resolution maps
1165        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        // Track last data row (for future formula-driven mode)
1182        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        // Write task rows
1195        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            // Toggle alternating row color when task changes
1201            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            // Get dependency info
1222            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        // Add conditional formatting for day columns
1331        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        // Freeze header row and fixed columns
1360        sheet.set_freeze_panes(1, 10).ok();
1361
1362        Ok(())
1363    }
1364
1365    /// Write headers for daily schedule
1366    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        // Fixed column headers
1374        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        // Column widths for fixed columns
1394        sheet.set_column_width(0, 12).ok(); // Task ID
1395        sheet.set_column_width(1, 25).ok(); // Activity
1396        sheet.set_column_width(2, 3).ok(); // Lvl
1397        sheet.set_column_width(3, 3).ok(); // M
1398        sheet.set_column_width(4, 12).ok(); // Profile
1399        sheet.set_column_width(5, 10).ok(); // Depends On
1400        sheet.set_column_width(6, 5).ok(); // Type
1401        sheet.set_column_width(7, 5).ok(); // Lag
1402        sheet.set_column_width(8, 6).ok(); // Effort
1403        sheet.set_column_width(9, 8).ok(); // Start
1404        sheet.set_column_width(10, 8).ok(); // End
1405
1406        // Day column headers with date and weekend/holiday styling
1407        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            // Format header: "M 6/1" (weekday + date)
1413            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            // Check if it's a holiday
1425            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            // Choose header format based on day type
1431            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        // Set row height for header
1446        sheet.set_row_height(0, 40).ok();
1447
1448        Ok(())
1449    }
1450
1451    /// Write a daily schedule row
1452    #[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        // Select formats based on row type
1480        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        // Col A: Task ID
1499        sheet
1500            .write_with_format(row, 0, task_id, text_fmt)
1501            .map_err(|e| RenderError::Format(e.to_string()))?;
1502
1503        // Col B: Activity
1504        sheet
1505            .write_with_format(row, 1, task_name, activity_fmt)
1506            .map_err(|e| RenderError::Format(e.to_string()))?;
1507
1508        // Col C: Lvl
1509        sheet
1510            .write_with_format(row, 2, level as f64, number_fmt)
1511            .map_err(|e| RenderError::Format(e.to_string()))?;
1512
1513        // Col D: Milestone marker
1514        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        // Col E: Profile
1520        sheet
1521            .write_with_format(row, 4, profile, text_fmt)
1522            .map_err(|e| RenderError::Format(e.to_string()))?;
1523
1524        // Col F: Depends On
1525        sheet
1526            .write_with_format(row, 5, predecessor, text_fmt)
1527            .map_err(|e| RenderError::Format(e.to_string()))?;
1528
1529        // Col G: Type
1530        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        // Col H: Lag
1536        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        // Col I: Effort
1547        sheet
1548            .write_with_format(row, effort_col, person_days, number_fmt)
1549            .map_err(|e| RenderError::Format(e.to_string()))?;
1550
1551        // Col J: Start (date format)
1552        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        // Col K: End (date format)
1558        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        // Day columns
1564        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    /// Write day columns for a task row
1583    ///
1584    /// Uses smart hour distribution to ensure the sum of displayed hours
1585    /// exactly matches the expected effort (no rounding errors).
1586    #[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        // Calculate total hours and working days for smart distribution
1603        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        // Smart distribution: base hours per day + remainder distributed across first N days
1607        // Example: 8h over 5 days -> base=1, remainder=3 -> [2,2,2,1,1] sums to 8
1608        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        // Track which working day we're on (for remainder distribution)
1617        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            // Check day type
1624            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            // Select cell format based on day type
1631            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            // Container tasks: no Gantt bar
1644            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            // Non-working days: always empty (no hours distributed)
1652            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            // Working day within task range
1660            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                    // Smart distribution: first `remainder` days get base+1, rest get base
1667                    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    /// Count working days between two dates (inclusive)
1698    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    /// Write headers for simple schedule (no dependencies)
1711    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        // Column widths
1732        sheet.set_column_width(0, 25).ok(); // Activity
1733        sheet.set_column_width(1, 3).ok(); // Lvl (nesting level)
1734        sheet.set_column_width(2, 3).ok(); // M (milestone marker)
1735        sheet.set_column_width(3, 15).ok(); // Profile
1736        sheet.set_column_width(4, 6).ok(); // pd
1737        sheet.set_column_width(5, 6).ok(); // Start
1738        sheet.set_column_width(6, 6).ok(); // End
1739
1740        Ok(())
1741    }
1742
1743    /// Write headers for schedule with dependencies
1744    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        // Column widths
1769        sheet.set_column_width(0, 12).ok(); // Task ID
1770        sheet.set_column_width(1, 25).ok(); // Activity
1771        sheet.set_column_width(2, 3).ok(); // Lvl (nesting level)
1772        sheet.set_column_width(3, 3).ok(); // M (milestone marker)
1773        sheet.set_column_width(4, 12).ok(); // Profile
1774        sheet.set_column_width(5, 10).ok(); // Depends On
1775        sheet.set_column_width(6, 5).ok(); // Type
1776        sheet.set_column_width(7, 5).ok(); // Lag
1777        sheet.set_column_width(8, 7).ok(); // Effort
1778        sheet.set_column_width(9, 6).ok(); // Start
1779        sheet.set_column_width(10, 6).ok(); // End
1780
1781        Ok(())
1782    }
1783
1784    /// Write a schedule row without dependency formulas
1785    #[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, // Effective weeks (auto-fit applied)
1806    ) -> Result<(), RenderError> {
1807        // Select formats: milestones use gold, otherwise alternate white/blue per task
1808        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        // Container tasks use bold text for Activity to distinguish phases
1817        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        // Col A: Activity (bold for containers)
1828        sheet
1829            .write_with_format(row, 0, task_name, activity_fmt)
1830            .map_err(|e| RenderError::Format(e.to_string()))?;
1831
1832        // Col B: Lvl (nesting level for hierarchy filtering/grouping)
1833        sheet
1834            .write_with_format(row, 1, level as f64, number_fmt)
1835            .map_err(|e| RenderError::Format(e.to_string()))?;
1836
1837        // Col C: Milestone marker (◆ for milestones, empty otherwise)
1838        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        // Col D: Profile
1844        sheet
1845            .write_with_format(row, 3, profile, text_fmt)
1846            .map_err(|e| RenderError::Format(e.to_string()))?;
1847
1848        // Col E: pd (effort)
1849        sheet
1850            .write_with_format(row, effort_col, person_days, number_fmt)
1851            .map_err(|e| RenderError::Format(e.to_string()))?;
1852
1853        // Col F: Start
1854        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        // Col G: End
1859        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        // Week columns (M column is at index 2 for simple layout)
1864        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    /// Write a schedule row with dependency formulas
1888    #[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, // Effective weeks (auto-fit applied)
1914    ) -> Result<(), RenderError> {
1915        let excel_row = row + 1; // Excel is 1-indexed
1916
1917        // Select formats: milestones use gold, otherwise alternate white/blue per task
1918        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        // Container tasks use bold text for Activity to distinguish phases
1927        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        // Col A: Task ID
1938        sheet
1939            .write_with_format(row, 0, task_id, text_fmt)
1940            .map_err(|e| RenderError::Format(e.to_string()))?;
1941
1942        // Col B: Activity (bold for containers)
1943        sheet
1944            .write_with_format(row, 1, task_name, activity_fmt)
1945            .map_err(|e| RenderError::Format(e.to_string()))?;
1946
1947        // Col C: Lvl (nesting level for hierarchy filtering/grouping)
1948        sheet
1949            .write_with_format(row, 2, level as f64, number_fmt)
1950            .map_err(|e| RenderError::Format(e.to_string()))?;
1951
1952        // Col D: Milestone marker (◆ for milestones, empty otherwise)
1953        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        // Col E: Profile
1959        sheet
1960            .write_with_format(row, 4, profile, text_fmt)
1961            .map_err(|e| RenderError::Format(e.to_string()))?;
1962
1963        // Col F: Depends On
1964        sheet
1965            .write_with_format(row, 5, predecessor, text_fmt)
1966            .map_err(|e| RenderError::Format(e.to_string()))?;
1967
1968        // Col G: Type (FS/SS/FF/SF)
1969        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        // Col H: Lag
1975        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        // Col I: Effort (pd)
1986        sheet
1987            .write_with_format(row, effort_col, person_days, number_fmt)
1988            .map_err(|e| RenderError::Format(e.to_string()))?;
1989
1990        // Col J: Start Week - Formula-driven if has predecessor
1991        // Column references with Lvl column:
1992        // F=Depends On, G=Type, H=Lag, I=Effort, J=Start, K=End
1993        // VLOOKUP range A:K, End=col 11, Start=col 10
1994        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        // Col K: End Week - Formula: Start + CEILING(effort * hours_per_day / hours_per_week) - 1
2037        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        // Week columns (M column is at index 3 for deps layout with Lvl)
2059        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    /// Write week columns with Gantt bar formulas
2083    ///
2084    /// Formula-driven rendering for what-if analysis:
2085    /// - Milestones: derived from M column ("◆"), shows "◆" in milestone week, "" elsewhere
2086    /// - Tasks: shows hours when > 0, "" when zero (no hidden zeros)
2087    /// - Container tasks: always empty (no effort to display)
2088    #[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, // Reserved for future conditional formatting of critical path
2096        is_milestone: bool,
2097        is_container: bool,
2098        is_odd: bool,
2099        formats: &ExcelFormats,
2100        week_start_col: u16,
2101        milestone_col: u16, // M column position for formula reference
2102        effort_col: u16,
2103        start_col: u16,
2104        end_col: u16,
2105        person_days: f64,
2106        schedule_weeks: u32, // Effective weeks (auto-fit applied)
2107    ) -> 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        // Select format based on milestone status and row alternation
2118        // Milestones get gold background, others get alternating white/blue
2119        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            // Container tasks: no Gantt bar (effort is 0, dates are derived from children)
2133            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                // Unified formula checking M column for milestone status:
2142                // =IF($M2="◆",
2143                //     IF(AND(week>=Start, week<=End), "◆", ""),
2144                //     IF(AND(week>=Start, week<=End, hours>0), hours, ""))
2145                //
2146                // - Milestones: "◆" if in range, "" otherwise
2147                // - Tasks: hours if in range AND > 0, "" otherwise
2148                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                // Static mode: compute value directly
2178                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    /// Write total row for schedule
2206    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, // Effective weeks (auto-fit applied)
2214    ) -> Result<(), RenderError> {
2215        if row <= 1 {
2216            return Ok(());
2217        }
2218
2219        // Write TOTAL label in first column
2220        sheet
2221            .write_with_format(row, 0, "TOTAL", &formats.total_row)
2222            .map_err(|e| RenderError::Format(e.to_string()))?;
2223
2224        // Fill empty cells up to effort column
2225        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        // SUM formula for effort (pd) column
2232        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        // Fill empty cells from effort+1 to week columns
2245        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        // Sum formulas for each week column
2252        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    /// Add Executive Summary sheet
2271    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        // Project info section
2285        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        // Cost summary
2345        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        // Calculate totals
2353        // Use explicit effort_days if available, otherwise calculate from duration × units
2354        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        // Column widths
2397        sheet.set_column_width(0, 20).ok();
2398        sheet.set_column_width(1, 25).ok();
2399
2400        Ok(())
2401    }
2402
2403    /// Add Calendar Analysis sheet showing weekend/holiday impact per task
2404    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        // Headers
2417        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        // Column widths
2436        sheet.set_column_width(0, 15).ok(); // Task ID
2437        sheet.set_column_width(1, 25).ok(); // Task Name
2438        sheet.set_column_width(2, 12).ok(); // Calendar
2439        sheet.set_column_width(3, 12).ok(); // Duration
2440        sheet.set_column_width(4, 12).ok(); // Working Days
2441        sheet.set_column_width(5, 10).ok(); // Weekends
2442        sheet.set_column_width(6, 10).ok(); // Holidays
2443        sheet.set_column_width(7, 12).ok(); // Non-Working %
2444        sheet.set_column_width(8, 30).ok(); // Diagnostics
2445
2446        // Get project calendar for fallback
2447        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        // Collect tasks in WBS order
2455        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            // Get task info from schedule
2460            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                // Get the calendar for this task (use project calendar as fallback)
2466                let calendar = &project_calendar;
2467
2468                // Calculate calendar impact
2469                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                // Get diagnostics for this task
2476                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                // Write row
2484                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                // Non-working percentage with conditional formatting color
2507                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                // Diagnostics column
2528                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        // Freeze header row
2544        sheet.set_freeze_panes(1, 0).ok();
2545
2546        Ok(())
2547    }
2548
2549    /// Calculate calendar impact for a scheduled task
2550    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            // Check if it's a holiday
2564            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; // Avoid infinite loop for zero-duration tasks
2580            }
2581        }
2582
2583        (working_days, weekend_days, holiday_days)
2584    }
2585
2586    /// Filter diagnostics relevant to a specific task
2587    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    /// Check if a diagnostic is relevant to a specific task
2596    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(&quoted_id)
2601            }
2602            DiagnosticCode::H004TaskUnconstrained => diagnostic.message.contains(&quoted_id),
2603            DiagnosticCode::W001AbstractAssignment | DiagnosticCode::H001MixedAbstraction => {
2604                diagnostic.message.contains(&quoted_id)
2605            }
2606            DiagnosticCode::W014ContainerDependency => diagnostic.message.contains(&quoted_id),
2607            _ => false,
2608        }
2609    }
2610
2611    /// Add Diagnostics sheet with all project diagnostics
2612    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        // Headers
2624        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        // Column widths
2632        sheet.set_column_width(0, 8).ok(); // Code
2633        sheet.set_column_width(1, 10).ok(); // Severity
2634        sheet.set_column_width(2, 60).ok(); // Message
2635        sheet.set_column_width(3, 40).ok(); // Hint
2636
2637        // Sort diagnostics by severity (Error first, then Warning, Hint, Info)
2638        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        // Write diagnostic rows
2647        for (i, diag) in sorted_diags.iter().enumerate() {
2648            let row = (i + 1) as u32;
2649
2650            // Color code by severity
2651            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        // Add summary section at the bottom
2689        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        // Freeze header row
2747        sheet.set_freeze_panes(1, 0).ok();
2748
2749        Ok(())
2750    }
2751
2752    /// Convert date to week number relative to project start
2753    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    /// Convert column number to Excel letter (0 -> A, 25 -> Z, 26 -> AA)
2759    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    /// Collect task IDs in WBS (Work Breakdown Structure) order
2773    ///
2774    /// Performs depth-first traversal of the task hierarchy, returning
2775    /// full path task IDs with their nesting level for indentation.
2776    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    /// Helper for collect_wbs_order that tracks the parent path
2781    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            // Build the full path ID
2789            let full_id = if parent_path.is_empty() {
2790                task.id.clone()
2791            } else {
2792                format!("{}.{}", parent_path, task.id)
2793            };
2794
2795            // Add this task
2796            result.push((full_id.clone(), level));
2797
2798            // Recursively add children
2799            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
2811/// Reusable Excel formats
2812struct 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    // Alternating row colors for Schedule sheet (per-task banding)
2822    row_even_text: Format,
2823    row_even_number: Format,
2824    row_odd_text: Format,
2825    row_odd_number: Format,
2826    // Milestone formats (gold tint for semantic distinction)
2827    milestone_text: Format,
2828    milestone_number: Format,
2829    milestone_week: Format,
2830    // Container task formats (bold to distinguish phases from leaf tasks)
2831    container_even_text: Format,
2832    container_odd_text: Format,
2833    // Week column empty formats for alternating row banding (filled via conditional formatting)
2834    gantt_even_empty: Format,
2835    gantt_odd_empty: Format,
2836    // Daily schedule: weekend formats (gray background)
2837    weekend_header: Format,
2838    weekend_cell: Format,
2839    // Daily schedule: holiday formats (gold/orange background)
2840    holiday_header: Format,
2841    holiday_cell: Format,
2842}
2843
2844/// Renderer implementation that saves to file path
2845impl 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        // Add resources with Money rates
2868        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        // Add tasks
2885        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        // XLSX files start with PK (ZIP header)
3086        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        // Same day = week 1
3131        assert_eq!(renderer.date_to_week(start, start), 1);
3132
3133        // 6 days later = still week 1
3134        let day6 = NaiveDate::from_ymd_opt(2025, 1, 12).unwrap();
3135        assert_eq!(renderer.date_to_week(day6, start), 1);
3136
3137        // 7 days later = week 2
3138        let day7 = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
3139        assert_eq!(renderer.date_to_week(day7, start), 2);
3140
3141        // 14 days later = week 3
3142        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        // Dependencies are enabled by default
3179        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        // Should produce valid XLSX
3190        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        // Should produce valid XLSX
3207        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); // Part-time work week
3214
3215        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        // Test that with dependencies, changing predecessor would cascade
3227        // (We can't test actual Excel formula evaluation, but we can verify structure)
3228        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}