Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"next": "15.3.1",
"next-themes": "^0.4.6",
"pg": "^8.20.0",
"pino": "^9.5.0",
"puppeteer": "^24.42.0",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
Expand All @@ -78,6 +79,7 @@
"react-intersection-observer": "^10.0.3",
"react-virtualized-auto-sizer": "^1.0.7",
"react-window": "^1.8.9",
"reacts-cli": "^1.0.2",
"recharts": "^2.15.4",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
Expand Down
85 changes: 69 additions & 16 deletions src/app/api/exports/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,84 @@
*/

import { NextRequest, NextResponse } from 'next/server';
import { schedulerService, ExportOptions } from '@/lib/export-scheduler';
import { createLogger } from '@/lib/logging';
import { createCounterMetric } from '@/lib/logging/performance';
import { withRequestLogging } from '@/middleware/logger';
import {
ExportExecutionOptions,
ExportFilter,
ExportProgressState,
ExportSort,
prepareExportData,
} from '@/lib/export';
import { exportData, fetchDataForTemplate, getTemplate } from '@/lib/export-scheduler';

// Mock user ID - in production, get from auth session
const getCurrentUserId = (): string => 'user-123';
const routeLogger = createLogger('exports.execute');

function asFilters(value: unknown): ExportFilter[] | undefined {
return Array.isArray(value) ? (value as ExportFilter[]) : undefined;
}

function asSort(value: unknown): ExportSort[] | undefined {
return Array.isArray(value) ? (value as ExportSort[]) : undefined;
}

function asColumns(value: unknown): string[] | undefined {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : undefined;
}

export async function POST(request: NextRequest) {
try {
const userId = getCurrentUserId();
const body = await request.json();
return await withRequestLogging(request, 'exports.execute', async (requestId) => {
const body = await request.json();
const templateId = typeof body.templateId === 'string' ? body.templateId : '';

if (!templateId) {
return NextResponse.json({ error: 'Template ID is required' }, { status: 400 });
}

const template = await getTemplate(templateId);
if (!template) {
return NextResponse.json({ error: 'Template not found' }, { status: 404 });
}

const options: ExportOptions = {
templateId: body.templateId,
scheduleId: body.scheduleId,
immediate: true,
};
const progress: ExportProgressState[] = [];
const options: ExportExecutionOptions = {
filters: asFilters(body.filters),
sort: asSort(body.sort),
columns: asColumns(body.columns) ?? template.columns,
onProgress: (state) => {
progress.push(state);
},
};

const result = await schedulerService.executeExport(options, userId);
const sourceData = await fetchDataForTemplate(template);
const preparedData = prepareExportData(sourceData, options);
const { blob, fileName } = await exportData(template, sourceData, options);

if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 });
}
routeLogger.info('Export generated on demand', {
requestId,
context: {
templateId,
fileName,
rows: sourceData.rows.length,
},
metrics: [createCounterMetric('export.requests', 1, { format: template.format })],
});

return NextResponse.json({ result });
return NextResponse.json({
result: {
success: true,
fileName,
fileSize: blob.size,
contentType: blob.type,
rowCount: preparedData.rows.length,
progress,
},
});
});
} catch (error) {
console.error('Error executing export:', error);
routeLogger.error('Failed to execute export', { error });
return NextResponse.json({ error: 'Failed to execute export' }, { status: 500 });
}
}
114 changes: 114 additions & 0 deletions src/components/ExportButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { apiClient } from '@/lib/api';
import { ExportFilter, ExportProgressState, ExportSort } from '@/lib/export';

interface ExportButtonResult {
success: boolean;
fileName: string;
fileSize: number;
contentType: string;
rowCount: number;
progress?: ExportProgressState[];
}

interface ExportButtonProps {
templateId: string;
label?: string;
className?: string;
filters?: ExportFilter[];
sort?: ExportSort[];
columns?: string[];
onComplete?: (result: ExportButtonResult) => void;
}

export function ExportButton({
templateId,
label = 'Run Export',
className = '',
filters,
sort,
columns,
onComplete,
}: ExportButtonProps) {
const [isRunning, setIsRunning] = useState(false);
const [progress, setProgress] = useState<ExportProgressState | null>(null);
const [message, setMessage] = useState<string | null>(null);

const handleClick = async () => {
setIsRunning(true);
setMessage(null);
setProgress({
stage: 'preparing',
percent: 10,
message: 'Preparing export request',
});

try {
const response = await apiClient.post<{ result: ExportButtonResult }>(
'/api/exports/execute',
{
templateId,
filters,
sort,
columns,
},
);

const finalProgress =
response.result.progress?.[response.result.progress.length - 1] ?? {
stage: 'completed' as const,
percent: 100,
message: 'Export completed',
};

setProgress(finalProgress);
setMessage(
`${response.result.fileName} ready (${response.result.rowCount} rows, ${(
response.result.fileSize / 1024
).toFixed(2)} KB)`,
);
onComplete?.(response.result);
} catch (error) {
setProgress({
stage: 'completed',
percent: 100,
message: 'Export failed',
});
setMessage(error instanceof Error ? error.message : 'Export failed');
} finally {
setIsRunning(false);
}
};

return (
<div className="space-y-2">
<button
type="button"
onClick={handleClick}
disabled={isRunning}
className={className}
>
{isRunning ? 'Exporting...' : label}
</button>

{progress && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{progress.message}</span>
<span>{progress.percent}%</span>
</div>
<div className="h-2 rounded-full bg-gray-200" role="progressbar" aria-valuenow={progress.percent}>
<div
className="h-2 rounded-full bg-blue-600 transition-all"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
)}

{message && <p className="text-xs text-gray-600">{message}</p>}
</div>
);
}

export default ExportButton;
Loading
Loading