{{ theme.skipToContentLabel || 'Skip to content' }}

DOCX Export Progress Bar Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add a progress modal to the Weekly Reports DOCX export feature, showing step-based percentage progress (0-100%) with status text.

Architecture: Create a reusable ExportProgressModal component that displays export progress. Modify WeeklyReportService.exportAsDOCX() to accept an optional progress callback. Update both report pages (list and edit) to show the modal during export.

Tech Stack: React 19, TypeScript 5, Tailwind CSS 4, Iconify icons, docx library


Task 1: Create ExportProgressModal Component

Files:

  • Create: src/components/shared/ExportModal/ExportProgressModal.tsx
  • Modify: src/components/shared/ExportModal/index.ts

Step 1: Create the ExportProgressModal component

Create src/components/shared/ExportModal/ExportProgressModal.tsx:

tsx
/**
 * ExportProgressModal - Shows progress during DOCX export
 * Displays step-based percentage with status text and error handling
 */

import { Icon } from "@iconify/react";

export interface ExportProgressModalProps {
  isOpen: boolean;
  progress: number; // 0-100
  currentStep: string; // e.g., "Processing images..."
  currentDetail?: string; // e.g., "3 of 5"
  error?: string | null; // Error message if failed
  onRetry?: () => void; // Retry callback
  onCancel: () => void; // Cancel/close callback
}

export function ExportProgressModal({
  isOpen,
  progress,
  currentStep,
  currentDetail,
  error,
  onRetry,
  onCancel,
}: ExportProgressModalProps) {
  if (!isOpen) return null;

  const clampedProgress = Math.max(0, Math.min(100, progress));
  const isComplete = clampedProgress >= 100 && !error;

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
      <div className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-2xl overflow-hidden">
        {/* Header */}
        <div className="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
          <h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
            {error ? "Export Failed" : isComplete ? "Export Complete" : "Exporting Report"}
          </h2>
        </div>

        {/* Content */}
        <div className="px-6 py-6">
          {error ? (
            // Error State
            <div className="text-center">
              <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
                <Icon
                  className="w-8 h-8 text-red-600 dark:text-red-400"
                  icon="solar:danger-triangle-bold"
                />
              </div>
              <p className="text-sm text-red-600 dark:text-red-400 mb-4">
                {error}
              </p>
            </div>
          ) : isComplete ? (
            // Complete State
            <div className="text-center">
              <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
                <Icon
                  className="w-8 h-8 text-green-600 dark:text-green-400"
                  icon="solar:check-circle-bold"
                />
              </div>
              <p className="text-sm text-slate-600 dark:text-slate-400">
                Your report has been downloaded successfully.
              </p>
            </div>
          ) : (
            // Progress State
            <div className="space-y-4">
              {/* Progress Bar */}
              <div className="relative">
                <div className="flex items-center justify-between mb-2">
                  <span className="text-sm font-medium text-slate-700 dark:text-slate-300">
                    {currentStep}
                  </span>
                  <span className="text-sm font-medium text-slate-700 dark:text-slate-300">
                    {Math.round(clampedProgress)}%
                  </span>
                </div>
                <div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
                  <div
                    className="h-full bg-primary rounded-full transition-all duration-300 ease-out"
                    style={{ width: `${clampedProgress}%` }}
                  />
                </div>
                {currentDetail && (
                  <p className="mt-2 text-xs text-slate-500 dark:text-slate-400 text-center">
                    {currentDetail}
                  </p>
                )}
              </div>

              {/* Spinner */}
              <div className="flex justify-center">
                <Icon
                  className="w-6 h-6 text-primary animate-spin"
                  icon="svg-spinners:ring-resize"
                />
              </div>
            </div>
          )}
        </div>

        {/* Footer */}
        <div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3">
          {error ? (
            <>
              <button
                className="px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
                type="button"
                onClick={onCancel}
              >
                Cancel
              </button>
              {onRetry && (
                <button
                  className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
                  type="button"
                  onClick={onRetry}
                >
                  <Icon className="w-4 h-4" icon="solar:restart-bold" />
                  Retry
                </button>
              )}
            </>
          ) : isComplete ? (
            <button
              className="px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
              type="button"
              onClick={onCancel}
            >
              Done
            </button>
          ) : (
            <button
              className="px-4 py-2 text-sm font-medium text-slate-400 dark:text-slate-500 cursor-not-allowed rounded-lg"
              disabled
              type="button"
            >
              Please wait...
            </button>
          )}
        </div>
      </div>
    </div>
  );
}

Step 2: Export from index

Modify src/components/shared/ExportModal/index.ts - add the new export:

typescript
export { BaseExportModal } from "./BaseExportModal";
export type {
  ChartOption,
  ModelOption,
  BaseExportConfig,
  BaseExportModalProps,
} from "./types";
export { ExportProgressModal } from "./ExportProgressModal";
export type { ExportProgressModalProps } from "./ExportProgressModal";

Step 3: Verify build passes

Run: pnpm build

Expected: Build succeeds with no TypeScript errors.

Step 4: Commit

bash
git add src/components/shared/ExportModal/ExportProgressModal.tsx src/components/shared/ExportModal/index.ts
git commit -m "feat(export): add ExportProgressModal component"

Task 2: Add Progress Callback Type to Types File

Files:

  • Modify: src/components/shared/ExportModal/types.ts

Step 1: Add ProgressCallback type

Add to src/components/shared/ExportModal/types.ts at the end of the file:

typescript
/**
 * Callback for reporting export progress
 * @param progress - Percentage complete (0-100)
 * @param step - Current step description
 * @param detail - Optional detail (e.g., "3 of 5")
 */
export type ExportProgressCallback = (
  progress: number,
  step: string,
  detail?: string
) => void;

Step 2: Verify build passes

Run: pnpm build

Expected: Build succeeds.

Step 3: Commit

bash
git add src/components/shared/ExportModal/types.ts
git commit -m "feat(export): add ExportProgressCallback type"

Task 3: Modify WeeklyReportService.exportAsDOCX with Progress Callback

Files:

  • Modify: src/features/weekly-reports/services/weeklyReportService.ts

Step 1: Update exportAsDOCX method signature and add progress reporting

Find the exportAsDOCX method (around line 585) and replace it entirely:

typescript
  /**
   * Export report as DOCX file
   * @param report - The report to export
   * @param onProgress - Optional callback for progress updates
   */
  static async exportAsDOCX(
    report: WeeklyReport,
    onProgress?: (progress: number, step: string, detail?: string) => void
  ): Promise<void> {
    const reportProgress = (progress: number, step: string, detail?: string) => {
      onProgress?.(progress, step, detail);
    };

    // Step 1: Loading libraries (0-10%)
    reportProgress(0, "Loading export libraries...");
    
    const {
      Document,
      Paragraph,
      TextRun,
      HeadingLevel,
      AlignmentType,
      ImageRun,
      convertInchesToTwip,
    } = await import("docx");
    const { saveAs } = await import("file-saver");

    reportProgress(10, "Building document...");

    const formatDate = (dateStr: string) => {
      const date = new Date(dateStr);
      return date.toLocaleDateString("en-AU", {
        day: "2-digit",
        month: "2-digit",
        year: "numeric",
      });
    };

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const children: any[] = [];

    // Title
    children.push(
      new Paragraph({
        text: "6.0 Report – Observations & Improvements",
        heading: HeadingLevel.HEADING_1,
        thematicBreak: true,
      })
    );

    children.push(
      new Paragraph({
        text: "Dust Ranger Deployment – Weekly Update",
        heading: HeadingLevel.HEADING_2,
      })
    );

    children.push(
      new Paragraph({
        children: [
          new TextRun({
            text: `Reporting Period: `,
            bold: true,
          }),
          new TextRun({
            text: `Week Ending ${formatDate(report.report_period_end)}`,
          }),
        ],
      })
    );

    children.push(
      new Paragraph({
        children: [
          new TextRun({
            text: `Welshpool Week Starting: `,
            bold: true,
          }),
          new TextRun({
            text: formatDate(report.welshpool_week_starting),
          }),
        ],
        spacing: { after: 400 },
      })
    );

    reportProgress(15, "Building document...");

    // Section 1: Site Observations
    children.push(
      new Paragraph({
        text: "1. Site Observations & Progression",
        heading: HeadingLevel.HEADING_2,
        spacing: { before: 400 },
      })
    );

    // Count total images for progress tracking
    let totalImages = 0;
    let processedImages = 0;
    
    if (report.site_observations.length > 0) {
      for (const site of report.site_observations) {
        for (const device of site.devices) {
          if (device.observations) {
            // Count images in observations (rough estimate based on img tags)
            const imgMatches = device.observations.match(/<img[^>]+>/g);
            if (imgMatches) {
              totalImages += imgMatches.length;
            }
          }
        }
      }
    }

    reportProgress(20, "Processing site observations...");

    if (report.site_observations.length > 0) {
      for (const site of report.site_observations) {
        children.push(
          new Paragraph({
            text: site.site_name,
            heading: HeadingLevel.HEADING_3,
          })
        );

        if (site.site_level_notes) {
          children.push(
            new Paragraph({
              text: site.site_level_notes,
              spacing: { after: 200 },
            })
          );
        }

        if (site.devices.length > 0) {
          for (const device of site.devices) {
            const deviceText = [
              new TextRun({ text: `${device.device_id}`, bold: true }),
            ];

            if (device.status) {
              deviceText.push(
                new TextRun({
                  text: ` - Status: ${device.status.replace(/_/g, " ")}`,
                })
              );
            }

            children.push(
              new Paragraph({
                children: deviceText,
                bullet: { level: 0 },
              })
            );

            if (device.observations) {
              try {
                // Update progress before processing each device's images
                const deviceImgCount = (device.observations.match(/<img[^>]+>/g) || []).length;
                
                const observationBlocks =
                  await WeeklyReportService.buildObservationDocxBlocks(
                    device.observations,
                    convertInchesToTwip(0.5),
                    ImageRun
                  );
                children.push(...observationBlocks);
                
                // Update progress after processing images
                processedImages += deviceImgCount;
                if (totalImages > 0) {
                  // Images processing is 25-85% of total progress
                  const imageProgress = 25 + (processedImages / totalImages) * 60;
                  reportProgress(
                    Math.round(imageProgress),
                    "Processing images...",
                    `${processedImages} of ${totalImages}`
                  );
                }
              } catch (error) {
                console.error(
                  "Failed to process device observations for DOCX:",
                  device.device_id,
                  error
                );
              }
            }
          }
        }
      }
    } else {
      children.push(
        new Paragraph({
          children: [
            new TextRun({
              text: "No site observations for this period.",
              italics: true,
            }),
          ],
        })
      );
    }

    reportProgress(85, "Generating file...");

    // Section 2: Flow Meter Usage
    children.push(
      new Paragraph({
        text: "2. Flow Meter Usage Updates",
        heading: HeadingLevel.HEADING_2,
        spacing: { before: 400 },
      })
    );

    if (report.flow_meter_date_range) {
      children.push(
        new Paragraph({
          children: [
            new TextRun({
              text: "Data Period: ",
              bold: true,
            }),
            new TextRun({
              text: `${formatDate(report.flow_meter_date_range.start_date)} - ${formatDate(report.flow_meter_date_range.end_date)}`,
            }),
          ],
          spacing: { after: 200 },
        })
      );
    }

    if (report.flow_meter_usage.length > 0) {
      report.flow_meter_usage.forEach((usage) => {
        const usageText = [
          new TextRun({ text: `${usage.site_name}: `, bold: true }),
          new TextRun({ text: usage.usage_dates }),
        ];
        if (usage.volume_used) {
          usageText.push(new TextRun({ text: `, ${usage.volume_used}` }));
        }
        if (usage.notes) {
          usageText.push(new TextRun({ text: ` - ${usage.notes}` }));
        }

        children.push(
          new Paragraph({
            children: usageText,
            bullet: { level: 0 },
          })
        );
      });
    } else {
      children.push(
        new Paragraph({
          children: [
            new TextRun({
              text: "No flow meter usage for this period.",
              italics: true,
            }),
          ],
        })
      );
    }

    // Section 3: Dashboard Updates
    children.push(
      new Paragraph({
        text: "3. Dashboard Updates & Issues",
        heading: HeadingLevel.HEADING_2,
        spacing: { before: 400 },
      })
    );

    if (report.dashboard_updates.length > 0) {
      report.dashboard_updates.forEach((update) => {
        children.push(
          new Paragraph({
            text: update.category,
            heading: HeadingLevel.HEADING_3,
          })
        );
        children.push(
          new Paragraph({
            text: update.description,
            spacing: { after: 200 },
          })
        );
      });
    } else {
      children.push(
        new Paragraph({
          children: [
            new TextRun({
              text: "No dashboard updates for this period.",
              italics: true,
            }),
          ],
        })
      );
    }

    // Section 4: Vendor Activities
    children.push(
      new Paragraph({
        text: "4. Connect Source / Other Vendors",
        heading: HeadingLevel.HEADING_2,
        spacing: { before: 400 },
      })
    );

    if (report.vendor_activities.length > 0) {
      report.vendor_activities.forEach((activity) => {
        children.push(
          new Paragraph({
            text: `${activity.vendor_name} - ${activity.activity_type}`,
            heading: HeadingLevel.HEADING_3,
          })
        );
        children.push(
          new Paragraph({
            text: activity.description,
            spacing: { after: 200 },
          })
        );
      });
    } else {
      children.push(
        new Paragraph({
          children: [
            new TextRun({
              text: "No vendor activities for this period.",
              italics: true,
            }),
          ],
        })
      );
    }

    // Section 5: Water Truck Testing
    children.push(
      new Paragraph({
        text: "5. Water Truck Project Testing",
        heading: HeadingLevel.HEADING_2,
        spacing: { before: 400 },
      })
    );

    if (report.water_truck_testing.sites) {
      children.push(
        new Paragraph({
          children: [
            new TextRun({ text: "Site(s) / location(s): ", bold: true }),
            new TextRun({ text: report.water_truck_testing.sites }),
          ],
          bullet: { level: 0 },
        })
      );
      children.push(
        new Paragraph({
          children: [
            new TextRun({ text: "Hardware used: ", bold: true }),
            new TextRun({ text: report.water_truck_testing.hardware }),
          ],
          bullet: { level: 0 },
        })
      );
      children.push(
        new Paragraph({
          children: [
            new TextRun({
              text: "Test summary:",
              bold: true,
            }),
          ],
          bullet: { level: 0 },
        })
      );
      children.push(
        new Paragraph({
          children: [
            new TextRun({ text: "What was tested: ", bold: true }),
            new TextRun({
              text: report.water_truck_testing.test_summary.what_tested,
            }),
          ],
          bullet: { level: 1 },
        })
      );
      children.push(
        new Paragraph({
          children: [
            new TextRun({ text: "Key issues observed: ", bold: true }),
            new TextRun({
              text: report.water_truck_testing.test_summary.issues_observed,
            }),
          ],
          bullet: { level: 1 },
        })
      );
      children.push(
        new Paragraph({
          children: [
            new TextRun({ text: "Requested improvements: ", bold: true }),
            new TextRun({
              text: report.water_truck_testing.test_summary
                .improvements_requested,
            }),
          ],
          bullet: { level: 1 },
        })
      );
      children.push(
        new Paragraph({
          children: [
            new TextRun({ text: "Next steps: ", bold: true }),
            new TextRun({
              text: report.water_truck_testing.test_summary.next_steps,
            }),
          ],
          bullet: { level: 1 },
        })
      );
    } else {
      children.push(
        new Paragraph({
          children: [
            new TextRun({
              text: "No water truck testing for this period.",
              italics: true,
            }),
          ],
        })
      );
    }

    // Section 6: Hardware & Installations
    children.push(
      new Paragraph({
        text: "6. Hardware & Installations",
        heading: HeadingLevel.HEADING_2,
        spacing: { before: 400 },
      })
    );

    if (report.hardware_installations.length > 0) {
      report.hardware_installations.forEach((hw) => {
        children.push(
          new Paragraph({
            children: [
              new TextRun({ text: hw.type, bold: true }),
              new TextRun({ text: ` at ${hw.location}: ${hw.details}` }),
            ],
            bullet: { level: 0 },
          })
        );
      });
    } else {
      children.push(
        new Paragraph({
          children: [
            new TextRun({
              text: "No hardware installations for this period.",
              italics: true,
            }),
          ],
        })
      );
    }

    // Section 7: Admin & Reporting
    children.push(
      new Paragraph({
        text: "7. Admin & Reporting",
        heading: HeadingLevel.HEADING_2,
        spacing: { before: 400 },
      })
    );

    if (report.admin_reporting.travel.length > 0) {
      children.push(
        new Paragraph({
          text: "Travel & logistics:",
          heading: HeadingLevel.HEADING_3,
        })
      );
      report.admin_reporting.travel.forEach((item) => {
        children.push(
          new Paragraph({
            children: [
              new TextRun({ text: `${item.type}: `, bold: true }),
              new TextRun({ text: item.details }),
            ],
            bullet: { level: 0 },
          })
        );
      });
    }

    if (report.admin_reporting.reporting.length > 0) {
      children.push(
        new Paragraph({
          text: "Reporting & records:",
          heading: HeadingLevel.HEADING_3,
        })
      );
      report.admin_reporting.reporting.forEach((item) => {
        children.push(
          new Paragraph({
            children: [
              new TextRun({ text: `${item.type}: `, bold: true }),
              new TextRun({ text: item.details }),
            ],
            bullet: { level: 0 },
          })
        );
      });
    }

    if (
      report.admin_reporting.travel.length === 0 &&
      report.admin_reporting.reporting.length === 0
    ) {
      children.push(
        new Paragraph({
          children: [
            new TextRun({
              text: "No admin activities for this period.",
              italics: true,
            }),
          ],
        })
      );
    }

    // Section 8: Other Tasks
    children.push(
      new Paragraph({
        text: "8. Other Tasks Completed",
        heading: HeadingLevel.HEADING_2,
        spacing: { before: 400 },
      })
    );

    let hasOtherTasks = false;

    if (report.other_tasks.site_support.length > 0) {
      children.push(
        new Paragraph({
          text: "Site support:",
          heading: HeadingLevel.HEADING_3,
        })
      );
      report.other_tasks.site_support.forEach((task) => {
        children.push(
          new Paragraph({
            text: task.description,
            bullet: { level: 0 },
          })
        );
      });
      hasOtherTasks = true;
    }

    if (report.other_tasks.stakeholder.length > 0) {
      children.push(
        new Paragraph({
          text: "Stakeholder engagement:",
          heading: HeadingLevel.HEADING_3,
        })
      );
      report.other_tasks.stakeholder.forEach((task) => {
        children.push(
          new Paragraph({
            text: task.description,
            bullet: { level: 0 },
          })
        );
      });
      hasOtherTasks = true;
    }

    if (report.other_tasks.internal.length > 0) {
      children.push(
        new Paragraph({
          text: "Internal improvement:",
          heading: HeadingLevel.HEADING_3,
        })
      );
      report.other_tasks.internal.forEach((task) => {
        children.push(
          new Paragraph({
            text: task.description,
            bullet: { level: 0 },
          })
        );
      });
      hasOtherTasks = true;
    }

    if (!hasOtherTasks) {
      children.push(
        new Paragraph({
          children: [
            new TextRun({
              text: "No other tasks for this period.",
              italics: true,
            }),
          ],
        })
      );
    }

    reportProgress(90, "Generating file...");

    // Create document
    const doc = new Document({
      sections: [
        {
          properties: {},
          children,
        },
      ],
    });

    // Generate and save
    const { Packer } = await import("docx");
    const blob = await Packer.toBlob(doc);
    
    reportProgress(95, "Starting download...");
    
    const filename = `Weekly_Report_${report.report_period_end}.docx`;
    saveAs(blob, filename);
    
    reportProgress(100, "Complete!");
  }

Step 2: Verify build passes

Run: pnpm build

Expected: Build succeeds with no TypeScript errors.

Step 3: Commit

bash
git add src/features/weekly-reports/services/weeklyReportService.ts
git commit -m "feat(export): add progress callback to exportAsDOCX"

Task 4: Integrate Progress Modal into Report List Page

Files:

  • Modify: src/app/(admin)/(pages)/report-template/index.tsx

Step 1: Add progress modal state and handlers

Add these imports at the top of the file (after existing imports):

typescript
import { ExportProgressModal } from "@/components/shared/ExportModal";

Add these state variables inside the ReportTemplatePage component (after the existing state declarations around line 16-23):

typescript
  // DOCX export progress state
  const [exportProgress, setExportProgress] = useState(0);
  const [exportStep, setExportStep] = useState("");
  const [exportDetail, setExportDetail] = useState<string | undefined>();
  const [exportError, setExportError] = useState<string | null>(null);
  const [showExportModal, setShowExportModal] = useState(false);
  const [exportingReport, setExportingReport] = useState<WeeklyReport | null>(null);

Step 2: Replace the handleExportDOCX function

Find and replace the handleExportDOCX function (around line 111-118):

typescript
  const handleExportDOCX = async (report: WeeklyReport) => {
    // Reset state
    setExportProgress(0);
    setExportStep("Initializing...");
    setExportDetail(undefined);
    setExportError(null);
    setExportingReport(report);
    setShowExportModal(true);

    try {
      await WeeklyReportService.exportAsDOCX(report, (progress, step, detail) => {
        setExportProgress(progress);
        setExportStep(step);
        setExportDetail(detail);
      });
    } catch (error) {
      console.error("Failed to export as DOCX:", error);
      setExportError(
        error instanceof Error
          ? error.message
          : "Failed to export as DOCX. Please try again."
      );
    }
  };

  const handleExportRetry = () => {
    if (exportingReport) {
      void handleExportDOCX(exportingReport);
    }
  };

  const handleExportCancel = () => {
    setShowExportModal(false);
    setExportingReport(null);
    setExportError(null);
  };

Step 3: Add the ExportProgressModal component

Add this JSX right before the closing </> of the return statement (before line 503):

tsx
      {/* Export Progress Modal */}
      <ExportProgressModal
        currentDetail={exportDetail}
        currentStep={exportStep}
        error={exportError}
        isOpen={showExportModal}
        progress={exportProgress}
        onCancel={handleExportCancel}
        onRetry={handleExportRetry}
      />

Step 4: Verify build passes

Run: pnpm build

Expected: Build succeeds.

Step 5: Commit

bash
git add src/app/\(admin\)/\(pages\)/report-template/index.tsx
git commit -m "feat(export): add progress modal to report list page"

Task 5: Integrate Progress Modal into Report Edit Page

Files:

  • Modify: src/app/(admin)/(pages)/report-template/edit/[id].tsx

Step 1: Add progress modal imports and state

Add this import at the top (after existing imports):

typescript
import { ExportProgressModal } from "@/components/shared/ExportModal";

Add these state variables inside the EditReportPage component (after line 28 where exporting state is declared):

typescript
  // DOCX export progress state
  const [exportProgress, setExportProgress] = useState(0);
  const [exportStep, setExportStep] = useState("");
  const [exportDetail, setExportDetail] = useState<string | undefined>();
  const [exportError, setExportError] = useState<string | null>(null);
  const [showExportModal, setShowExportModal] = useState(false);

Step 2: Replace the handleExport function

Find and replace the handleExport function (around line 96-125):

typescript
  const handleExport = async () => {
    // Reset state and show modal
    setExportProgress(0);
    setExportStep("Initializing...");
    setExportDetail(undefined);
    setExportError(null);
    setShowExportModal(true);
    setExporting(true);

    try {
      // Build a WeeklyReport object from current state
      const report: WeeklyReport = {
        id: id || "",
        user_id: user?.id || "",
        report_period_end: reportPeriodEnd,
        welshpool_week_starting: welshpoolWeekStarting,
        status: "draft",
        site_observations: siteObservations,
        flow_meter_usage: flowMeterUsage,
        flow_meter_date_range: flowMeterDateRange,
        dashboard_updates: dashboardUpdates,
        vendor_activities: vendorActivities,
        water_truck_testing: waterTruckTesting,
        hardware_installations: hardwareInstallations,
        admin_reporting: adminReporting,
        other_tasks: otherTasks,
        created_at: "",
        updated_at: "",
      };
      
      await WeeklyReportService.exportAsDOCX(report, (progress, step, detail) => {
        setExportProgress(progress);
        setExportStep(step);
        setExportDetail(detail);
      });
    } catch (error) {
      console.error("Failed to export report:", error);
      setExportError(
        error instanceof Error
          ? error.message
          : "Failed to export report. Please try again."
      );
    } finally {
      setExporting(false);
    }
  };

  const handleExportRetry = () => {
    void handleExport();
  };

  const handleExportCancel = () => {
    setShowExportModal(false);
    setExportError(null);
  };

Step 3: Add the ExportProgressModal component

Add this JSX right before the closing </main> tag (before line 376):

tsx
        {/* Export Progress Modal */}
        <ExportProgressModal
          currentDetail={exportDetail}
          currentStep={exportStep}
          error={exportError}
          isOpen={showExportModal}
          progress={exportProgress}
          onCancel={handleExportCancel}
          onRetry={handleExportRetry}
        />

Step 4: Verify build passes

Run: pnpm build

Expected: Build succeeds.

Step 5: Commit

bash
git add src/app/\(admin\)/\(pages\)/report-template/edit/\[id\].tsx
git commit -m "feat(export): add progress modal to report edit page"

Task 6: Final Verification

Step 1: Run full build

Run: pnpm build

Expected: Build succeeds with no errors.

Step 2: Run linter

Run: pnpm lint

Expected: No new lint errors introduced.

Step 3: Start dev server and manual test

Run: pnpm dev

Manual verification steps:

  1. Navigate to /report-template

  2. Click "Export" on any report, then "Download as DOCX"

  3. Verify progress modal appears with percentage and step text

  4. Verify modal shows "Complete!" state when finished

  5. Click "Done" to close

  6. Navigate to /report-template/edit/{id} for any report

  7. Click "Export DOCX" button

  8. Verify same progress modal behavior

Step 4: Final commit

bash
git add -A
git commit -m "feat(export): complete DOCX export progress bar implementation"

Summary

This implementation adds:

  1. ExportProgressModal - A reusable modal component showing progress percentage, step text, and error handling
  2. Progress callback in exportAsDOCX - Reports progress at key stages (loading libraries, building document, processing images, generating file, download)
  3. Integration in report list page - Shows progress when exporting from the reports table
  4. Integration in report edit page - Shows progress when exporting from the edit view

Progress stages:

  • 0-10%: Loading libraries
  • 10-25%: Building document structure
  • 25-85%: Processing images (with "X of Y" detail)
  • 85-95%: Generating file
  • 95-100%: Starting download