feat: implement project export to Excel with API endpoint and UI button
This commit is contained in:
@@ -54,6 +54,12 @@ A comprehensive project management system built with Next.js for managing constr
|
|||||||
- System activity monitoring
|
- System activity monitoring
|
||||||
- Audit trail for compliance and debugging
|
- Audit trail for compliance and debugging
|
||||||
|
|
||||||
|
### 📊 Data Export
|
||||||
|
|
||||||
|
- Export projects to Excel format grouped by status
|
||||||
|
- Includes project name, address, plot, WP, and finish date
|
||||||
|
- Separate sheets for each project status (registered, in progress, fulfilled, etc.)
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Framework**: Next.js 15.1.8
|
- **Framework**: Next.js 15.1.8
|
||||||
@@ -178,6 +184,7 @@ src/
|
|||||||
- `npm run build` - Build for production
|
- `npm run build` - Build for production
|
||||||
- `npm run start` - Start production server
|
- `npm run start` - Start production server
|
||||||
- `npm run lint` - Run ESLint
|
- `npm run lint` - Run ESLint
|
||||||
|
- `npm run export-projects` - Export all projects to Excel file grouped by status
|
||||||
|
|
||||||
## Docker Commands
|
## Docker Commands
|
||||||
|
|
||||||
|
|||||||
58
export-projects-to-excel.mjs
Normal file
58
export-projects-to-excel.mjs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { getAllProjects } from './src/lib/queries/projects.js';
|
||||||
|
|
||||||
|
function exportProjectsToExcel() {
|
||||||
|
try {
|
||||||
|
// Get all projects
|
||||||
|
const projects = getAllProjects();
|
||||||
|
|
||||||
|
// Group projects by status
|
||||||
|
const groupedProjects = projects.reduce((acc, project) => {
|
||||||
|
const status = project.project_status || 'unknown';
|
||||||
|
if (!acc[status]) {
|
||||||
|
acc[status] = [];
|
||||||
|
}
|
||||||
|
acc[status].push({
|
||||||
|
'Nazwa projektu': project.project_name,
|
||||||
|
'Adres': project.address || '',
|
||||||
|
'Działka': project.plot || '',
|
||||||
|
'WP': project.wp || '',
|
||||||
|
'Data zakończenia': project.finish_date || ''
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Polish status translations for sheet names
|
||||||
|
const statusTranslations = {
|
||||||
|
'registered': 'Zarejestrowany',
|
||||||
|
'in_progress_design': 'W realizacji (projektowanie)',
|
||||||
|
'in_progress_construction': 'W realizacji (budowa)',
|
||||||
|
'fulfilled': 'Zakończony',
|
||||||
|
'cancelled': 'Wycofany',
|
||||||
|
'unknown': 'Nieznany'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create workbook
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
// Create a sheet for each status
|
||||||
|
Object.keys(groupedProjects).forEach(status => {
|
||||||
|
const sheetName = statusTranslations[status] || status;
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(groupedProjects[status]);
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
const filename = `projects_export_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||||
|
XLSX.writeFile(workbook, filename);
|
||||||
|
|
||||||
|
console.log(`Excel file created: ${filename}`);
|
||||||
|
console.log(`Sheets created for statuses: ${Object.keys(groupedProjects).join(', ')}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting projects to Excel:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the export
|
||||||
|
exportProjectsToExcel();
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"create-admin": "node scripts/create-admin.js",
|
"create-admin": "node scripts/create-admin.js",
|
||||||
|
"export-projects": "node export-projects-to-excel.mjs",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"@testing-library/react": "^16.1.0",
|
"@testing-library/react": "^16.1.0",
|
||||||
"@testing-library/user-event": "^14.5.0",
|
"@testing-library/user-event": "^14.5.0",
|
||||||
"@types/leaflet": "^1.9.18",
|
"@types/leaflet": "^1.9.18",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.8",
|
"eslint-config-next": "15.1.8",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
|||||||
96
src/app/api/projects/export/route.js
Normal file
96
src/app/api/projects/export/route.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Force this API route to use Node.js runtime for database access and file operations
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { getAllProjects } from "@/lib/queries/projects";
|
||||||
|
import initializeDatabase from "@/lib/init-db";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withReadAuth } from "@/lib/middleware/auth";
|
||||||
|
import {
|
||||||
|
logApiActionSafe,
|
||||||
|
AUDIT_ACTIONS,
|
||||||
|
RESOURCE_TYPES,
|
||||||
|
} from "@/lib/auditLogSafe.js";
|
||||||
|
|
||||||
|
// Make sure the DB is initialized before queries run
|
||||||
|
initializeDatabase();
|
||||||
|
|
||||||
|
async function exportProjectsHandler(req) {
|
||||||
|
try {
|
||||||
|
// Get all projects
|
||||||
|
const projects = getAllProjects();
|
||||||
|
|
||||||
|
// Group projects by status
|
||||||
|
const groupedProjects = projects.reduce((acc, project) => {
|
||||||
|
const status = project.project_status || 'unknown';
|
||||||
|
if (!acc[status]) {
|
||||||
|
acc[status] = [];
|
||||||
|
}
|
||||||
|
acc[status].push({
|
||||||
|
'Nazwa projektu': project.project_name,
|
||||||
|
'Adres': project.address || '',
|
||||||
|
'Działka': project.plot || '',
|
||||||
|
'WP': project.wp || '',
|
||||||
|
'Data zakończenia': project.finish_date || ''
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Polish status translations for sheet names
|
||||||
|
const statusTranslations = {
|
||||||
|
'registered': 'Zarejestrowany',
|
||||||
|
'in_progress_design': 'W realizacji (projektowanie)',
|
||||||
|
'in_progress_construction': 'W realizacji (budowa)',
|
||||||
|
'fulfilled': 'Zakończony',
|
||||||
|
'cancelled': 'Wycofany',
|
||||||
|
'unknown': 'Nieznany'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create workbook
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
// Create a sheet for each status
|
||||||
|
Object.keys(groupedProjects).forEach(status => {
|
||||||
|
const sheetName = statusTranslations[status] || status;
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(groupedProjects[status]);
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate buffer
|
||||||
|
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||||
|
|
||||||
|
// Generate filename with current date
|
||||||
|
const filename = `projects_export_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||||
|
|
||||||
|
// Log the export action
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.DATA_EXPORT,
|
||||||
|
RESOURCE_TYPES.PROJECT,
|
||||||
|
null,
|
||||||
|
req.auth,
|
||||||
|
{
|
||||||
|
exportType: 'excel',
|
||||||
|
totalProjects: projects.length,
|
||||||
|
statuses: Object.keys(groupedProjects)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the Excel file
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting projects to Excel:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to export projects' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withReadAuth(exportProjectsHandler);
|
||||||
@@ -123,6 +123,28 @@ export default function ProjectListPage() {
|
|||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExportExcel = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/projects/export');
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `projects_export_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} else {
|
||||||
|
alert('Failed to export projects. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export error:', error);
|
||||||
|
alert('An error occurred during export. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleFilters = () => {
|
const toggleFilters = () => {
|
||||||
setFiltersExpanded(!filtersExpanded);
|
setFiltersExpanded(!filtersExpanded);
|
||||||
};
|
};
|
||||||
@@ -203,6 +225,27 @@ export default function ProjectListPage() {
|
|||||||
{t('projects.mine') || 'Moje'}
|
{t('projects.mine') || 'Moje'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={handleExportExcel}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t('projects.exportExcel') || 'Export to Excel'}
|
||||||
|
</Button>
|
||||||
<Link href="/projects/new" className="w-full sm:w-auto">
|
<Link href="/projects/new" className="w-full sm:w-auto">
|
||||||
<Button variant="primary" size="lg" className="w-full">
|
<Button variant="primary" size="lg" className="w-full">
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ const translations = {
|
|||||||
projects: "projektów",
|
projects: "projektów",
|
||||||
mapView: "Widok mapy",
|
mapView: "Widok mapy",
|
||||||
mine: "Moje",
|
mine: "Moje",
|
||||||
|
exportExcel: "Eksport do Excela",
|
||||||
createFirstProject: "Utwórz pierwszy projekt",
|
createFirstProject: "Utwórz pierwszy projekt",
|
||||||
noMatchingResults: "Brak projektów pasujących do kryteriów wyszukiwania. Spróbuj zmienić wyszukiwane frazy.",
|
noMatchingResults: "Brak projektów pasujących do kryteriów wyszukiwania. Spróbuj zmienić wyszukiwane frazy.",
|
||||||
showingResults: "Wyświetlono {shown} z {total} projektów",
|
showingResults: "Wyświetlono {shown} z {total} projektów",
|
||||||
@@ -742,6 +743,7 @@ const translations = {
|
|||||||
projects: "projects",
|
projects: "projects",
|
||||||
mapView: "Map View",
|
mapView: "Map View",
|
||||||
mine: "Mine",
|
mine: "Mine",
|
||||||
|
exportExcel: "Export to Excel",
|
||||||
createFirstProject: "Create first project",
|
createFirstProject: "Create first project",
|
||||||
noMatchingResults: "No projects match the search criteria. Try changing your search terms.",
|
noMatchingResults: "No projects match the search criteria. Try changing your search terms.",
|
||||||
showingResults: "Showing {shown} of {total} projects",
|
showingResults: "Showing {shown} of {total} projects",
|
||||||
|
|||||||
Reference in New Issue
Block a user