feat: add download functionality for upcoming projects report in Excel format

This commit is contained in:
2025-10-22 11:38:19 +02:00
parent af28be8112
commit 42668862fd
2 changed files with 237 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
import { NextResponse } from 'next/server';
import ExcelJS from 'exceljs';
import { getAllProjects } from '@/lib/queries/projects';
import { parseISO, isAfter, isBefore, startOfDay, addWeeks, differenceInDays } from 'date-fns';
export const dynamic = 'force-dynamic';
export async function GET(request) {
try {
const today = startOfDay(new Date());
const nextMonth = addWeeks(today, 5); // Next 5 weeks
// Get all projects
const allProjects = getAllProjects();
// Filter for upcoming projects (not fulfilled, not cancelled, have finish dates)
const upcomingProjects = allProjects
.filter(project => {
if (!project.finish_date) return false;
if (project.project_status === 'fulfilled' || project.project_status === 'cancelled') return false;
try {
const projectDate = parseISO(project.finish_date);
return isAfter(projectDate, today) && isBefore(projectDate, nextMonth);
} catch (error) {
return false;
}
})
.sort((a, b) => {
const dateA = parseISO(a.finish_date);
const dateB = parseISO(b.finish_date);
return dateA - dateB;
});
// Filter for overdue projects
const overdueProjects = allProjects
.filter(project => {
if (!project.finish_date) return false;
if (project.project_status === 'fulfilled' || project.project_status === 'cancelled') return false;
try {
const projectDate = parseISO(project.finish_date);
return isBefore(projectDate, today);
} catch (error) {
return false;
}
})
.sort((a, b) => {
const dateA = parseISO(a.finish_date);
const dateB = parseISO(b.finish_date);
return dateB - dateA; // Most recently overdue first
});
// Create workbook
const workbook = new ExcelJS.Workbook();
workbook.creator = 'Panel Zarządzania Projektami';
workbook.created = new Date();
// Status translations
const statusTranslations = {
registered: 'Zarejestrowany',
approved: 'Zatwierdzony',
pending: 'Oczekujący',
in_progress: 'W trakcie',
in_progress_design: 'W realizacji (projektowanie)',
in_progress_construction: 'W realizacji (realizacja)',
fulfilled: 'Zakończony',
cancelled: 'Wycofany',
};
// Create Upcoming Projects sheet
const upcomingSheet = workbook.addWorksheet('Nadchodzące terminy');
upcomingSheet.columns = [
{ header: 'Nazwa projektu', key: 'name', width: 35 },
{ header: 'Klient', key: 'customer', width: 25 },
{ header: 'Adres', key: 'address', width: 30 },
{ header: 'Działka', key: 'plot', width: 15 },
{ header: 'Data zakończenia', key: 'finish_date', width: 18 },
{ header: 'Dni do terminu', key: 'days_until', width: 15 },
{ header: 'Status', key: 'status', width: 25 },
{ header: 'Odpowiedzialny', key: 'assigned_to', width: 20 }
];
// Style header row
upcomingSheet.getRow(1).font = { bold: true };
upcomingSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF4472C4' }
};
upcomingSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
// Add upcoming projects data
upcomingProjects.forEach(project => {
const daysUntil = differenceInDays(parseISO(project.finish_date), today);
const row = upcomingSheet.addRow({
name: project.project_name,
customer: project.customer || '',
address: project.address || '',
plot: project.plot || '',
finish_date: project.finish_date,
days_until: daysUntil,
status: statusTranslations[project.project_status] || project.project_status,
assigned_to: project.assigned_to || ''
});
// Color code based on urgency
if (daysUntil <= 7) {
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFE0E0' } // Light red
};
} else if (daysUntil <= 14) {
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFF4E0' } // Light orange
};
}
});
// Create Overdue Projects sheet
if (overdueProjects.length > 0) {
const overdueSheet = workbook.addWorksheet('Przeterminowane');
overdueSheet.columns = [
{ header: 'Nazwa projektu', key: 'name', width: 35 },
{ header: 'Klient', key: 'customer', width: 25 },
{ header: 'Adres', key: 'address', width: 30 },
{ header: 'Działka', key: 'plot', width: 15 },
{ header: 'Data zakończenia', key: 'finish_date', width: 18 },
{ header: 'Dni po terminie', key: 'days_overdue', width: 15 },
{ header: 'Status', key: 'status', width: 25 },
{ header: 'Odpowiedzialny', key: 'assigned_to', width: 20 }
];
// Style header row
overdueSheet.getRow(1).font = { bold: true };
overdueSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE74C3C' }
};
overdueSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
// Add overdue projects data
overdueProjects.forEach(project => {
const daysOverdue = Math.abs(differenceInDays(parseISO(project.finish_date), today));
const row = overdueSheet.addRow({
name: project.project_name,
customer: project.customer || '',
address: project.address || '',
plot: project.plot || '',
finish_date: project.finish_date,
days_overdue: daysOverdue,
status: statusTranslations[project.project_status] || project.project_status,
assigned_to: project.assigned_to || ''
});
// Color code based on severity
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFE0E0' } // Light red
};
});
}
// Generate buffer
const buffer = await workbook.xlsx.writeBuffer();
// Generate filename with current date
const filename = `nadchodzace_projekty_${new Date().toISOString().split('T')[0]}.xlsx`;
// Return response with 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 generating upcoming projects report:', error);
return NextResponse.json(
{ error: 'Failed to generate report', details: error.message },
{ status: 500 }
);
}
}

View File

@@ -60,6 +60,7 @@ export default function ProjectCalendarPage() {
const [loading, setLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
const [viewMode, setViewMode] = useState('month'); // 'month' or 'upcoming'
const [downloading, setDownloading] = useState(false);
useEffect(() => {
fetch("/api/projects")
@@ -131,6 +132,37 @@ export default function ProjectCalendarPage() {
});
};
const handleDownloadReport = async () => {
setDownloading(true);
try {
const response = await fetch('/api/reports/upcoming-projects');
if (!response.ok) {
throw new Error('Failed to download report');
}
// Get the blob from the response
const blob = await response.blob();
// Create a download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nadchodzace_projekty_${new Date().toISOString().split('T')[0]}.xlsx`;
document.body.appendChild(link);
link.click();
// Clean up
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading report:', error);
alert('Błąd podczas pobierania raportu');
} finally {
setDownloading(false);
}
};
const renderCalendarGrid = () => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
@@ -383,6 +415,19 @@ export default function ProjectCalendarPage() {
</div>
</PageHeader>
<div className="mb-4 flex justify-end">
<button
onClick={handleDownloadReport}
disabled={downloading}
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed rounded transition-colors"
title={downloading ? 'Pobieranie...' : 'Eksportuj raport nadchodzących projektów do Excel'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" 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>
</button>
</div>
{viewMode === 'month' ? renderCalendarGrid() : renderUpcomingView()}
</PageContainer>
);