Skip to content

[FEATURE] Export - Frontend integration #147

@codewizdave

Description

@codewizdave

Wire up the export functionality in the frontend to use the backend IPC handlers. Replace the fake progress with real IPC communication.

Requirements

Pre-Validation (NEW)

  • Before export, query and display record count for each selected type
  • Show preview: "23 employees, 5 attachments will be exported"
  • Allow user to cancel before actual export
  • Disable export button while counting (or show spinner)

API Types

// src/renderer/src/types/export.ts

type DateRange = 'today' | '7days' | '30days' | 'all';
type ExportType = 'employees' | 'attachments' | 'media';
type ExportFormat = 'csv' | 'xlsx' | 'pdf';

type ExportOptions = {
  types: ExportType[];
  format: ExportFormat;
  dateRange: DateRange;
};

type ExportResult = {
  success: boolean;
  filePath?: string;
  recordCount: number;
  canceled?: boolean;
  error?: string;
};

type ExportProgress = {
  current: number;
  total: number;
  percent: number;
  stage: 'query' | 'export' | 'write';
};

type ExportPreview = {
  employees: number;
  attachments: number;
  media: number;
  total: number;
};

Preload API

// src/preload/index.ts

export interface ExportAPI {
  exportData: (options: ExportOptions) => Promise<ExportResult>;
  previewExport: (options: { types: ExportType[]; dateRange: DateRange }) => Promise<ExportPreview>;
  onExportProgress: (callback: (progress: ExportProgress) => void) => () => void;
  openFile: (filePath: string) => Promise<void>;
  openFolder: (folderPath: string) => Promise<void>;
}

IPC Handler - Preview

// src/core/ipc/database/handlers.ts

// Preview: just count records without exporting
ipcMain.handle('preview-export', async (_event, options: { types: ExportType[]; dateRange: DateRange }) => {
  const preview: ExportPreview = { employees: 0, attachments: 0, media: 0, total: 0 };

  if (options.types.includes('employees')) {
    preview.employees = countEmployees(options.dateRange);
    preview.total += preview.employees;
  }
  if (options.types.includes('attachments')) {
    preview.attachments = countAttachments(options.dateRange);
    preview.total += preview.attachments;
  }
  if (options.types.includes('media')) {
    preview.media = countMedia(options.dateRange);
    preview.total += preview.media;
  }

  return preview;
});

Export Action

// src/renderer/src/actions/database.ts
import { toast } from '@/utils/toast';
import { t } from '@/i18n';  // or your i18n setup

type ExportOptions = {
  types: ('employees' | 'attachments' | 'media')[];
  format: 'csv' | 'xlsx' | 'pdf';
  dateRange: 'today' | '7days' | '30days' | 'all';
};

type ExportResult = {
  success: boolean;
  filePath?: string;
  recordCount: number;
  canceled?: boolean;
  error?: string;
};

type ExportProgress = {
  current: number;
  total: number;
  percent: number;
  stage: 'query' | 'export' | 'write';
};

type ExportPreview = {
  employees: number;
  attachments: number;
  media: number;
  total: number;
};

// Preview action - shows count before export
const previewExport = async (
  types: ExportType[],
  dateRange: DateRange
): Promise<ExportPreview> => {
  return window.api.previewExport({ types, dateRange });
};

// Export action
const exportData = async (
  options: ExportOptions,
  onProgress?: (progress: ExportProgress) => void
): Promise<ExportResult> => {
  if (!options.types || options.types.length === 0) {
    toast({
      title: t('export.error.noTypes.title'),
      description: t('export.error.noTypes.description'),
      variant: 'destructive',
    });
    return { success: false, error: 'No types selected', recordCount: 0 };
  }

  const unsubscribe = onProgress
    ? window.api.onExportProgress(onProgress)
    : undefined;

  try {
    const result = await window.api.exportData(options);

    if (result.canceled) {
      return result;
    }

    if (result.success) {
      toast({
        title: t('export.success.title'),
        description: t('export.success.description', { count: result.recordCount }),
      });
    } else {
      toast({
        title: t('export.error.failed.title'),
        description: result.error || t('export.error.failed.description'),
        variant: 'destructive',
      });
    }

    return result;
  } catch (error) {
    const message = error instanceof Error ? error.message : t('export.error.unknown');
    toast({
      title: t('export.error.failed.title'),
      description: message,
      variant: 'destructive',
    });
    throw error;
  } finally {
    if (unsubscribe) {
      unsubscribe();
    }
  }
};

export { exportData, previewExport };

UI - Export Dialog with Preview

// src/renderer/src/components/right-sidebar.tsx

import { exportData, previewExport } from '@/actions/database';
import { t } from '@/i18n';

const [selectedTypes, setSelectedTypes] = useState<ExportType[]>([]);
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('csv');
const [dateRange, setDateRange] = useState<DateRange>('all');
const [isExporting, setIsExporting] = useState(false);
const [isPreviewing, setIsPreviewing] = useState(false);
const [exportPreview, setExportPreview] = useState<ExportPreview | null>(null);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportResult, setExportResult] = useState<ExportResult | null>(null);

// Preview when types or dateRange change
useEffect(() => {
  if (selectedTypes.length === 0) {
    setExportPreview(null);
    return;
  }

  const timer = setTimeout(async () => {
    setIsPreviewing(true);
    try {
      const preview = await previewExport(selectedTypes, dateRange);
      setExportPreview(preview);
    } catch (e) {
      setExportPreview(null);
    } finally {
      setIsPreviewing(false);
    }
  }, 300); // Debounce 300ms

  return () => clearTimeout(timer);
}, [selectedTypes, dateRange]);

const handleExport = async () => {
  if (selectedTypes.length === 0 || !exportPreview || exportPreview.total === 0) {
    toast({
      title: t('export.error.noData.title'),
      description: t('export.error.noData.description'),
      variant: 'destructive',
    });
    return;
  }

  setIsExporting(true);
  setExportProgress({ current: 0, total: 0, percent: 0, stage: 'query' });
  setExportResult(null);

  try {
    const result = await exportData(
      { types: selectedTypes, format: selectedFormat, dateRange },
      (progress) => setExportProgress(progress)
    );

    if (result.canceled) return;
    setExportResult(result);
  } finally {
    setIsExporting(false);
    setExportProgress(null);
  }
};

// Preview UI
{selectedTypes.length > 0 && (
  <div className="text-sm text-muted-foreground">
    {isPreviewing ? (
      <span>{t('export.preview.loading')}</span>
    ) : exportPreview ? (
      <span>
        {exportPreview.total > 0
          ? t('export.preview.ready', {
              employees: exportPreview.employees,
              attachments: exportPreview.attachments,
              media: exportPreview.media,
              total: exportPreview.total,
            })
          : t('export.preview.empty')}
      </span>
    ) : null}
  </div>
)}

i18n Keys

// src/i18n/locales/fr.json
{
  "export": {
    "title": "Exporter les donnees",
    "preview": {
      "loading": "Calcul en cours...",
      "ready": "{total} enregistrements a exporter ({employees} employes, {attachments} pieces jointes, {media} medias)",
      "empty": "Aucune donnee a exporter"
    },
    "success": {
      "title": "Export reussi",
      "description": "{count} enregistrements exportes"
    },
    "error": {
      "noTypes": {
        "title": "Export echoue",
        "description": "Selectionnez au moins un type de donnee"
      },
      "noData": {
        "title": "Aucune donnee",
        "description": "Aucune donnee a exporter avec ces filtres"
      },
      "failed": {
        "title": "Export echoue",
        "description": "Une erreur est survenue"
      },
      "unknown": "Erreur inconnue"
    },
    "actions": {
      "openFile": "Ouvrir le fichier",
      "openFolder": "Ouvrir le dossier",
      "close": "Fermer"
    }
  }
}

Dialog States

1. Initial State
   - Form with types, format, date range
   - Preview shows: "45 enregistrements a exporter"
   - Export button (enabled if records > 0)

2. Loading Preview
   - Form visible
   - Preview shows: "Calcul en cours..."
   - Export button disabled

3. No Data
   - Preview shows: "Aucune donnee a exporter"
   - Export button disabled

4. Exporting
   - Progress bar visible
   - Form disabled

5. Success
   - Success message + file path
   - "Ouvrir le fichier" button
   - "Ouvrir le dossier" button
   - "Fermer" button

6. Error
   - Error message
   - "Fermer" button (dialog stays open)

IPC Handler with File Operations

// src/core/ipc/database/handlers.ts
import { ipcMain, shell } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { app } from 'electron';
import { csvExporter } from '@/lib/exporters/csv-exporter';
import { excelExporter } from '@/lib/exporters/excel-exporter';
import { pdfExporter } from '@/lib/exporters/pdf-exporter';
import { getEmployeesForExport, getAttachmentsForExport, getMediaForExport } from '@/db/queries/export-queries';

const getExportDir = (): string => {
  const documentsPath = app.getPath('documents');
  const exportDir = path.join(documentsPath, 'WEMS', 'exports');

  if (!fs.existsSync(exportDir)) {
    fs.mkdirSync(exportDir, { recursive: true });
  }

  return exportDir;
};

const exportDataHandler = async (event: Electron.IpcMainInvokeEvent, options: ExportOptions) => {
  const sender = event.sender;

  try {
    if (!options.types || options.types.length === 0) {
      return { success: false, error: 'No export types selected', recordCount: 0 };
    }

    const exporter = options.format === 'csv'
      ? csvExporter
      : options.format === 'xlsx'
        ? excelExporter
        : pdfExporter;

    const ext = exporter.getExtension();
    const exportDir = getExportDir();
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const fileName = `export-${timestamp}.${ext}`;
    const filePath = path.join(exportDir, fileName);

    // Query data
    sendProgress(sender, { current: 0, total: 0, percent: 0, stage: 'query' });
    const data: Record<string, unknown[]> = {};
    let totalRecords = 0;

    for (let i = 0; i < options.types.length; i++) {
      const type = options.types[i];

      if (type === 'employees') {
        data.employees = getEmployeesForExport(options.dateRange);
        totalRecords += data.employees.length;
      } else if (type === 'attachments') {
        data.attachments = getAttachmentsForExport(options.dateRange);
        totalRecords += data.attachments.length;
      } else if (type === 'media') {
        data.media = getMediaForExport(options.dateRange);
        totalRecords += data.media.length;
      }

      sendProgress(sender, {
        current: i + 1,
        total: options.types.length,
        percent: Math.round(((i + 1) / options.types.length) * 50),
        stage: 'query',
      });
    }

    // Generate file
    sendProgress(sender, { current: 0, total: totalRecords, percent: 50, stage: 'export' });
    const buffer = await exporter.generate(data);

    // Write to disk
    sendProgress(sender, { current: 0, total: totalRecords, percent: 80, stage: 'write' });
    await fs.promises.writeFile(filePath, buffer);

    sendProgress(sender, { current: totalRecords, total: totalRecords, percent: 100, stage: 'write' });

    return { success: true, filePath, recordCount: totalRecords };
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Erreur inconnue';
    return { success: false, error: message, recordCount: 0 };
  }
};

const openFileHandler = async (_event: Electron.IpcMainInvokeEvent, filePath: string) => {
  await shell.openPath(filePath);
};

const openFolderHandler = async (_event: Electron.IpcMainInvokeEvent, folderPath: string) => {
  shell.showItemInFolder(folderPath);
};

ipcMain.handle('export-data', exportDataHandler);
ipcMain.handle('open-file', openFileHandler);
ipcMain.handle('open-folder', openFolderHandler);

Testing Checklist

  • Preview updates when types change
  • Preview updates when date range changes
  • Preview shows correct counts
  • Export disabled when no data
  • Export disabled when preview loading
  • Export with data works correctly
  • All strings use i18n keys

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions