diff --git a/apps/backend/.env.example b/apps/backend/.env.example index d75b551..22ec7bb 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -3,4 +3,5 @@ NODE_ENV=development HOST=0.0.0.0 PORT=3333 MONGODB_URI=mongodb://localhost:27017 -# MONGODB_URI=mongodb://host.docker.internal:27017 # For local dev with docker \ No newline at end of file +# MONGODB_URI=mongodb://host.docker.internal:27017 # For local dev with docker +MTES_DOMAIN=http://localhost:3333 \ No newline at end of file diff --git a/apps/backend/src/routers/api/events/rounds.ts b/apps/backend/src/routers/api/events/rounds.ts index 2a41996..632a74f 100644 --- a/apps/backend/src/routers/api/events/rounds.ts +++ b/apps/backend/src/routers/api/events/rounds.ts @@ -294,4 +294,33 @@ router.get('/votedMembers/:roundId', async (req: Request, res: Response) => { res.json({ ok: true, votedMembers }); }); +router.get('/votedMembersWithDetails/:roundId', async (req: Request, res: Response) => { + const { roundId } = req.params; + if (!roundId) { + console.log('❌ Round ID is null or undefined'); + res.status(400).json({ ok: false, message: 'Round ID is missing' }); + return; + } + console.log(`⏬ Getting voted members with details for round ${roundId}`); + const votedMembers = await db.getVotedMembers(roundId); + if (!votedMembers) { + console.log(`❌ Could not get voted members`); + res.status(500).json({ ok: false, message: 'Could not get voted members' }); + return; + } + + // Get member details for each voted member + const votedMembersWithDetails = await Promise.all( + votedMembers.map(async (votingStatus) => { + const member = await db.getMember({ _id: votingStatus.memberId }); + return { + ...votingStatus, + member + }; + }) + ); + + res.json({ ok: true, votedMembers: votedMembersWithDetails }); +}); + export default router; diff --git a/apps/frontend/components/mtes/round-results-pdf.tsx b/apps/frontend/components/mtes/round-results-pdf.tsx new file mode 100644 index 0000000..9577d85 --- /dev/null +++ b/apps/frontend/components/mtes/round-results-pdf.tsx @@ -0,0 +1,677 @@ +import { + Box, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + GlobalStyles, + Grid, + Paper +} from '@mui/material'; +import { WithId } from 'mongodb'; +import { Member, Round, VotingStatus } from '@mtes/types'; +import { SignatureDisplay } from './signature-display'; // Assuming you have a SignatureDisplay component + +interface RoleResult { + contestant: WithId; + votes: number; +} + +interface VotingStatusWithMember extends WithId { + member: WithId; +} + +interface RoundResultsPdfProps { + round: WithId; + results: Record; + votedMembers: VotingStatusWithMember[]; + totalMembers: number; + eventName: string; + eventDate?: string; +} + +const processResults = (results: Record): Record => { + if (!results || typeof results !== 'object') { + return {}; + } + + const combineAndSort = (roleResults: RoleResult[]): RoleResult[] => { + if (!Array.isArray(roleResults) || roleResults.length === 0) { + return []; + } + + const whiteVotes = roleResults.filter(result => result.contestant.name.includes('פתק לבן')); + const nonWhiteVotes = roleResults.filter(result => !result.contestant.name.includes('פתק לבן')); + + let processedResults = nonWhiteVotes; + if (whiteVotes.length > 0) { + const totalWhiteVotes = whiteVotes.reduce((sum, result) => sum + result.votes, 0); + const combinedWhiteVote: RoleResult = { + contestant: { + _id: whiteVotes[0].contestant._id, + name: 'פתק לבן', + city: 'אין אמון באף אחד', + isPresent: true, + isMM: false + }, + votes: totalWhiteVotes + }; + processedResults = [...nonWhiteVotes, combinedWhiteVote]; + } + + return processedResults.sort((a, b) => b.votes - a.votes); + }; + + return Object.fromEntries( + Object.entries(results).map(([role, roleResults]) => [role, combineAndSort(roleResults)]) + ); +}; + +export const RoundResultsPdf = ({ + round, + results: initialResults, + votedMembers, + totalMembers, + eventName, + eventDate = '' +}: RoundResultsPdfProps) => { + const results = processResults(initialResults); + + if (!round || !results || !votedMembers || totalMembers <= 0) { + return ( + + + ❌ אין נתונים להצגה + + + ); + } + + return ( + <> + + + {/* Header */} + + + 🗳️ {eventName} + + {eventDate && ( + + 📅 {eventDate} + + )} + + 📊 תוצאות ההצבעה + + + + {/* Voting Summary - Moved to top */} + + + 📈 סיכום הצבעות + + + + + + ✅ {votedMembers.length} + + + הצביעו + + + + + / + + + + + 👥 {totalMembers} + + + סה"כ נוכחים + + + + + + 📊 {Math.round((votedMembers.length / totalMembers) * 100)}% השתתפות + + + + 💡 סף כשירות: 1-2 מועמדים = 66% • 3+ מועמדים = 50% + 1 + + + + {/* Results by Role */} + {round.roles?.map((role, roleIndex) => { + const roleResults = results[role.role] as RoleResult[]; + + if (!roleResults || !Array.isArray(roleResults) || roleResults.length === 0) { + return ( + + + {role.role} - אין תוצאות + + + ); + } + + // Sort results by votes in descending order + const sortedResults = [...roleResults].sort((a, b) => b.votes - a.votes); + + // Count non-white ballot contestants + const nonWhiteContestants = sortedResults.filter( + r => !r.contestant.name.includes('פתק לבן') + ); + const numContestants = nonWhiteContestants.length; + + // Determine threshold based on number of contestants + const requiredThreshold = numContestants <= 2 ? 66 : 50; + const thresholdVotersNeeded = + numContestants <= 2 + ? Math.ceil((66 / 100) * totalMembers) // 66% for 1-2 contestants (rounded up) + : Math.floor(totalMembers / 2) + 1; // 50% + 1 for 3+ contestants + + const numWinners = role.numWinners || 1; + + // Get ALL candidates that meet the threshold requirement (including white votes) + const candidatesAboveThreshold = sortedResults.filter( + r => r.votes >= thresholdVotersNeeded + ); + + // Take top numWinners from those above threshold + const actualWinners = candidatesAboveThreshold.slice(0, numWinners); + + return ( + + + 👑 {role.role} + {numWinners > 1 ? ` (${numWinners} נבחרים)` : ''} + + + + 🎯 סף נדרש: {requiredThreshold}% ({thresholdVotersNeeded} קולות) + + + + + + + + 🏆 + + + 👤 שם המועמד + + + 🏙️ עיר + + + 🗳️ קולות + + + 📊 אחוזים + + + ✅ סטטוס + + + + + {sortedResults.map((result: RoleResult, index: number) => { + const position = index + 1; + const isWinner = actualWinners.some( + w => w.contestant._id.toString() === result.contestant._id.toString() + ); + const isAboveThreshold = result.votes >= thresholdVotersNeeded; + const isWhiteVote = result.contestant.name.includes('פתק לבן'); + const percentage = Math.round((result.votes / totalMembers) * 100); + + // Determine emoji and status + let positionEmoji = ''; + let statusEmoji = ''; + let statusText = ''; + + if (isWinner) { + positionEmoji = + position === 1 + ? '🥇' + : position === 2 + ? '🥈' + : position === 3 + ? '🥉' + : '🏆'; + statusEmoji = '✅'; + statusText = 'נבחר'; + } else if (isWhiteVote) { + positionEmoji = '⚪'; + statusEmoji = '📄'; + statusText = 'פתק לבן'; + } else if (!isAboveThreshold && position <= numWinners) { + positionEmoji = '❌'; + statusEmoji = '🚫'; + statusText = 'לא עבר סף'; + } else { + positionEmoji = `#${position}`; + statusEmoji = '⏸️'; + statusText = 'לא נבחר'; + } + + return ( + + + {positionEmoji} + + + {result.contestant.name} + + + {result.contestant.city} + + + {result.votes} + + = requiredThreshold ? '#2e7d32' : '#666' + }} + > + {percentage}% + + + + {statusEmoji} + + {statusText} + + + + + ); + })} + +
+
+
+ ); + })} + + {/* Voters Section with Signatures */} + + + ✍️ רשימת מצביעים וחתימות + + + + {votedMembers.map((votingStatus, index) => { + const member = votingStatus.member; + const signatureData = votingStatus.signature as Record; + + return ( + + + + + {index + 1} + + + {member?.name || 'לא נמצא'} + + + + + {member?.city || 'לא נמצא'} •{' '} + {new Date(votingStatus.votedAt).toLocaleTimeString('he-IL', { + hour: '2-digit', + minute: '2-digit' + })} + + + + + + + + ); + })} + + +
+ + ); +}; diff --git a/apps/frontend/components/mtes/round-results.tsx b/apps/frontend/components/mtes/round-results.tsx index b4562dd..6f205e7 100644 --- a/apps/frontend/components/mtes/round-results.tsx +++ b/apps/frontend/components/mtes/round-results.tsx @@ -1,6 +1,9 @@ -import { Box, Typography, Avatar, Paper } from '@mui/material'; +import { Box, Typography, Avatar, Paper, Fab } from '@mui/material'; +import { PictureAsPdf as PdfIcon, Launch as LaunchIcon } from '@mui/icons-material'; import { WithId } from 'mongodb'; import { Member, Round, VotingStatus } from '@mtes/types'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; interface RoleResult { contestant: WithId; @@ -52,6 +55,29 @@ export const RoundResults = ({ electionThreshold = 50 }: RoundResultsProps) => { const results = processResults(initialResults); + const router = useRouter(); + const [isExporting, setIsExporting] = useState(false); + + const handleExportPdf = async () => { + try { + setIsExporting(true); + + // Get current event info if available + const eventId = router.query.eventId as string; + const queryParams = new URLSearchParams({ + roundId: round._id.toString(), + ...(eventId && { eventId }) + }); + + // Open simple export page in new tab - this shows the data as-is for printing + const exportUrl = `/mtes/export-results?${queryParams.toString()}`; + window.open(exportUrl, '_blank'); + } catch (error) { + console.error('Error opening export page:', error); + } finally { + setIsExporting(false); + } + }; if (!round || !results || !votedMembers || totalMembers <= 0) { return ( @@ -99,6 +125,37 @@ export const RoundResults = ({ {round.roles.map(role => { const roleResults = results[role.role] as RoleResult[]; + // Validate that roleResults exists and is an array + if (!roleResults || !Array.isArray(roleResults) || roleResults.length === 0) { + return ( + + + {role.role} (אין תוצאות) + + + אין תוצאות זמינות לתפקיד זה + + + ); + } + // Sort results by votes in descending order const sortedResults = [...roleResults].sort((a, b) => b.votes - a.votes); @@ -493,6 +550,39 @@ export const RoundResults = ({ סף כשירות משתנה לפי מספר מועמדים: 1-2 מועמדים = 66%, יותר = 50% + 1 + + {/* Export Buttons */} + + + + {isExporting ? 'מוריד...' : 'הורד PDF'} + + ); }; diff --git a/apps/frontend/components/mtes/signature-display.tsx b/apps/frontend/components/mtes/signature-display.tsx new file mode 100644 index 0000000..31b4621 --- /dev/null +++ b/apps/frontend/components/mtes/signature-display.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { Box } from '@mui/material'; + +interface SignatureDisplayProps { + signatureData: Record; + width?: number | string; + height?: number | string; + scale?: number; +} + +export const SignatureDisplay: React.FC = ({ + signatureData, + width = 120, + height = 60, + scale = 0.5 +}) => { + // Check if signature data exists and is valid + if ( + !signatureData || + typeof signatureData !== 'object' || + Object.keys(signatureData).length === 0 + ) { + return ( + + אין חתימה + + ); + } + + // Convert signature data to SVG paths + const createPathFromPoints = (points: number[][]): string => { + if (!Array.isArray(points) || points.length === 0) return ''; + + try { + let path = `M${points[0][0] * scale},${points[0][1] * scale}`; + for (let i = 1; i < points.length; i++) { + if (Array.isArray(points[i]) && points[i].length >= 2) { + path += ` L${points[i][0] * scale},${points[i][1] * scale}`; + } + } + return path; + } catch (error) { + console.warn('Error creating signature path:', error); + return ''; + } + }; + + // Get all valid paths from signature data + const allPaths = Object.values(signatureData) + .filter(points => Array.isArray(points) && points.length > 0) + .map(createPathFromPoints) + .filter(path => path.length > 0); + + // If no valid paths, show no signature message + if (allPaths.length === 0) { + return ( + + חתימה לא תקינה + + ); + } + + const svgWidth = typeof width === 'number' ? width : 120; + const svgHeight = typeof height === 'number' ? height : 60; + + return ( + + + {allPaths.map((path, index) => ( + + ))} + + + ); +}; diff --git a/apps/frontend/pages/mtes/export-pdf.tsx b/apps/frontend/pages/mtes/export-pdf.tsx new file mode 100644 index 0000000..7805115 --- /dev/null +++ b/apps/frontend/pages/mtes/export-pdf.tsx @@ -0,0 +1,163 @@ +import { GetServerSideProps, NextPage } from 'next'; +import { WithId } from 'mongodb'; +import { Member, Round, VotingStatus } from '@mtes/types'; +import { RoundResultsPdf } from '../../components/mtes/round-results-pdf'; +import { apiFetch } from '../../lib/utils/fetch'; +import Head from 'next/head'; + +interface RoleResult { + contestant: WithId; + votes: number; +} + +interface VotingStatusWithMember extends WithId { + member: WithId; +} + +interface ExportPdfPageProps { + round: WithId; + results: Record; + votedMembers: VotingStatusWithMember[]; + totalMembers: number; + eventName: string; + eventDate?: string; +} + +const ExportPdfPage: NextPage = ({ + round, + results, + votedMembers, + totalMembers, + eventName, + eventDate +}) => { + return ( + <> + + {eventName || 'תוצאות הצבעה - PDF'} + + + + + + ); +}; + +export const getServerSideProps: GetServerSideProps = async context => { + const { roundId, eventId } = context.query; + + try { + // Verify authentication + const user = await apiFetch(`/api/me`, undefined, context).then(res => + res.ok ? res.json() : null + ); + + if (!user) { + return { + redirect: { + destination: '/login', + permanent: false + } + }; + } + + // Fetch all rounds to find the specific round + const roundResponse = await apiFetch(`/api/events/rounds`, undefined, context); + if (!roundResponse.ok) { + console.error('Failed to fetch rounds:', roundResponse.status); + return { notFound: true }; + } + const allRounds = await roundResponse.json(); + const round = allRounds.find((r: any) => r._id.toString() === roundId); + + if (!round) { + console.error('Round not found:', roundId); + return { notFound: true }; + } + + // Fetch results + const resultsResponse = await apiFetch( + `/api/events/rounds/results/${roundId}`, + undefined, + context + ); + let results = {}; + if (resultsResponse.ok) { + const resultsData = await resultsResponse.json(); + results = resultsData.results || {}; + } else { + console.error('Failed to fetch results:', resultsResponse.status); + } + + // Fetch voting status with member details + const votingStatusResponse = await apiFetch( + `/api/events/rounds/votedMembersWithDetails/${roundId}`, + undefined, + context + ); + let votedMembers = []; + if (votingStatusResponse.ok) { + const votedData = await votingStatusResponse.json(); + votedMembers = votedData.votedMembers || []; + } else { + console.error('Failed to fetch voting status:', votingStatusResponse.status); + } + + // Fetch total members count + const membersResponse = await apiFetch(`/api/events/members`, undefined, context); + let totalMembers = 0; + if (membersResponse.ok) { + const allMembers = await membersResponse.json(); + totalMembers = allMembers.filter((member: any) => member.isPresent).length; + } else { + console.error('Failed to fetch members:', membersResponse.status); + totalMembers = votedMembers.length; + } + + // Fetch event details + let eventName = 'תוצאות הצבעה'; + let eventDate = ''; + + // Try to get the current event (there seems to be only one event at a time) + const eventResponse = await apiFetch(`/public/event`, undefined, context); + if (eventResponse.ok) { + const event = await eventResponse.json(); + eventName = event.name || eventName; + eventDate = event.date ? new Date(event.date).toLocaleDateString('he-IL') : ''; + } + + return { + props: { + round, + results, + votedMembers, + totalMembers, + eventName, + eventDate + } + }; + } catch (error) { + console.error('Error fetching export data:', error); + return { notFound: true }; + } +}; + +export default ExportPdfPage; diff --git a/apps/frontend/pages/mtes/export-results.tsx b/apps/frontend/pages/mtes/export-results.tsx new file mode 100644 index 0000000..ea391e5 --- /dev/null +++ b/apps/frontend/pages/mtes/export-results.tsx @@ -0,0 +1,350 @@ +import { GetServerSideProps, NextPage } from 'next'; +import { WithId } from 'mongodb'; +import { Member, Round, VotingStatus } from '@mtes/types'; +import { RoundResultsPdf } from '../../components/mtes/round-results-pdf'; +import { apiFetch } from '../../lib/utils/fetch'; +import { + Box, + Button, + Container, + Fab, + List, + ListItem, + ListItemIcon, + ListItemText, + Typography +} from '@mui/material'; +import { + Print as PrintIcon, + PictureAsPdf as PdfIcon, + CheckCircleOutline +} from '@mui/icons-material'; +import { useEffect, useState } from 'react'; + +interface RoleResult { + contestant: WithId; + votes: number; +} + +interface VotingStatusWithMember extends WithId { + member: WithId; +} + +interface ExportResultsPageProps { + round: WithId; + results: Record; + votedMembers: VotingStatusWithMember[]; + totalMembers: number; + eventName?: string; + eventDate?: string; +} + +const ExportResultsPage: NextPage = ({ + round, + results, + votedMembers, + totalMembers, + eventName, + eventDate +}) => { + const [isPrintMode, setIsPrintMode] = useState(false); + + // Debug logging + console.log('ExportResultsPage - Props received:', { + hasRound: !!round, + roundName: round?.name, + hasResults: !!results, + resultsKeys: results ? Object.keys(results) : [], + votedMembersCount: votedMembers?.length || 0, + totalMembers, + eventName, + eventDate + }); + + useEffect(() => { + // Check if we're in print mode (URL parameter) + const urlParams = new URLSearchParams(window.location.search); + setIsPrintMode(urlParams.get('print') === 'true'); + }, []); + + const handleGeneratePdf = async () => { + try { + // Open the same page with print=true parameter for PDF generation + const printUrl = `${window.location.origin}${ + window.location.pathname + }?print=true&${window.location.search.substring(1)}`; + + // For client-side PDF generation, we'll open in new window with print dialog + const printWindow = window.open(printUrl, '_blank', 'width=800,height=600'); + if (printWindow) { + printWindow.onload = () => { + setTimeout(() => { + printWindow.print(); + }, 500); + }; + } + } catch (error) { + console.error('Error generating PDF:', error); + } + }; + + // In print mode, hide control buttons and show only the PDF content + if (isPrintMode) { + return ( + + + + ); + } + + return ( + + {/* Control Buttons */} + + + + + {/* Instructions */} + + + הוראות ליצירת קובץ PDF + + + כדי ליצור קובץ PDF של התוצאות, לחץ על הכפתור "יצא ל-PDF". פעולה זו תפתח את חלון ההדפסה של + הדפדפן. + + + בחלון שייפתח, בחר באפשרות "שמור כ-PDF" (Save as PDF) כיעד ההדפסה. + + + הגדרות מומלצות לקבלת התוצאה הטובה ביותר: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* PDF Preview */} + +

+ תצוגה מקדימה של ה-PDF +

+ + + + +
+
+ ); +}; + +export const getServerSideProps: GetServerSideProps = async context => { + const { roundId, eventId } = context.query; + + try { + // Verify authentication + const user = await apiFetch(`/api/me`, undefined, context).then(res => + res.ok ? res.json() : null + ); + + if (!user) { + return { + redirect: { + destination: '/login', + permanent: false + } + }; + } + + // Fetch all rounds to find the specific round + const roundResponse = await apiFetch(`/api/events/rounds`, undefined, context); + if (!roundResponse.ok) { + console.error('Failed to fetch rounds:', roundResponse.status, roundResponse.statusText); + return { notFound: true }; + } + const allRounds = await roundResponse.json(); + const round = allRounds.find((r: any) => r._id.toString() === roundId); + + if (!round) { + console.error('Round not found:', roundId); + return { notFound: true }; + } + + // Fetch results + const resultsResponse = await apiFetch( + `/api/events/rounds/results/${roundId}`, + undefined, + context + ); + let results = {}; + if (resultsResponse.ok) { + const resultsData = await resultsResponse.json(); + results = resultsData.results || {}; + } else { + console.error('Failed to fetch results:', resultsResponse.status, resultsResponse.statusText); + // Continue with empty results rather than failing + } + + // Fetch voting status with member details + const votingStatusResponse = await apiFetch( + `/api/events/rounds/votedMembersWithDetails/${roundId}`, + undefined, + context + ); + let votedMembers = []; + if (votingStatusResponse.ok) { + const votedData = await votingStatusResponse.json(); + votedMembers = votedData.votedMembers || []; + } else { + console.error( + 'Failed to fetch voting status:', + votingStatusResponse.status, + votingStatusResponse.statusText + ); + } + + // Fetch total members count + const membersResponse = await apiFetch(`/api/events/members`, undefined, context); + let totalMembers = 0; + if (membersResponse.ok) { + const allMembers = await membersResponse.json(); + totalMembers = allMembers.filter((member: any) => member.isPresent).length; + } else { + console.error('Failed to fetch members:', membersResponse.status, membersResponse.statusText); + // Use voted members count as fallback + totalMembers = votedMembers.length; + } + + // Fetch event details if eventId is provided + let eventName = 'בחירות מועצות תלמידים אזוריות'; + let eventDate = ''; + + if (eventId) { + const eventResponse = await apiFetch(`/api/events/${eventId}`, undefined, context); + if (eventResponse.ok) { + const event = await eventResponse.json(); + eventName = event.name || eventName; + eventDate = event.date ? new Date(event.date).toLocaleDateString('he-IL') : ''; + } else { + console.error('Failed to fetch event:', eventResponse.status, eventResponse.statusText); + } + } + + // Log the data structure for debugging + console.log('Export page data:', { + roundId, + roundName: round.name, + roles: round.roles?.map((r: any) => r.role) || [], + resultsKeys: Object.keys(results), + votedMembersCount: votedMembers.length, + totalMembers + }); + + return { + props: { + round, + results, + votedMembers, + totalMembers, + eventName, + eventDate + } + }; + } catch (error) { + console.error('Error fetching export data:', error); + return { notFound: true }; + } +}; + +export default ExportResultsPage;