diff --git a/apps/admin/locale/en.json b/apps/admin/locale/en.json index 5d7b14f09..df08bdea2 100644 --- a/apps/admin/locale/en.json +++ b/apps/admin/locale/en.json @@ -657,6 +657,8 @@ "title": "Publish Event", "message": "Are you sure you want to publish this event? This will make the results public and visible to everyone.", "event-name": "Event: {eventName}", + "info": "If SendGrid integration is enabled, emails will be sent to team contacts with their results.", + "publishing-progress": "Publishing event...", "confirm": "Publish Event", "cancel": "Cancel", "publishing": "Publishing..." @@ -666,6 +668,7 @@ "message": "Download results as a ZIP file? This will include rubrics and scoresheets for all teams registered in this event.", "event-name": "Event: {eventName}", "info": "The ZIP file will be organized by team slug (e.g.,9999-IL) with PDFs inside each folder.", + "generating": "Generating results...", "confirm": "Download", "cancel": "Cancel", "downloading": "Downloading..." @@ -677,6 +680,7 @@ "complete-success": "Event completed successfully", "complete-error": "Failed to complete event", "publish-success": "Event published successfully", + "publish-success-with-emails": "Event published successfully! Sent {emailsSent} emails ({emailsFailed} failed).", "publish-error": "Failed to publish event", "download-success": "Event results downloaded successfully", "download-error": "Failed to download event results", diff --git a/apps/admin/locale/he.json b/apps/admin/locale/he.json index 5036328c6..d3f89fde1 100644 --- a/apps/admin/locale/he.json +++ b/apps/admin/locale/he.json @@ -657,6 +657,8 @@ "title": "פרסום אירוע", "message": "האם אתם בטוחים שברצונכם לפרסם את האירוע? פעולה זו תהפוך את התוצאות לציבוריות ונראות לכולם.", "event-name": "אירוע: {eventName}", + "info": "אם קיימת אינטגרציה עם SendGrid, יישלחו הודעות דוא\"ל ליצירי קשר של הקבוצה עם התוצאות שלהם.", + "publishing-progress": "מפרסם תוצאות...", "confirm": "פרסום אירוע", "cancel": "ביטול", "publishing": "מפרסם..." @@ -666,6 +668,7 @@ "message": "להוריד תוצאות כקובץ ZIP? זה יכלול מחווני שיפוט ודפי ניקוד לכל הקבוצות הרשומות באירוע זה.", "event-name": "אירוע: {eventName}", "info": "קובץ ה-ZIP יהיה מאורגן לפי סימון הקבוצה (לדוגמה, 9999-IL) עם קבצי PDF בתוך כל תיקייה.", + "generating": "מייצר תוצאות...", "confirm": "הורדה", "cancel": "ביטול", "downloading": "מוריד..." @@ -677,6 +680,7 @@ "complete-success": "האירוע הושלם בהצלחה", "complete-error": "סיום האירוע נכשל", "publish-success": "האירוע פורסם בהצלחה", + "publish-success-with-emails": "האירוע פורסם בהצלחה! נשלחו {emailsSent} הודעות דוא\"ל ({emailsFailed} נכשלו).", "publish-error": "פרסום האירוע נכשל", "download-success": "תוצאות האירוע הורדו בהצלחה", "download-error": "הורדת תוצאות האירוע נכשלה", diff --git a/apps/admin/locale/pl.json b/apps/admin/locale/pl.json index 2e195edb0..3665a6779 100644 --- a/apps/admin/locale/pl.json +++ b/apps/admin/locale/pl.json @@ -657,6 +657,8 @@ "title": "Opublikuj wydarzenie", "message": "Czy na pewno chcesz opublikować to wydarzenie? Wyniki będą widoczne dla wszystkich.", "event-name": "Wydarzenie: {eventName}", + "info": "Jeśli integracja SendGrid jest włączona, wiadomości e-mail z wynikami będą wysłane do kontaktów drużyn.", + "publishing-progress": "Publikowanie wydarzenia...", "confirm": "Opublikuj wydarzenie", "cancel": "Anuluj", "publishing": "Publikowanie..." @@ -666,6 +668,7 @@ "message": "Pobrać wyniki jako plik ZIP? Będzie zawierać rubryki i karty oceny dla wszystkich drużyn zarejestrowanych w tym wydarzeniu.", "event-name": "Wydarzenie: {eventName}", "info": "Plik ZIP będzie zorganizowany według identyfikatora drużyny (np. 9999-IL) z plikami PDF w każdym folderze.", + "generating": "Generowanie wyników...", "confirm": "Pobierz", "cancel": "Anuluj", "downloading": "Pobieranie..." @@ -677,6 +680,7 @@ "complete-success": "Wydarzenie zakończono pomyślnie", "complete-error": "Nie udało się zakończyć wydarzenia", "publish-success": "Wydarzenie opublikowano pomyślnie", + "publish-success-with-emails": "Wydarzenie opublikowano pomyślnie! Wysłano {emailsSent} wiadomości e-mail ({emailsFailed} nie powiodło się).", "publish-error": "Nie udało się opublikować wydarzenia", "download-success": "Wyniki wydarzenia pobrano pomyślnie", "download-error": "Nie udało się pobrać wyników wydarzenia", diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/download-results-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/download-results-dialog.tsx index 396b27608..75f74c916 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/download-results-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/download-results-dialog.tsx @@ -10,13 +10,15 @@ import { DialogActions, Typography, CircularProgress, - Stack + LinearProgress, + Stack, + Box } from '@mui/material'; interface DownloadResultsDialogProps { open: boolean; onClose: () => void; - onConfirm: () => Promise; + onConfirm: (onProgress: (percent: number) => void) => Promise; eventName: string; } @@ -27,14 +29,17 @@ export const DownloadResultsDialog: React.FC = ({ eventName }) => { const [isDownloading, setIsDownloading] = useState(false); + const [progress, setProgress] = useState(null); const t = useTranslations('pages.events.settings.dialogs.download-results'); const handleConfirm = async () => { setIsDownloading(true); + setProgress(0); try { - await onConfirm(); + await onConfirm(setProgress); } finally { setIsDownloading(false); + setProgress(null); } }; @@ -50,6 +55,14 @@ export const DownloadResultsDialog: React.FC = ({ {t('info')} + {progress !== null && ( + + + {t('generating')} + + + + )} diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx index 8e8b8ed99..d95873f81 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx @@ -5,7 +5,7 @@ import { KeyedMutator } from 'swr'; import { useLocale, useTranslations } from 'next-intl'; import { Box, Card, CardContent, Typography, Button, Divider, Stack } from '@mui/material'; import { EventSettings } from '@lems/types/api/admin'; -import { apiFetch } from '@lems/shared'; +import { apiFetch, connectSseStream, getApiBase } from '@lems/shared'; import { useEvent } from '../../components/event-context'; import { CompleteEventDialog } from './complete-event-dialog'; import { PublishEventDialog } from './publish-event-dialog'; @@ -29,7 +29,6 @@ export const EventActionsSection: React.FC = ({ const [completeDialogOpen, setCompleteDialogOpen] = useState(false); const [publishDialogOpen, setPublishDialogOpen] = useState(false); const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); - const [isDownloadLoading, setIsDownloadLoading] = useState(false); const handleCompleteEvent = async () => { setAlert(null); @@ -50,57 +49,71 @@ export const EventActionsSection: React.FC = ({ } }; - const handlePublishEvent = async () => { + const handlePublishEvent = async (onProgress: (percent: number, message?: string) => void) => { setAlert(null); try { - const response = await apiFetch(`/admin/events/${event.id}/settings/publish`, { - method: 'POST' - }); + const result = await connectSseStream<{ + published: boolean; + emailsSent: number; + emailsFailed: number; + failedEmails?: string[]; + }>( + `/admin/events/${event.id}/settings/publish`, + { method: 'POST' }, + { + onStart: () => onProgress(0), + onProgress: (percent, message) => onProgress(percent, message) + } + ); - if (response.ok) { + if (result?.published) { await mutateSettings(); - setAlert({ type: 'success', message: t('messages.publish-success') }); + const successMsg = + result.emailsSent > 0 + ? t('messages.publish-success-with-emails', { + emailsSent: result.emailsSent, + emailsFailed: result.emailsFailed + }) + : t('messages.publish-success'); + setAlert({ type: 'success', message: successMsg }); setPublishDialogOpen(false); } else { setAlert({ type: 'error', message: t('messages.publish-error') }); } - } catch { - setAlert({ type: 'error', message: t('messages.publish-error') }); + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : t('messages.publish-error'); + setAlert({ type: 'error', message: errorMsg }); } }; - const handleDownloadResults = async () => { + const handleDownloadResults = async (onProgress: (percent: number) => void) => { setAlert(null); - setIsDownloadLoading(true); try { - const response = await apiFetch( + const result = await connectSseStream<{ token: string }>( `/admin/events/${event.id}/settings/download?language=${locale}`, + { method: 'POST' }, { - method: 'POST', - responseType: 'binary' + onStart: () => onProgress(0), + onProgress } ); - if (response.ok) { - const blob = response.data as Blob; - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${event.name.replace(/\s+/g, '_')}-results.zip`; - document.body.appendChild(a); - a.click(); - a.remove(); - window.URL.revokeObjectURL(url); - - setAlert({ type: 'success', message: t('messages.download-success') }); - setDownloadDialogOpen(false); - } else { + if (!result?.token) { setAlert({ type: 'error', message: t('messages.download-error') }); + return; } + + const a = document.createElement('a'); + a.href = `${getApiBase(true)}/admin/events/${event.id}/settings/download/file?token=${result.token}`; + document.body.appendChild(a); + a.click(); + a.remove(); + + setAlert({ type: 'success', message: t('messages.download-success') }); + setDownloadDialogOpen(false); } catch { setAlert({ type: 'error', message: t('messages.download-error') }); - } finally { - setIsDownloadLoading(false); } }; @@ -173,7 +186,6 @@ export const EventActionsSection: React.FC = ({ disabled={!settings.published} onClick={() => setDownloadDialogOpen(true)} sx={{ minWidth: 160 }} - loading={isDownloadLoading} > {t('event-actions.download-results')} diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/publish-event-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/publish-event-dialog.tsx index 489a2c4f6..187ceabd1 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/publish-event-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/publish-event-dialog.tsx @@ -9,13 +9,17 @@ import { DialogContent, DialogActions, Typography, - CircularProgress + CircularProgress, + LinearProgress, + Stack, + Box, + Alert } from '@mui/material'; interface PublishEventDialogProps { open: boolean; onClose: () => void; - onConfirm: () => Promise; + onConfirm: (onProgress: (percent: number, message?: string) => void) => Promise; eventName: string; } @@ -26,14 +30,23 @@ export const PublishEventDialog: React.FC = ({ eventName }) => { const [isPublishing, setIsPublishing] = useState(false); + const [progress, setProgress] = useState(null); + const [progressMessage, setProgressMessage] = useState(null); const t = useTranslations('pages.events.settings.dialogs.publish-event'); const handleConfirm = async () => { setIsPublishing(true); + setProgress(0); + setProgressMessage(null); try { - await onConfirm(); + await onConfirm((percent, message) => { + setProgress(percent); + if (message) setProgressMessage(message); + }); } finally { setIsPublishing(false); + setProgress(null); + setProgressMessage(null); } }; @@ -41,12 +54,34 @@ export const PublishEventDialog: React.FC = ({ {t('title')} - - {t('message')} - - - {t('event-name', { eventName })} - + + {t('message')} + + {t('event-name', { eventName })} + + + {t('info')} + + {progress !== null && ( + + {progressMessage ? ( + + + {progressMessage} + + + + ) : ( + + + {t('publishing-progress')} + + + + )} + + )} +