scheduling features
This commit is contained in:
parent
aaba7df97d
commit
e95bc8883e
@ -329,13 +329,15 @@ ALERTMANAGER_EMBED_PORT=8895
|
|||||||
|
|
||||||
# --- SMS Campaigns (Termux Android Bridge) ---
|
# --- SMS Campaigns (Termux Android Bridge) ---
|
||||||
# ENABLE_SMS is the initial default; once saved in admin Settings, the DB value is authoritative
|
# ENABLE_SMS is the initial default; once saved in admin Settings, the DB value is authoritative
|
||||||
|
# URL + API key are typically managed via admin Settings page (DB overrides env)
|
||||||
|
# Use Tailscale IP (100.x.x.x) for stable addressing across networks
|
||||||
ENABLE_SMS=false
|
ENABLE_SMS=false
|
||||||
TERMUX_API_URL=http://10.0.0.193:5001
|
TERMUX_API_URL=http://100.x.x.x:5001
|
||||||
TERMUX_API_KEY=
|
TERMUX_API_KEY=
|
||||||
SMS_DELAY_BETWEEN_MS=3000
|
SMS_DELAY_BETWEEN_MS=3000
|
||||||
SMS_MAX_RETRIES=3
|
SMS_MAX_RETRIES=3
|
||||||
SMS_RESPONSE_SYNC_INTERVAL_MS=30000
|
SMS_RESPONSE_SYNC_INTERVAL_MS=120000
|
||||||
SMS_DEVICE_MONITOR_INTERVAL_MS=30000
|
SMS_DEVICE_MONITOR_INTERVAL_MS=300000
|
||||||
|
|
||||||
# --- Monitoring (only used with --profile monitoring) ---
|
# --- Monitoring (only used with --profile monitoring) ---
|
||||||
PROMETHEUS_PORT=9090
|
PROMETHEUS_PORT=9090
|
||||||
|
|||||||
@ -14,6 +14,34 @@
|
|||||||
</head>
|
</head>
|
||||||
<body style="margin:0;background:#1a1025">
|
<body style="margin:0;background:#1a1025">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<noscript>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:center;height:100vh;color:#e0e0e0;font-family:sans-serif">
|
||||||
|
<p>JavaScript is required to run this application.</p>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
<script>
|
||||||
|
// Fallback: if the main module fails to load entirely (stale deployment),
|
||||||
|
// show a reload prompt after a timeout. The main app replaces #root content on success.
|
||||||
|
setTimeout(function() {
|
||||||
|
var root = document.getElementById('root');
|
||||||
|
if (root && root.children.length === 0) {
|
||||||
|
var key = 'cm_chunk_reload';
|
||||||
|
var last = sessionStorage.getItem(key);
|
||||||
|
var now = Date.now();
|
||||||
|
if (!last || now - parseInt(last, 10) > 10000) {
|
||||||
|
sessionStorage.setItem(key, String(now));
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
root.innerHTML = '<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;color:#e0e0e0;font-family:sans-serif;text-align:center;padding:0 24px">'
|
||||||
|
+ '<div style="font-size:48px;margin-bottom:16px">↻</div>'
|
||||||
|
+ '<h2 style="margin:0 0 8px;font-size:20px">Application Updated</h2>'
|
||||||
|
+ '<p style="margin:0 0 24px;color:#999;max-width:400px;line-height:1.5">A new version has been deployed. Please refresh to load the latest version.</p>'
|
||||||
|
+ '<button onclick="window.location.reload()" style="padding:10px 24px;font-size:14px;border:none;border-radius:6px;background:#9d4edd;color:#fff;cursor:pointer">Refresh Page</button>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
</script>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -115,6 +115,9 @@ import SocialDashboardPage from '@/pages/social/SocialDashboardPage';
|
|||||||
import SocialGraphPage from '@/pages/social/SocialGraphPage';
|
import SocialGraphPage from '@/pages/social/SocialGraphPage';
|
||||||
import SocialModerationPage from '@/pages/social/SocialModerationPage';
|
import SocialModerationPage from '@/pages/social/SocialModerationPage';
|
||||||
import MeetingJoinPage from '@/pages/public/MeetingJoinPage';
|
import MeetingJoinPage from '@/pages/public/MeetingJoinPage';
|
||||||
|
import MeetingPlannerPage from '@/pages/MeetingPlannerPage';
|
||||||
|
import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
|
||||||
|
import PollsListPage from '@/pages/public/PollsListPage';
|
||||||
import JitsiAuthPage from '@/pages/JitsiAuthPage';
|
import JitsiAuthPage from '@/pages/JitsiAuthPage';
|
||||||
import NotFoundPage from '@/pages/NotFoundPage';
|
import NotFoundPage from '@/pages/NotFoundPage';
|
||||||
import CommandPalette from '@/components/command-palette/CommandPalette';
|
import CommandPalette from '@/components/command-palette/CommandPalette';
|
||||||
@ -225,6 +228,14 @@ export default function App() {
|
|||||||
<Route path="/events" element={<FeatureGate feature="enableEvents"><PublicLayout /></FeatureGate>}>
|
<Route path="/events" element={<FeatureGate feature="enableEvents"><PublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<EventsPage />} />
|
<Route index element={<EventsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
{/* Scheduling polls — feature-gated */}
|
||||||
|
<Route path="/polls" element={<FeatureGate feature="enableMeetingPlanner"><PublicLayout /></FeatureGate>}>
|
||||||
|
<Route index element={<PollsListPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/poll/:slug" element={<FeatureGate feature="enableMeetingPlanner"><PublicLayout /></FeatureGate>}>
|
||||||
|
<Route index element={<SchedulingPollPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* Public meeting join page — feature-gated */}
|
{/* Public meeting join page — feature-gated */}
|
||||||
<Route path="/meet/:slug" element={<FeatureGate feature="enableMeet"><PublicLayout /></FeatureGate>}>
|
<Route path="/meet/:slug" element={<FeatureGate feature="enableMeet"><PublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<MeetingJoinPage />} />
|
<Route index element={<MeetingJoinPage />} />
|
||||||
@ -674,6 +685,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="meeting-planner"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||||
|
<MeetingPlannerPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="map/cuts"
|
path="map/cuts"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@ -112,6 +112,7 @@ const DEFAULT_ADMIN_NAV_ITEMS: NavItem[] = [
|
|||||||
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
|
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
|
||||||
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
|
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
|
||||||
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
|
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
|
||||||
|
{ id: 'polls', label: 'Polls', path: '/polls', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableMeetingPlanner' },
|
||||||
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
|
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
|
||||||
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
|
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
|
||||||
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
|
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
|
||||||
@ -249,7 +250,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
|||||||
children: [
|
children: [
|
||||||
{ key: '/app/map', icon: <EnvironmentOutlined />, label: 'Locations' },
|
{ key: '/app/map', icon: <EnvironmentOutlined />, label: 'Locations' },
|
||||||
{ key: '/app/map/data-quality', icon: <BarChartOutlined />, label: 'Data Quality' },
|
{ key: '/app/map/data-quality', icon: <BarChartOutlined />, label: 'Data Quality' },
|
||||||
{ key: '/app/map/shifts', icon: <ScheduleOutlined />, label: 'Shifts' },
|
|
||||||
{ key: '/app/map/cuts', icon: <ScissorOutlined />, label: 'Areas' },
|
{ key: '/app/map/cuts', icon: <ScissorOutlined />, label: 'Areas' },
|
||||||
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
|
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
|
||||||
{ key: '/app/map/settings', icon: <SettingOutlined />, label: 'Settings' },
|
{ key: '/app/map/settings', icon: <SettingOutlined />, label: 'Settings' },
|
||||||
@ -257,6 +257,25 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scheduling submenu — visible if either Shifts (enableMap) or Meeting Planner is enabled
|
||||||
|
if (settings?.enableMap !== false || settings?.enableMeetingPlanner) {
|
||||||
|
const schedulingChildren: any[] = [];
|
||||||
|
if (settings?.enableMap !== false) {
|
||||||
|
schedulingChildren.push({ key: '/app/map/shifts', icon: <ScheduleOutlined />, label: 'Shifts' });
|
||||||
|
}
|
||||||
|
if (settings?.enableMeetingPlanner) {
|
||||||
|
schedulingChildren.push({ key: '/app/meeting-planner', icon: <CalendarOutlined />, label: 'Meeting Planner' });
|
||||||
|
}
|
||||||
|
if (schedulingChildren.length > 0) {
|
||||||
|
items.push({
|
||||||
|
key: 'scheduling-submenu',
|
||||||
|
icon: <ScheduleOutlined />,
|
||||||
|
label: 'Scheduling',
|
||||||
|
children: schedulingChildren,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (settings?.enableMediaFeatures !== false) {
|
if (settings?.enableMediaFeatures !== false) {
|
||||||
items.push({
|
items.push({
|
||||||
key: 'media-submenu',
|
key: 'media-submenu',
|
||||||
|
|||||||
113
admin/src/components/ErrorBoundary.tsx
Normal file
113
admin/src/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { Component, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
isChunkError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global error boundary that catches React rendering crashes.
|
||||||
|
* Detects stale chunk errors (after redeployment) and offers auto-reload.
|
||||||
|
*/
|
||||||
|
export default class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { hasError: false, isChunkError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
const isChunkError = isChunkLoadError(error);
|
||||||
|
return { hasError: true, isChunkError };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error) {
|
||||||
|
// If it's a stale chunk error, auto-reload once (avoid infinite loop via sessionStorage flag)
|
||||||
|
if (isChunkLoadError(error)) {
|
||||||
|
const reloadKey = 'cm_chunk_reload';
|
||||||
|
const lastReload = sessionStorage.getItem(reloadKey);
|
||||||
|
const now = Date.now();
|
||||||
|
// Only auto-reload if we haven't done so in the last 10 seconds
|
||||||
|
if (!lastReload || now - parseInt(lastReload, 10) > 10000) {
|
||||||
|
sessionStorage.setItem(reloadKey, String(now));
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReload = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
background: '#1a1025',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '0 24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>
|
||||||
|
{this.state.isChunkError ? '\u21BB' : '\u26A0'}
|
||||||
|
</div>
|
||||||
|
<h2 style={{ margin: '0 0 8px', fontSize: 20, fontWeight: 600 }}>
|
||||||
|
{this.state.isChunkError
|
||||||
|
? 'Application Updated'
|
||||||
|
: 'Something went wrong'}
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: '0 0 24px', color: '#999', maxWidth: 400, lineHeight: 1.5 }}>
|
||||||
|
{this.state.isChunkError
|
||||||
|
? 'A new version has been deployed. Please refresh to load the latest version.'
|
||||||
|
: 'An unexpected error occurred. A page refresh usually fixes this.'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={this.handleReload}
|
||||||
|
style={{
|
||||||
|
padding: '10px 24px',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: '#9d4edd',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'opacity 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => (e.currentTarget.style.opacity = '0.85')}
|
||||||
|
onMouseOut={(e) => (e.currentTarget.style.opacity = '1')}
|
||||||
|
>
|
||||||
|
Refresh Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect chunk/module load errors (stale deployments) */
|
||||||
|
function isChunkLoadError(error: Error): boolean {
|
||||||
|
const msg = error.message || '';
|
||||||
|
return (
|
||||||
|
msg.includes('Failed to fetch dynamically imported module') ||
|
||||||
|
msg.includes('Loading chunk') ||
|
||||||
|
msg.includes('Loading CSS chunk') ||
|
||||||
|
msg.includes('error loading dynamically imported module') ||
|
||||||
|
msg.includes('Importing a module script failed') ||
|
||||||
|
// Vite-specific: chunk not found
|
||||||
|
msg.includes('is not a valid JavaScript MIME type') ||
|
||||||
|
// Generic syntax errors from loading wrong content (e.g., HTML 404 page as JS)
|
||||||
|
(error.name === 'SyntaxError' && msg.includes('Unexpected token'))
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -19,10 +19,11 @@ const FEATURE_LABELS: Record<string, string> = {
|
|||||||
enableEvents: 'Events',
|
enableEvents: 'Events',
|
||||||
enableSocial: 'Social Connections',
|
enableSocial: 'Social Connections',
|
||||||
enableMeet: 'Video Meetings',
|
enableMeet: 'Video Meetings',
|
||||||
|
enableMeetingPlanner: 'Meeting Planner',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FeatureGateProps {
|
interface FeatureGateProps {
|
||||||
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet'>;
|
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner'>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -549,6 +549,28 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
|
|||||||
</div>
|
</div>
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
case 'scheduling-poll': {
|
||||||
|
const pollSlug = (defaults.pollSlug as string) || '';
|
||||||
|
const showComments = defaults.showComments !== false;
|
||||||
|
const title = (defaults.title as string) || 'Vote on a Meeting Time';
|
||||||
|
return `
|
||||||
|
<section style="padding: 60px 40px;">
|
||||||
|
<div class="scheduling-poll-block"
|
||||||
|
data-poll-slug="${pollSlug}"
|
||||||
|
data-show-comments="${showComments}"
|
||||||
|
data-title="${title}"
|
||||||
|
style="max-width: 700px; margin: 0 auto;">
|
||||||
|
<div style="background: linear-gradient(135deg, #fa8c16 0%, #d46b08 100%); border-radius: 12px; padding: 32px; text-align: center; color: #fff;">
|
||||||
|
<svg style="width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;" fill="currentColor" viewBox="0 0 1024 1024">
|
||||||
|
<path d="M880 184H712v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H384v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H144c-17.7 0-32 14.3-32 32v664c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V216c0-17.7-14.3-32-32-32z"/>
|
||||||
|
</svg>
|
||||||
|
<p style="margin: 0; font-size: 1.2rem; font-weight: 600;">${title}</p>
|
||||||
|
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
|
||||||
|
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Poll will render on published page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -153,10 +153,11 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '0 2px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '0 2px' }}>
|
||||||
{visible.map(item => {
|
{visible.map(item => {
|
||||||
|
const isPoll = item.type === 'poll';
|
||||||
const isShift = item.type === 'shift';
|
const isShift = item.type === 'shift';
|
||||||
const bg = isShift ? 'rgba(24, 144, 255, 0.2)' : 'rgba(82, 196, 26, 0.2)';
|
const bg = isPoll ? 'rgba(250, 140, 22, 0.2)' : isShift ? 'rgba(24, 144, 255, 0.2)' : 'rgba(82, 196, 26, 0.2)';
|
||||||
const border = isShift ? 'rgba(24, 144, 255, 0.5)' : 'rgba(82, 196, 26, 0.5)';
|
const border = isPoll ? 'rgba(250, 140, 22, 0.5)' : isShift ? 'rgba(24, 144, 255, 0.5)' : 'rgba(82, 196, 26, 0.5)';
|
||||||
const accent = isShift ? '#1890ff' : '#52c41a';
|
const accent = isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@ -195,6 +196,7 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
|||||||
|
|
||||||
const renderItemCard = (item: UnifiedCalendarItem) => {
|
const renderItemCard = (item: UnifiedCalendarItem) => {
|
||||||
const isShift = item.type === 'shift';
|
const isShift = item.type === 'shift';
|
||||||
|
const isPoll = item.type === 'poll';
|
||||||
const spotsLeft = isShift && item.maxVolunteers
|
const spotsLeft = isShift && item.maxVolunteers
|
||||||
? item.maxVolunteers - (item.currentVolunteers || 0)
|
? item.maxVolunteers - (item.currentVolunteers || 0)
|
||||||
: null;
|
: null;
|
||||||
@ -202,13 +204,16 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
|||||||
const pct = isShift && item.maxVolunteers && item.maxVolunteers > 0
|
const pct = isShift && item.maxVolunteers && item.maxVolunteers > 0
|
||||||
? Math.round(((item.currentVolunteers || 0) / item.maxVolunteers) * 100)
|
? Math.round(((item.currentVolunteers || 0) / item.maxVolunteers) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
const borderColor = isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a';
|
||||||
|
const tagColor = isPoll ? 'orange' : isShift ? 'blue' : 'green';
|
||||||
|
const tagLabel = isPoll ? 'Poll' : isShift ? 'Shift' : 'Event';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={item.id}
|
key={item.id}
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
borderLeft: `4px solid ${isShift ? '#1890ff' : '#52c41a'}`,
|
borderLeft: `4px solid ${borderColor}`,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -221,8 +226,8 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Tag color={isShift ? 'blue' : 'green'} style={{ margin: 0, fontSize: 11 }}>
|
<Tag color={tagColor} style={{ margin: 0, fontSize: 11 }}>
|
||||||
{isShift ? 'Shift' : 'Event'}
|
{tagLabel}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -286,7 +291,7 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isShift && item.gancioUrl && (
|
{!isShift && !isPoll && item.gancioUrl && (
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
@ -298,6 +303,19 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
|||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isPoll && item.pollSlug && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<CalendarOutlined />}
|
||||||
|
href={`/poll/${item.pollSlug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Vote ({item.pollVoteCount ?? 0})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
586
admin/src/components/scheduling/SchedulingPollWidget.tsx
Normal file
586
admin/src/components/scheduling/SchedulingPollWidget.tsx
Normal file
@ -0,0 +1,586 @@
|
|||||||
|
/**
|
||||||
|
* Self-contained scheduling poll widget for GrapesJS landing pages.
|
||||||
|
* Rendered via createRoot() outside the App's ConfigProvider — uses inline styles only, no Ant Design.
|
||||||
|
* Follows the same pattern as CampaignFormWidget, DonationWidget, etc.
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const apiBase = '/api';
|
||||||
|
|
||||||
|
// Theme colors matching the dark public pages
|
||||||
|
const COLORS = {
|
||||||
|
bg: '#0d1b2a',
|
||||||
|
card: '#1b2838',
|
||||||
|
cardAlt: '#243447',
|
||||||
|
primary: '#fa8c16',
|
||||||
|
primaryHover: '#d46b08',
|
||||||
|
text: '#fff',
|
||||||
|
textMuted: 'rgba(255,255,255,0.65)',
|
||||||
|
border: 'rgba(255,255,255,0.15)',
|
||||||
|
success: '#52c41a',
|
||||||
|
error: '#ff4d4f',
|
||||||
|
yes: '#52c41a',
|
||||||
|
ifNeedBe: '#faad14',
|
||||||
|
no: '#d9d9d9',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
OPEN: '#52c41a',
|
||||||
|
CLOSED: '#fa8c16',
|
||||||
|
FINALIZED: '#1890ff',
|
||||||
|
CANCELLED: '#ff4d4f',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
OPEN: 'Open',
|
||||||
|
CLOSED: 'Closed',
|
||||||
|
FINALIZED: 'Finalized',
|
||||||
|
CANCELLED: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
const VOTE_LABELS: Record<string, string> = {
|
||||||
|
YES: 'Yes',
|
||||||
|
IF_NEED_BE: 'If Need Be',
|
||||||
|
NO: 'No',
|
||||||
|
};
|
||||||
|
|
||||||
|
const VOTE_COLORS: Record<string, string> = {
|
||||||
|
YES: COLORS.yes,
|
||||||
|
IF_NEED_BE: COLORS.ifNeedBe,
|
||||||
|
NO: COLORS.no,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PollOption {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
yesCount?: number;
|
||||||
|
ifNeedBeCount?: number;
|
||||||
|
noCount?: number;
|
||||||
|
score?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PollVoter {
|
||||||
|
name: string;
|
||||||
|
votes: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PollComment {
|
||||||
|
id: string;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PollData {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
location: string | null;
|
||||||
|
status: string;
|
||||||
|
timezone: string;
|
||||||
|
votingDeadline: string | null;
|
||||||
|
finalizedOptionId: string | null;
|
||||||
|
finalizedOption: PollOption | null;
|
||||||
|
allowAnonymous: boolean;
|
||||||
|
createdBy?: { name: string | null; email: string };
|
||||||
|
options: PollOption[];
|
||||||
|
voters: PollVoter[];
|
||||||
|
comments: PollComment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchedulingPollWidgetProps {
|
||||||
|
pollSlug: string;
|
||||||
|
showComments?: boolean;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 14px',
|
||||||
|
border: `1px solid ${COLORS.border}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
color: COLORS.text,
|
||||||
|
fontSize: 14,
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
const btnStyle: React.CSSProperties = {
|
||||||
|
padding: '10px 24px',
|
||||||
|
background: COLORS.primary,
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
width: '100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const VOTER_TOKEN_KEY = 'poll_voter_token_';
|
||||||
|
|
||||||
|
export function SchedulingPollWidget({ pollSlug, showComments = true, title }: SchedulingPollWidgetProps) {
|
||||||
|
const [poll, setPoll] = useState<PollData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Vote form
|
||||||
|
const [voterName, setVoterName] = useState('');
|
||||||
|
const [voterEmail, setVoterEmail] = useState('');
|
||||||
|
const [votes, setVotes] = useState<Record<string, string>>({});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [hasVoted, setHasVoted] = useState(false);
|
||||||
|
const [submitMsg, setSubmitMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Comment form
|
||||||
|
const [commentName, setCommentName] = useState('');
|
||||||
|
const [commentContent, setCommentContent] = useState('');
|
||||||
|
const [commentSubmitting, setCommentSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const fetchPoll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<PollData>(`${apiBase}/meeting-planner/public/${pollSlug}`);
|
||||||
|
setPoll(data);
|
||||||
|
|
||||||
|
// Check for stored voter token
|
||||||
|
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + pollSlug);
|
||||||
|
if (storedToken) {
|
||||||
|
setHasVoted(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Poll not found or unavailable');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [pollSlug]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPoll(); }, [fetchPoll]);
|
||||||
|
|
||||||
|
const handleVoteChange = (optionId: string, value: string) => {
|
||||||
|
setVotes((prev) => ({ ...prev, [optionId]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitVotes = async () => {
|
||||||
|
if (!poll || !voterName.trim()) {
|
||||||
|
setSubmitMsg({ type: 'error', text: 'Please enter your name' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Object.keys(votes).length) {
|
||||||
|
setSubmitMsg({ type: 'error', text: 'Please vote on at least one option' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubmitMsg(null);
|
||||||
|
try {
|
||||||
|
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + pollSlug);
|
||||||
|
const { data } = await axios.post(`${apiBase}/meeting-planner/public/${pollSlug}/vote`, {
|
||||||
|
voterName: voterName.trim(),
|
||||||
|
voterEmail: voterEmail.trim() || undefined,
|
||||||
|
voterToken: storedToken || undefined,
|
||||||
|
votes: Object.entries(votes).map(([optionId, value]) => ({ optionId, value })),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.voterToken) {
|
||||||
|
localStorage.setItem(VOTER_TOKEN_KEY + pollSlug, data.voterToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitMsg({ type: 'success', text: hasVoted ? 'Votes updated!' : 'Votes submitted!' });
|
||||||
|
setHasVoted(true);
|
||||||
|
fetchPoll();
|
||||||
|
} catch (err: any) {
|
||||||
|
setSubmitMsg({ type: 'error', text: err.response?.data?.error?.message || 'Failed to submit votes' });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitComment = async () => {
|
||||||
|
if (!commentName.trim() || !commentContent.trim()) return;
|
||||||
|
setCommentSubmitting(true);
|
||||||
|
try {
|
||||||
|
await axios.post(`${apiBase}/meeting-planner/public/${pollSlug}/comment`, {
|
||||||
|
authorName: commentName.trim(),
|
||||||
|
content: commentContent.trim(),
|
||||||
|
});
|
||||||
|
setCommentContent('');
|
||||||
|
fetchPoll();
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
} finally {
|
||||||
|
setCommentSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40, color: COLORS.textMuted }}>
|
||||||
|
Loading poll...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !poll) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40, color: COLORS.textMuted }}>
|
||||||
|
{error || 'Poll not found'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = poll.status === 'OPEN';
|
||||||
|
const isFinalized = poll.status === 'FINALIZED';
|
||||||
|
const bestScore = poll.options.length ? Math.max(...poll.options.map((o) => o.score ?? 0)) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 700, margin: '0 auto', fontFamily: "'Inter', -apple-system, sans-serif", color: COLORS.text }}>
|
||||||
|
{/* Title */}
|
||||||
|
{title && (
|
||||||
|
<h2 style={{ textAlign: 'center', marginBottom: 8, fontSize: '1.5rem', fontWeight: 700 }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Poll header */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<h3 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>
|
||||||
|
{poll.title}
|
||||||
|
</h3>
|
||||||
|
{poll.description && (
|
||||||
|
<p style={{ color: COLORS.textMuted, margin: '0 0 8px', lineHeight: 1.5 }}>
|
||||||
|
{poll.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: `${STATUS_COLORS[poll.status] || '#666'}22`,
|
||||||
|
color: STATUS_COLORS[poll.status] || '#666',
|
||||||
|
border: `1px solid ${STATUS_COLORS[poll.status] || '#666'}44`,
|
||||||
|
}}>
|
||||||
|
{STATUS_LABELS[poll.status] || poll.status}
|
||||||
|
</span>
|
||||||
|
{poll.location && (
|
||||||
|
<span style={{ fontSize: 13, color: COLORS.textMuted }}>
|
||||||
|
{poll.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 13, color: COLORS.textMuted }}>
|
||||||
|
{poll.timezone}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deadline */}
|
||||||
|
{poll.votingDeadline && isOpen && (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(250,140,22,0.1)',
|
||||||
|
border: '1px solid rgba(250,140,22,0.3)',
|
||||||
|
marginBottom: 16,
|
||||||
|
fontSize: 13,
|
||||||
|
color: COLORS.primary,
|
||||||
|
}}>
|
||||||
|
Voting deadline: {formatDateTime(poll.votingDeadline)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Finalized banner */}
|
||||||
|
{isFinalized && poll.finalizedOption && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(82,196,26,0.1)',
|
||||||
|
border: '1px solid rgba(82,196,26,0.3)',
|
||||||
|
marginBottom: 16,
|
||||||
|
color: COLORS.success,
|
||||||
|
}}>
|
||||||
|
<strong>Date Confirmed:</strong>{' '}
|
||||||
|
{formatDate(poll.finalizedOption.date)} — {poll.finalizedOption.startTime}–{poll.finalizedOption.endTime}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Options table */}
|
||||||
|
<div style={{
|
||||||
|
background: COLORS.card,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${COLORS.border}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ padding: '10px 14px', borderBottom: `2px solid ${COLORS.border}`, textAlign: 'left', minWidth: 130 }}>
|
||||||
|
Participant
|
||||||
|
</th>
|
||||||
|
{poll.options.map((opt) => (
|
||||||
|
<th
|
||||||
|
key={opt.id}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderBottom: `2px solid ${COLORS.border}`,
|
||||||
|
textAlign: 'center',
|
||||||
|
minWidth: 100,
|
||||||
|
background: isFinalized && poll.finalizedOptionId === opt.id
|
||||||
|
? 'rgba(82,196,26,0.12)'
|
||||||
|
: opt.score === bestScore && bestScore > 0
|
||||||
|
? 'rgba(82,196,26,0.06)'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>{formatDate(opt.date)}</div>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7 }}>{opt.startTime}–{opt.endTime}</div>
|
||||||
|
{isFinalized && poll.finalizedOptionId === opt.id && (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
marginTop: 4,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 3,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: 'rgba(82,196,26,0.2)',
|
||||||
|
color: COLORS.success,
|
||||||
|
}}>
|
||||||
|
Confirmed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{poll.voters.map((voter, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td style={{ padding: '8px 14px', borderBottom: `1px solid ${COLORS.border}` }}>
|
||||||
|
{voter.name}
|
||||||
|
</td>
|
||||||
|
{poll.options.map((opt) => {
|
||||||
|
const value = voter.votes[opt.id];
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={opt.id}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderBottom: `1px solid ${COLORS.border}`,
|
||||||
|
textAlign: 'center',
|
||||||
|
background: value ? `${VOTE_COLORS[value] || '#666'}18` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value && (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 3,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: `${VOTE_COLORS[value] || '#666'}22`,
|
||||||
|
color: VOTE_COLORS[value] || '#666',
|
||||||
|
}}>
|
||||||
|
{VOTE_LABELS[value] || value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{/* Score row */}
|
||||||
|
<tr style={{ fontWeight: 600 }}>
|
||||||
|
<td style={{ padding: '10px 14px', borderTop: `2px solid ${COLORS.border}` }}>Score</td>
|
||||||
|
{poll.options.map((opt) => (
|
||||||
|
<td
|
||||||
|
key={opt.id}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderTop: `2px solid ${COLORS.border}`,
|
||||||
|
textAlign: 'center',
|
||||||
|
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82,196,26,0.1)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 16 }}>{opt.score ?? 0}</div>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7 }}>
|
||||||
|
{opt.yesCount ?? 0}Y / {opt.ifNeedBeCount ?? 0}M / {opt.noCount ?? 0}N
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vote form */}
|
||||||
|
{isOpen && (
|
||||||
|
<div style={{
|
||||||
|
background: COLORS.card,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${COLORS.border}`,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}>
|
||||||
|
<h4 style={{ margin: '0 0 14px', fontSize: 15 }}>
|
||||||
|
{hasVoted ? 'Update Your Votes' : 'Cast Your Votes'}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginBottom: 14, flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name"
|
||||||
|
value={voterName}
|
||||||
|
onChange={(e) => setVoterName(e.target.value)}
|
||||||
|
style={{ ...inputStyle, flex: '1 1 200px' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email (optional)"
|
||||||
|
value={voterEmail}
|
||||||
|
onChange={(e) => setVoterEmail(e.target.value)}
|
||||||
|
style={{ ...inputStyle, flex: '1 1 200px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{poll.options.map((opt) => (
|
||||||
|
<div key={opt.id} style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ minWidth: 150, fontSize: 13 }}>
|
||||||
|
{formatDate(opt.date)} {opt.startTime}–{opt.endTime}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{(['YES', 'IF_NEED_BE', 'NO'] as const).map((val) => (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVoteChange(opt.id, val)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
border: `1px solid ${VOTE_COLORS[val]}66`,
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: votes[opt.id] === val ? `${VOTE_COLORS[val]}33` : 'transparent',
|
||||||
|
color: votes[opt.id] === val ? VOTE_COLORS[val] : COLORS.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{VOTE_LABELS[val]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{submitMsg && (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 13,
|
||||||
|
background: submitMsg.type === 'success' ? 'rgba(82,196,26,0.1)' : 'rgba(255,77,79,0.1)',
|
||||||
|
color: submitMsg.type === 'success' ? COLORS.success : COLORS.error,
|
||||||
|
border: `1px solid ${submitMsg.type === 'success' ? 'rgba(82,196,26,0.3)' : 'rgba(255,77,79,0.3)'}`,
|
||||||
|
}}>
|
||||||
|
{submitMsg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmitVotes}
|
||||||
|
disabled={submitting}
|
||||||
|
style={{
|
||||||
|
...btnStyle,
|
||||||
|
marginTop: 14,
|
||||||
|
opacity: submitting ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting...' : hasVoted ? 'Update Votes' : 'Submit Votes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
{showComments && (
|
||||||
|
<div style={{
|
||||||
|
background: COLORS.card,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${COLORS.border}`,
|
||||||
|
padding: 20,
|
||||||
|
}}>
|
||||||
|
<h4 style={{ margin: '0 0 14px', fontSize: 15 }}>
|
||||||
|
Comments ({poll.comments.length})
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{poll.comments.map((comment) => (
|
||||||
|
<div key={comment.id} style={{ marginBottom: 12, paddingBottom: 12, borderBottom: `1px solid ${COLORS.border}` }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<strong style={{ fontSize: 13 }}>{comment.authorName}</strong>
|
||||||
|
<span style={{ fontSize: 11, color: COLORS.textMuted }}>
|
||||||
|
{formatDateTime(comment.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: COLORS.textMuted, lineHeight: 1.5 }}>
|
||||||
|
{comment.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{poll.status !== 'CANCELLED' && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name"
|
||||||
|
value={commentName}
|
||||||
|
onChange={(e) => setCommentName(e.target.value)}
|
||||||
|
style={{ ...inputStyle, flex: '0 0 140px' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
value={commentContent}
|
||||||
|
onChange={(e) => setCommentContent(e.target.value)}
|
||||||
|
style={{ ...inputStyle, flex: '1 1 200px' }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmitComment(); }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmitComment}
|
||||||
|
disabled={commentSubmitting}
|
||||||
|
style={{
|
||||||
|
...btnStyle,
|
||||||
|
width: 'auto',
|
||||||
|
flex: '0 0 auto',
|
||||||
|
opacity: commentSubmitting ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Post
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,10 +1,47 @@
|
|||||||
import '@ant-design/v5-patch-for-react-19';
|
import '@ant-design/v5-patch-for-react-19';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
|
// Catch unhandled chunk/module load errors globally (e.g., stale deployment)
|
||||||
|
// These fire as window errors when a <script> tag fails to load
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target?.tagName === 'SCRIPT' || target?.tagName === 'LINK') {
|
||||||
|
const reloadKey = 'cm_chunk_reload';
|
||||||
|
const lastReload = sessionStorage.getItem(reloadKey);
|
||||||
|
const now = Date.now();
|
||||||
|
if (!lastReload || now - parseInt(lastReload, 10) > 10000) {
|
||||||
|
sessionStorage.setItem(reloadKey, String(now));
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true); // capture phase to catch resource load errors
|
||||||
|
|
||||||
|
// Catch unhandled promise rejections (dynamic import failures)
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
const msg = String(event.reason?.message || event.reason || '');
|
||||||
|
if (
|
||||||
|
msg.includes('Failed to fetch dynamically imported module') ||
|
||||||
|
msg.includes('error loading dynamically imported module') ||
|
||||||
|
msg.includes('Loading chunk') ||
|
||||||
|
msg.includes('Importing a module script failed')
|
||||||
|
) {
|
||||||
|
const reloadKey = 'cm_chunk_reload';
|
||||||
|
const lastReload = sessionStorage.getItem(reloadKey);
|
||||||
|
const now = Date.now();
|
||||||
|
if (!lastReload || now - parseInt(lastReload, 10) > 10000) {
|
||||||
|
sessionStorage.setItem(reloadKey, String(now));
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -58,6 +58,7 @@ import {
|
|||||||
ShoppingCartOutlined,
|
ShoppingCartOutlined,
|
||||||
MobileOutlined,
|
MobileOutlined,
|
||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import type { OnMount } from '@monaco-editor/react';
|
import type { OnMount } from '@monaco-editor/react';
|
||||||
@ -359,6 +360,7 @@ const SNIPPETS: MkDocsSnippet[] = [
|
|||||||
{ id: 'pricing-table', label: 'Pricing Table', group: 'insert', type: 'insert', template: '' },
|
{ id: 'pricing-table', label: 'Pricing Table', group: 'insert', type: 'insert', template: '' },
|
||||||
{ id: 'product-card', label: 'Product Card', group: 'insert', type: 'insert', template: '' },
|
{ id: 'product-card', label: 'Product Card', group: 'insert', type: 'insert', template: '' },
|
||||||
{ id: 'ad-insert', label: 'Ad', group: 'insert', type: 'insert', template: '' },
|
{ id: 'ad-insert', label: 'Ad', group: 'insert', type: 'insert', template: '' },
|
||||||
|
{ id: 'scheduling-poll', label: 'Scheduling Poll', group: 'insert', type: 'insert', template: '' },
|
||||||
{ id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' },
|
{ id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -590,6 +592,8 @@ export default function DocsPage() {
|
|||||||
const [donateInsertOpen, setDonateInsertOpen] = useState(false);
|
const [donateInsertOpen, setDonateInsertOpen] = useState(false);
|
||||||
const [productInsertOpen, setProductInsertOpen] = useState(false);
|
const [productInsertOpen, setProductInsertOpen] = useState(false);
|
||||||
const [adPickerOpen, setAdPickerOpen] = useState(false);
|
const [adPickerOpen, setAdPickerOpen] = useState(false);
|
||||||
|
const [pollInsertOpen, setPollInsertOpen] = useState(false);
|
||||||
|
const [pollSlugInput, setPollSlugInput] = useState('');
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -816,6 +820,13 @@ export default function DocsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scheduling poll — opens slug input modal
|
||||||
|
if (snippetId === 'scheduling-poll') {
|
||||||
|
setPollSlugInput('');
|
||||||
|
setPollInsertOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Pricing table — static CTA (plans are dynamic, so link out)
|
// Pricing table — static CTA (plans are dynamic, so link out)
|
||||||
if (snippetId === 'pricing-table') {
|
if (snippetId === 'pricing-table') {
|
||||||
const appUrl = config
|
const appUrl = config
|
||||||
@ -1058,6 +1069,23 @@ export default function DocsPage() {
|
|||||||
setAdPickerOpen(false);
|
setAdPickerOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handlePollInsert = useCallback(() => {
|
||||||
|
const slug = pollSlugInput.trim();
|
||||||
|
if (!slug) return;
|
||||||
|
|
||||||
|
const html = `<div class="scheduling-poll-block" data-poll-slug="${slug}" data-show-comments="true" data-title="Vote on a Meeting Time">\n Loading poll...\n</div>`;
|
||||||
|
|
||||||
|
const ed = monacoEditorRef.current;
|
||||||
|
if (ed) {
|
||||||
|
const sel = ed.getSelection();
|
||||||
|
if (sel) {
|
||||||
|
ed.executeEdits('poll-block-insert', [{ range: sel, text: '\n' + html + '\n' }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPollInsertOpen(false);
|
||||||
|
setPollSlugInput('');
|
||||||
|
}, [pollSlugInput]);
|
||||||
|
|
||||||
const handleCtxMenuClick = useCallback((snippetId: string) => {
|
const handleCtxMenuClick = useCallback((snippetId: string) => {
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
handleToolbarSnippet(snippetId);
|
handleToolbarSnippet(snippetId);
|
||||||
@ -1976,7 +2004,7 @@ export default function DocsPage() {
|
|||||||
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'insert').map(s => ({
|
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'insert').map(s => ({
|
||||||
key: s.id,
|
key: s.id,
|
||||||
label: s.label,
|
label: s.label,
|
||||||
icon: s.id === 'video-card' ? <PlayCircleOutlined /> : s.id === 'photo-insert' ? <PictureOutlined /> : s.id === 'donate-button' ? <HeartOutlined /> : s.id === 'pricing-table' ? <CrownOutlined /> : s.id === 'product-card' ? <ShoppingCartOutlined /> : s.id === 'ad-insert' ? <BuildOutlined /> : s.id === 'link' ? <LinkOutlined /> : s.id === 'image' ? <FileMarkdownOutlined /> : s.id === 'table' ? <TableOutlined /> : <PlusOutlined />,
|
icon: s.id === 'video-card' ? <PlayCircleOutlined /> : s.id === 'photo-insert' ? <PictureOutlined /> : s.id === 'donate-button' ? <HeartOutlined /> : s.id === 'pricing-table' ? <CrownOutlined /> : s.id === 'product-card' ? <ShoppingCartOutlined /> : s.id === 'ad-insert' ? <BuildOutlined /> : s.id === 'scheduling-poll' ? <CalendarOutlined /> : s.id === 'link' ? <LinkOutlined /> : s.id === 'image' ? <FileMarkdownOutlined /> : s.id === 'table' ? <TableOutlined /> : <PlusOutlined />,
|
||||||
onClick: () => handleToolbarSnippet(s.id),
|
onClick: () => handleToolbarSnippet(s.id),
|
||||||
})) }} trigger={['click']}>
|
})) }} trigger={['click']}>
|
||||||
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
|
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
|
||||||
@ -2190,6 +2218,27 @@ export default function DocsPage() {
|
|||||||
onInsert={handleAdInsert}
|
onInsert={handleAdInsert}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Scheduling Poll Insert Modal */}
|
||||||
|
<Modal
|
||||||
|
title="Insert Scheduling Poll"
|
||||||
|
open={pollInsertOpen}
|
||||||
|
onOk={handlePollInsert}
|
||||||
|
onCancel={() => { setPollInsertOpen(false); setPollSlugInput(''); }}
|
||||||
|
okText="Insert"
|
||||||
|
okButtonProps={{ disabled: !pollSlugInput.trim() }}
|
||||||
|
>
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
|
Enter the slug of the scheduling poll to embed. You can find poll slugs in the Meeting Planner admin page.
|
||||||
|
</Typography.Text>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. team-meeting-march"
|
||||||
|
value={pollSlugInput}
|
||||||
|
onChange={(e) => setPollSlugInput(e.target.value)}
|
||||||
|
onPressEnter={handlePollInsert}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Custom right-click context menu with submenus */}
|
{/* Custom right-click context menu with submenus */}
|
||||||
{ctxMenu && (
|
{ctxMenu && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
709
admin/src/pages/MeetingPlannerPage.tsx
Normal file
709
admin/src/pages/MeetingPlannerPage.tsx
Normal file
@ -0,0 +1,709 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Form,
|
||||||
|
Switch,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
Typography,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
TimePicker,
|
||||||
|
Drawer,
|
||||||
|
Card,
|
||||||
|
Tooltip,
|
||||||
|
Grid,
|
||||||
|
Divider,
|
||||||
|
Modal,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type {
|
||||||
|
SchedulingPoll,
|
||||||
|
PollsListResponse,
|
||||||
|
PollDetailResponse,
|
||||||
|
SchedulingPollStatus,
|
||||||
|
PollVoteValue,
|
||||||
|
} from '@/types/api';
|
||||||
|
import {
|
||||||
|
POLL_STATUS_COLORS,
|
||||||
|
POLL_STATUS_LABELS,
|
||||||
|
VOTE_VALUE_COLORS,
|
||||||
|
VOTE_VALUE_LABELS,
|
||||||
|
} from '@/types/api';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
const TIMEZONE_OPTIONS = [
|
||||||
|
'America/Vancouver',
|
||||||
|
'America/Edmonton',
|
||||||
|
'America/Regina',
|
||||||
|
'America/Winnipeg',
|
||||||
|
'America/Toronto',
|
||||||
|
'America/Halifax',
|
||||||
|
'America/St_Johns',
|
||||||
|
'America/New_York',
|
||||||
|
'America/Chicago',
|
||||||
|
'America/Denver',
|
||||||
|
'America/Los_Angeles',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MeetingPlannerPage() {
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const [polls, setPolls] = useState<SchedulingPoll[]>([]);
|
||||||
|
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<SchedulingPollStatus | undefined>();
|
||||||
|
|
||||||
|
// Create drawer
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createForm] = Form.useForm();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
// Detail drawer
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [selectedPoll, setSelectedPoll] = useState<PollDetailResponse | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
// Finalize modal
|
||||||
|
const [finalizeOpen, setFinalizeOpen] = useState(false);
|
||||||
|
const [selectedOptionId, setSelectedOptionId] = useState<string>('');
|
||||||
|
const [finalizing, setFinalizing] = useState(false);
|
||||||
|
|
||||||
|
// Convert modal
|
||||||
|
const [convertOpen, setConvertOpen] = useState(false);
|
||||||
|
const [convertForm] = Form.useForm();
|
||||||
|
const [converting, setConverting] = useState(false);
|
||||||
|
|
||||||
|
const fetchPolls = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = { page: pagination.page, limit: pagination.limit };
|
||||||
|
if (search) params.search = search;
|
||||||
|
if (statusFilter) params.status = statusFilter;
|
||||||
|
const { data } = await api.get<PollsListResponse>('/meeting-planner', { params });
|
||||||
|
setPolls(data.polls);
|
||||||
|
setPagination((p) => ({ ...p, total: data.pagination.total }));
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load polls');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [pagination.page, pagination.limit, search, statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPolls(); }, [fetchPolls]);
|
||||||
|
|
||||||
|
const fetchPollDetail = async (id: string) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<PollDetailResponse>(`/meeting-planner/${id}`);
|
||||||
|
setSelectedPoll(data);
|
||||||
|
setDetailOpen(true);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load poll details');
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (values: any) => {
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const options = values.options.map((opt: any) => ({
|
||||||
|
date: opt.date.format('YYYY-MM-DD'),
|
||||||
|
startTime: opt.startTime.format('HH:mm'),
|
||||||
|
endTime: opt.endTime.format('HH:mm'),
|
||||||
|
}));
|
||||||
|
await api.post('/meeting-planner', {
|
||||||
|
title: values.title,
|
||||||
|
description: values.description,
|
||||||
|
location: values.location,
|
||||||
|
timezone: values.timezone,
|
||||||
|
allowAnonymous: values.allowAnonymous ?? true,
|
||||||
|
notifyOnVote: values.notifyOnVote ?? true,
|
||||||
|
votingDeadline: values.votingDeadline?.toISOString(),
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
message.success('Poll created');
|
||||||
|
setCreateOpen(false);
|
||||||
|
createForm.resetFields();
|
||||||
|
fetchPolls();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to create poll');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/meeting-planner/${id}`);
|
||||||
|
message.success('Poll deleted');
|
||||||
|
fetchPolls();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to delete poll');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinalize = async () => {
|
||||||
|
if (!selectedPoll || !selectedOptionId) return;
|
||||||
|
setFinalizing(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/meeting-planner/${selectedPoll.id}/finalize`, {
|
||||||
|
optionId: selectedOptionId,
|
||||||
|
});
|
||||||
|
message.success('Poll finalized');
|
||||||
|
setFinalizeOpen(false);
|
||||||
|
fetchPollDetail(selectedPoll.id);
|
||||||
|
fetchPolls();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to finalize poll');
|
||||||
|
} finally {
|
||||||
|
setFinalizing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConvertToShift = async (values: any) => {
|
||||||
|
if (!selectedPoll) return;
|
||||||
|
setConverting(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/meeting-planner/${selectedPoll.id}/convert-to-shift`, {
|
||||||
|
maxVolunteers: values.maxVolunteers,
|
||||||
|
isPublic: values.isPublic ?? true,
|
||||||
|
});
|
||||||
|
message.success('Converted to shift');
|
||||||
|
setConvertOpen(false);
|
||||||
|
convertForm.resetFields();
|
||||||
|
fetchPollDetail(selectedPoll.id);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to convert to shift');
|
||||||
|
} finally {
|
||||||
|
setConverting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConvertToEvent = async () => {
|
||||||
|
if (!selectedPoll) return;
|
||||||
|
try {
|
||||||
|
await api.post(`/meeting-planner/${selectedPoll.id}/convert-to-event`);
|
||||||
|
message.success('Converted to Gancio event');
|
||||||
|
fetchPollDetail(selectedPoll.id);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to convert to event');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteComment = async (commentId: string) => {
|
||||||
|
if (!selectedPoll) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/meeting-planner/${selectedPoll.id}/comments/${commentId}`);
|
||||||
|
message.success('Comment deleted');
|
||||||
|
fetchPollDetail(selectedPoll.id);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to delete comment');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyShareLink = (slug: string) => {
|
||||||
|
const url = `${window.location.origin}/poll/${slug}`;
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
message.success('Share link copied');
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<SchedulingPoll> = [
|
||||||
|
{
|
||||||
|
title: 'Title',
|
||||||
|
dataIndex: 'title',
|
||||||
|
key: 'title',
|
||||||
|
render: (title: string, record) => (
|
||||||
|
<Button type="link" onClick={() => fetchPollDetail(record.id)} style={{ padding: 0 }}>
|
||||||
|
{title}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 110,
|
||||||
|
render: (status: SchedulingPollStatus) => (
|
||||||
|
<Tag color={POLL_STATUS_COLORS[status]}>{POLL_STATUS_LABELS[status]}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Options',
|
||||||
|
key: 'options',
|
||||||
|
width: 80,
|
||||||
|
render: (_, record) => record._count?.options ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Votes',
|
||||||
|
key: 'votes',
|
||||||
|
width: 80,
|
||||||
|
render: (_, record) => record._count?.votes ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Created',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 120,
|
||||||
|
render: (date: string) => dayjs(date).format('MMM D, YYYY'),
|
||||||
|
responsive: ['md'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
key: 'actions',
|
||||||
|
width: 140,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Tooltip title="Copy share link">
|
||||||
|
<Button size="small" icon={<CopyOutlined />} onClick={() => copyShareLink(record.slug)} />
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm title="Delete this poll?" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Voting Matrix Component ---
|
||||||
|
const VotingMatrix = ({ poll }: { poll: PollDetailResponse }) => {
|
||||||
|
if (!poll.options?.length) return <Text type="secondary">No options yet</Text>;
|
||||||
|
|
||||||
|
const bestScore = Math.max(...poll.options.map((o) => o.score ?? 0));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ padding: '8px 12px', borderBottom: '2px solid #303030', textAlign: 'left', minWidth: 120 }}>
|
||||||
|
Voter
|
||||||
|
</th>
|
||||||
|
{poll.options.map((opt) => (
|
||||||
|
<th
|
||||||
|
key={opt.id}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: '2px solid #303030',
|
||||||
|
textAlign: 'center',
|
||||||
|
minWidth: 100,
|
||||||
|
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82, 196, 26, 0.1)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>{dayjs(opt.date).format('MMM D')}</div>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7 }}>{opt.startTime}–{opt.endTime}</div>
|
||||||
|
{poll.finalizedOptionId === opt.id && (
|
||||||
|
<Tag color="blue" style={{ marginTop: 4 }}>Selected</Tag>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{poll.voters?.map((voter, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td style={{ padding: '6px 12px', borderBottom: '1px solid #303030' }}>
|
||||||
|
{voter.name}
|
||||||
|
</td>
|
||||||
|
{poll.options.map((opt) => {
|
||||||
|
const value = voter.votes[opt.id] as PollVoteValue | undefined;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={opt.id}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderBottom: '1px solid #303030',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: value ? `${VOTE_VALUE_COLORS[value]}20` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value ? (
|
||||||
|
<Tag
|
||||||
|
color={value === 'YES' ? 'green' : value === 'IF_NEED_BE' ? 'gold' : 'default'}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
>
|
||||||
|
{VOTE_VALUE_LABELS[value]}
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">—</Text>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{/* Tally row */}
|
||||||
|
<tr style={{ fontWeight: 600 }}>
|
||||||
|
<td style={{ padding: '8px 12px', borderTop: '2px solid #303030' }}>Score</td>
|
||||||
|
{poll.options.map((opt) => (
|
||||||
|
<td
|
||||||
|
key={opt.id}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderTop: '2px solid #303030',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82, 196, 26, 0.15)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>{opt.score ?? 0}</div>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7 }}>
|
||||||
|
{opt.yesCount}Y / {opt.ifNeedBeCount}M / {opt.noCount}N
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: screens.md ? 24 : 16 }}>
|
||||||
|
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||||
|
<Col>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>
|
||||||
|
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||||
|
Meeting Planner
|
||||||
|
</Title>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||||
|
{screens.md ? 'Create Poll' : 'New'}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col xs={24} sm={12} md={8}>
|
||||||
|
<Input
|
||||||
|
placeholder="Search polls..."
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Select
|
||||||
|
placeholder="Filter by status"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
allowClear
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={setStatusFilter}
|
||||||
|
options={Object.entries(POLL_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
dataSource={polls}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: pagination.page,
|
||||||
|
pageSize: pagination.limit,
|
||||||
|
total: pagination.total,
|
||||||
|
showSizeChanger: false,
|
||||||
|
onChange: (page) => setPagination((p) => ({ ...p, page })),
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Create Poll Drawer */}
|
||||||
|
<Drawer
|
||||||
|
title="Create Scheduling Poll"
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
width={screens.md ? 560 : '100%'}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={createForm}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleCreate}
|
||||||
|
initialValues={{
|
||||||
|
timezone: 'America/Edmonton',
|
||||||
|
allowAnonymous: true,
|
||||||
|
notifyOnVote: true,
|
||||||
|
options: [
|
||||||
|
{ date: null, startTime: null, endTime: null },
|
||||||
|
{ date: null, startTime: null, endTime: null },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="title" label="Title" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="e.g., Team Planning Meeting" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="Description">
|
||||||
|
<Input.TextArea rows={3} placeholder="What is this meeting about?" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="location" label="Location">
|
||||||
|
<Input placeholder="e.g., Community Centre Room 204" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="timezone" label="Timezone">
|
||||||
|
<Select options={TIMEZONE_OPTIONS.map((tz) => ({ value: tz, label: tz }))} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider>Date/Time Options</Divider>
|
||||||
|
|
||||||
|
<Form.List name="options">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
{fields.map(({ key, name }) => (
|
||||||
|
<Row key={key} gutter={8} align="middle" style={{ marginBottom: 8 }}>
|
||||||
|
<Col flex="auto">
|
||||||
|
<Space wrap>
|
||||||
|
<Form.Item name={[name, 'date']} noStyle rules={[{ required: true, message: 'Date required' }]}>
|
||||||
|
<DatePicker format="YYYY-MM-DD" placeholder="Date" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={[name, 'startTime']} noStyle rules={[{ required: true, message: 'Start required' }]}>
|
||||||
|
<TimePicker format="HH:mm" minuteStep={15} placeholder="Start" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={[name, 'endTime']} noStyle rules={[{ required: true, message: 'End required' }]}>
|
||||||
|
<TimePicker format="HH:mm" minuteStep={15} placeholder="End" />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
{fields.length > 2 && (
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => remove(name)} />
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={() => add({ date: null, startTime: null, endTime: null })}
|
||||||
|
block
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
disabled={fields.length >= 20}
|
||||||
|
>
|
||||||
|
Add Option
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
|
||||||
|
<Divider>Settings</Divider>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="allowAnonymous" label="Allow Anonymous" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="notifyOnVote" label="Notify on Vote" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="votingDeadline" label="Voting Deadline (optional)">
|
||||||
|
<DatePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Button type="primary" htmlType="submit" loading={creating} block>
|
||||||
|
Create Poll
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Poll Detail Drawer */}
|
||||||
|
<Drawer
|
||||||
|
title={selectedPoll?.title || 'Poll Details'}
|
||||||
|
open={detailOpen}
|
||||||
|
onClose={() => { setDetailOpen(false); setSelectedPoll(null); }}
|
||||||
|
width={screens.md ? 720 : '100%'}
|
||||||
|
loading={detailLoading}
|
||||||
|
extra={
|
||||||
|
selectedPoll && (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => copyShareLink(selectedPoll.slug)}
|
||||||
|
>
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedPoll && (
|
||||||
|
<>
|
||||||
|
{/* Status + Meta */}
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col>
|
||||||
|
<Tag color={POLL_STATUS_COLORS[selectedPoll.status]}>
|
||||||
|
{POLL_STATUS_LABELS[selectedPoll.status]}
|
||||||
|
</Tag>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Text type="secondary">
|
||||||
|
{selectedPoll._count?.votes ?? 0} votes across {selectedPoll._count?.options ?? 0} options
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{selectedPoll.description && (
|
||||||
|
<Text style={{ display: 'block', marginBottom: 16 }}>{selectedPoll.description}</Text>
|
||||||
|
)}
|
||||||
|
{selectedPoll.location && (
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
Location: {selectedPoll.location}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Voting Matrix */}
|
||||||
|
<Card size="small" title="Voting Matrix" style={{ marginBottom: 16 }}>
|
||||||
|
<VotingMatrix poll={selectedPoll} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Card size="small" title="Actions" style={{ marginBottom: 16 }}>
|
||||||
|
<Space wrap>
|
||||||
|
{selectedPoll.status === 'OPEN' && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedOptionId('');
|
||||||
|
setFinalizeOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Finalize
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{selectedPoll.status === 'FINALIZED' && !selectedPoll.convertedShiftId && (
|
||||||
|
<Button
|
||||||
|
icon={<TeamOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
convertForm.resetFields();
|
||||||
|
setConvertOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Convert to Shift
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{selectedPoll.status === 'FINALIZED' && !selectedPoll.convertedGancioEventId && (
|
||||||
|
<Popconfirm title="Convert to Gancio event?" onConfirm={handleConvertToEvent}>
|
||||||
|
<Button icon={<CalendarOutlined />}>Convert to Event</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
{selectedPoll.convertedShiftId && (
|
||||||
|
<Tag color="blue" icon={<CheckCircleOutlined />}>Converted to Shift</Tag>
|
||||||
|
)}
|
||||||
|
{selectedPoll.convertedGancioEventId && (
|
||||||
|
<Tag color="green" icon={<CheckCircleOutlined />}>Converted to Event</Tag>
|
||||||
|
)}
|
||||||
|
{selectedPoll.status === 'OPEN' && (
|
||||||
|
<Popconfirm
|
||||||
|
title="Close this poll?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await api.put(`/meeting-planner/${selectedPoll.id}`, { status: 'CLOSED' });
|
||||||
|
message.success('Poll closed');
|
||||||
|
fetchPollDetail(selectedPoll.id);
|
||||||
|
fetchPolls();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button icon={<ClockCircleOutlined />}>Close</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
{selectedPoll.comments && selectedPoll.comments.length > 0 && (
|
||||||
|
<Card size="small" title={`Comments (${selectedPoll.comments.length})`}>
|
||||||
|
{selectedPoll.comments.map((comment) => (
|
||||||
|
<div key={comment.id} style={{ marginBottom: 12, padding: '8px 0', borderBottom: '1px solid #303030' }}>
|
||||||
|
<Row justify="space-between">
|
||||||
|
<Col>
|
||||||
|
<Text strong>{comment.authorName}</Text>
|
||||||
|
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
|
||||||
|
{dayjs(comment.createdAt).format('MMM D, h:mm A')}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Popconfirm title="Delete comment?" onConfirm={() => handleDeleteComment(comment.id)}>
|
||||||
|
<Button size="small" danger type="text" icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Text style={{ display: 'block', marginTop: 4 }}>{comment.content}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Finalize Modal */}
|
||||||
|
<Modal
|
||||||
|
title="Finalize Poll"
|
||||||
|
open={finalizeOpen}
|
||||||
|
onCancel={() => setFinalizeOpen(false)}
|
||||||
|
onOk={handleFinalize}
|
||||||
|
confirmLoading={finalizing}
|
||||||
|
okText="Finalize"
|
||||||
|
okButtonProps={{ disabled: !selectedOptionId }}
|
||||||
|
>
|
||||||
|
<Text style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
Select the winning date/time option:
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
placeholder="Select option"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={selectedOptionId || undefined}
|
||||||
|
onChange={setSelectedOptionId}
|
||||||
|
options={selectedPoll?.options?.map((opt) => ({
|
||||||
|
value: opt.id,
|
||||||
|
label: `${dayjs(opt.date).format('MMM D, YYYY')} ${opt.startTime}–${opt.endTime} (score: ${opt.score ?? 0})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Convert to Shift Modal */}
|
||||||
|
<Modal
|
||||||
|
title="Convert to Volunteer Shift"
|
||||||
|
open={convertOpen}
|
||||||
|
onCancel={() => setConvertOpen(false)}
|
||||||
|
onOk={() => convertForm.submit()}
|
||||||
|
confirmLoading={converting}
|
||||||
|
okText="Create Shift"
|
||||||
|
>
|
||||||
|
<Form form={convertForm} layout="vertical" onFinish={handleConvertToShift} initialValues={{ maxVolunteers: 10, isPublic: true }}>
|
||||||
|
<Form.Item name="maxVolunteers" label="Max Volunteers" rules={[{ required: true }]}>
|
||||||
|
<Input type="number" min={1} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="isPublic" label="Public" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -505,7 +505,10 @@ export default function SettingsPage() {
|
|||||||
<Form.Item label="Video Meetings (Jitsi)" name="enableMeet" valuePropName="checked" extra="Self-hosted video calls — integrates with Rocket.Chat channels" style={{ marginBottom: 12 }}>
|
<Form.Item label="Video Meetings (Jitsi)" name="enableMeet" valuePropName="checked" extra="Self-hosted video calls — integrates with Rocket.Chat channels" style={{ marginBottom: 12 }}>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="SMS Campaigns" name="enableSms" valuePropName="checked" extra="Termux Android SMS for campaign texting and conversations" style={{ marginBottom: 0 }}>
|
<Form.Item label="SMS Campaigns" name="enableSms" valuePropName="checked" extra="Termux Android SMS for campaign texting and conversations" style={{ marginBottom: 12 }}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Meeting Planner" name="enableMeetingPlanner" valuePropName="checked" extra="Scheduling polls for finding the best meeting time" style={{ marginBottom: 0 }}>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { DonationWidget } from '@/components/payments/DonationWidget';
|
|||||||
import { PricingWidget } from '@/components/payments/PricingWidget';
|
import { PricingWidget } from '@/components/payments/PricingWidget';
|
||||||
import { ProductWidget } from '@/components/payments/ProductWidget';
|
import { ProductWidget } from '@/components/payments/ProductWidget';
|
||||||
import { CampaignFormWidget } from '@/components/influence/CampaignFormWidget';
|
import { CampaignFormWidget } from '@/components/influence/CampaignFormWidget';
|
||||||
|
import { SchedulingPollWidget } from '@/components/scheduling/SchedulingPollWidget';
|
||||||
import GalleryAdCard from '@/components/media/GalleryAdCard';
|
import GalleryAdCard from '@/components/media/GalleryAdCard';
|
||||||
import type { GalleryAd } from '@/types/gallery-ads';
|
import type { GalleryAd } from '@/types/gallery-ads';
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ export default function PublicLandingPage() {
|
|||||||
const paymentRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
const paymentRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||||
const campaignFormRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
const campaignFormRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||||
const adRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
const adRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||||
|
const pollRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
|
||||||
|
|
||||||
// Track page view
|
// Track page view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -322,12 +324,44 @@ export default function PublicLandingPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hydrate scheduling poll blocks
|
||||||
|
const hydratePollBlocks = () => {
|
||||||
|
const pollBlocks = contentRef.current?.querySelectorAll('.scheduling-poll-block');
|
||||||
|
if (!pollBlocks) return;
|
||||||
|
|
||||||
|
pollRootsRef.current.forEach((root) => {
|
||||||
|
try { root.unmount(); } catch (err) { console.error('Failed to unmount poll root:', err); }
|
||||||
|
});
|
||||||
|
pollRootsRef.current = [];
|
||||||
|
|
||||||
|
pollBlocks.forEach((blockEl) => {
|
||||||
|
const pollSlug = blockEl.getAttribute('data-poll-slug');
|
||||||
|
if (!pollSlug) return;
|
||||||
|
|
||||||
|
const showComments = blockEl.getAttribute('data-show-comments') !== 'false';
|
||||||
|
const title = blockEl.getAttribute('data-title') || undefined;
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
blockEl.innerHTML = '';
|
||||||
|
blockEl.appendChild(container);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const root = createRoot(container);
|
||||||
|
pollRootsRef.current.push(root);
|
||||||
|
root.render(<SchedulingPollWidget pollSlug={pollSlug} showComments={showComments} title={title} />);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to render scheduling poll widget:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Hydrate after DOM is ready
|
// Hydrate after DOM is ready
|
||||||
setTimeout(hydrateVideoBlocks, 100);
|
setTimeout(hydrateVideoBlocks, 100);
|
||||||
setTimeout(hydrateVideoCards, 200);
|
setTimeout(hydrateVideoCards, 200);
|
||||||
setTimeout(hydratePaymentBlocks, 150);
|
setTimeout(hydratePaymentBlocks, 150);
|
||||||
setTimeout(hydrateCampaignFormBlocks, 175);
|
setTimeout(hydrateCampaignFormBlocks, 175);
|
||||||
setTimeout(hydrateAdBlocks, 200);
|
setTimeout(hydrateAdBlocks, 200);
|
||||||
|
setTimeout(hydratePollBlocks, 180);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
return () => {
|
return () => {
|
||||||
@ -350,6 +384,11 @@ export default function PublicLandingPage() {
|
|||||||
try { root.unmount(); } catch (err) { console.error('Failed to unmount ad root on cleanup:', err); }
|
try { root.unmount(); } catch (err) { console.error('Failed to unmount ad root on cleanup:', err); }
|
||||||
});
|
});
|
||||||
adRootsRef.current = [];
|
adRootsRef.current = [];
|
||||||
|
|
||||||
|
pollRootsRef.current.forEach((root) => {
|
||||||
|
try { root.unmount(); } catch (err) { console.error('Failed to unmount poll root on cleanup:', err); }
|
||||||
|
});
|
||||||
|
pollRootsRef.current = [];
|
||||||
};
|
};
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|
||||||
|
|||||||
139
admin/src/pages/public/PollsListPage.tsx
Normal file
139
admin/src/pages/public/PollsListPage.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Tag,
|
||||||
|
Spin,
|
||||||
|
Grid,
|
||||||
|
Space,
|
||||||
|
Empty,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
CalendarOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import axios from 'axios';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { SchedulingPoll, PollsListResponse, SchedulingPollStatus } from '@/types/api';
|
||||||
|
import { POLL_STATUS_COLORS, POLL_STATUS_LABELS } from '@/types/api';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
export default function PollsListPage() {
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [polls, setPolls] = useState<SchedulingPoll[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPolls = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<PollsListResponse>('/api/meeting-planner/public');
|
||||||
|
setPolls(data.polls);
|
||||||
|
} catch {
|
||||||
|
// If unauthorized, try the public listing approach
|
||||||
|
setPolls([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchPolls();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 900, margin: '0 auto', padding: isMobile ? '16px 12px' : '24px 16px' }}>
|
||||||
|
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 24 }}>
|
||||||
|
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||||
|
Scheduling Polls
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{polls.length === 0 ? (
|
||||||
|
<Empty description="No open polls at the moment" />
|
||||||
|
) : (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{polls.map((poll) => {
|
||||||
|
const optionCount = poll._count?.options ?? 0;
|
||||||
|
const voteCount = poll._count?.votes ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col xs={24} sm={12} key={poll.id}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
onClick={() => navigate(`/poll/${poll.slug}`)}
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
|
<Row justify="space-between" align="top">
|
||||||
|
<Col flex="auto">
|
||||||
|
<Text strong style={{ fontSize: 16 }}>{poll.title}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Tag color={POLL_STATUS_COLORS[poll.status as SchedulingPollStatus]}>
|
||||||
|
{POLL_STATUS_LABELS[poll.status as SchedulingPollStatus]}
|
||||||
|
</Tag>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{poll.description && (
|
||||||
|
<Paragraph
|
||||||
|
type="secondary"
|
||||||
|
ellipsis={{ rows: 2 }}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
{poll.description}
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Space split={<span style={{ opacity: 0.3 }}>|</span>} wrap>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
<CalendarOutlined style={{ marginRight: 4 }} />
|
||||||
|
{optionCount} options
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
<TeamOutlined style={{ marginRight: 4 }} />
|
||||||
|
{voteCount} votes
|
||||||
|
</Text>
|
||||||
|
{poll.location && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
<EnvironmentOutlined style={{ marginRight: 4 }} />
|
||||||
|
{poll.location}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{poll.votingDeadline && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||||||
|
Deadline: {dayjs(poll.votingDeadline).format('MMM D, YYYY h:mm A')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="primary" size="small" block style={{ marginTop: 8 }}>
|
||||||
|
Vote Now
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
501
admin/src/pages/public/SchedulingPollPage.tsx
Normal file
501
admin/src/pages/public/SchedulingPollPage.tsx
Normal file
@ -0,0 +1,501 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Tag,
|
||||||
|
Input,
|
||||||
|
Radio,
|
||||||
|
message,
|
||||||
|
Spin,
|
||||||
|
Result,
|
||||||
|
Grid,
|
||||||
|
Space,
|
||||||
|
Divider,
|
||||||
|
Alert,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
CalendarOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import axios from 'axios';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import type {
|
||||||
|
PollDetailResponse,
|
||||||
|
PollVoteValue,
|
||||||
|
} from '@/types/api';
|
||||||
|
import {
|
||||||
|
POLL_STATUS_COLORS,
|
||||||
|
POLL_STATUS_LABELS,
|
||||||
|
VOTE_VALUE_COLORS,
|
||||||
|
VOTE_VALUE_LABELS,
|
||||||
|
} from '@/types/api';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
const VOTER_TOKEN_KEY = 'poll_voter_token_';
|
||||||
|
|
||||||
|
export default function SchedulingPollPage() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
const [poll, setPoll] = useState<PollDetailResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
// Vote form state
|
||||||
|
const [voterName, setVoterName] = useState(user?.name || '');
|
||||||
|
const [voterEmail, setVoterEmail] = useState(user?.email || '');
|
||||||
|
const [votes, setVotes] = useState<Record<string, PollVoteValue>>({});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [hasVoted, setHasVoted] = useState(false);
|
||||||
|
|
||||||
|
// Comment form state
|
||||||
|
const [commentName, setCommentName] = useState(user?.name || '');
|
||||||
|
const [commentContent, setCommentContent] = useState('');
|
||||||
|
const [commentSubmitting, setCommentSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const fetchPoll = useCallback(async () => {
|
||||||
|
if (!slug) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<PollDetailResponse>(`/api/meeting-planner/public/${slug}`);
|
||||||
|
setPoll(data);
|
||||||
|
|
||||||
|
// Check if user has already voted (by token or auth)
|
||||||
|
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + slug);
|
||||||
|
if (storedToken || user) {
|
||||||
|
const existingVoter = data.voters?.find((v) => {
|
||||||
|
if (user) return data.votes?.some((vote) => vote.userId === user.id && v.name === vote.voterName);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (existingVoter) {
|
||||||
|
setVoterName(existingVoter.name);
|
||||||
|
setVotes(existingVoter.votes);
|
||||||
|
setHasVoted(true);
|
||||||
|
} else if (storedToken) {
|
||||||
|
// Find voter by checking if any vote has our token
|
||||||
|
// We can't see tokens in the response, but if we stored one, we know we voted
|
||||||
|
setHasVoted(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [slug, user]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPoll(); }, [fetchPoll]);
|
||||||
|
|
||||||
|
const handleVoteChange = (optionId: string, value: PollVoteValue) => {
|
||||||
|
setVotes((prev) => ({ ...prev, [optionId]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitVotes = async () => {
|
||||||
|
if (!poll || !voterName.trim()) {
|
||||||
|
message.warning('Please enter your name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Object.keys(votes).length) {
|
||||||
|
message.warning('Please vote on at least one option');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + slug);
|
||||||
|
const { data } = await axios.post(`/api/meeting-planner/public/${slug}/vote`, {
|
||||||
|
voterName: voterName.trim(),
|
||||||
|
voterEmail: voterEmail.trim() || undefined,
|
||||||
|
voterToken: storedToken || undefined,
|
||||||
|
votes: Object.entries(votes).map(([optionId, value]) => ({ optionId, value })),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store voter token for anonymous edit access
|
||||||
|
if (data.voterToken) {
|
||||||
|
localStorage.setItem(VOTER_TOKEN_KEY + slug, data.voterToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success(hasVoted ? 'Votes updated' : 'Votes submitted');
|
||||||
|
setHasVoted(true);
|
||||||
|
fetchPoll();
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.error?.message || 'Failed to submit votes');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitComment = async () => {
|
||||||
|
if (!commentName.trim() || !commentContent.trim()) {
|
||||||
|
message.warning('Please enter your name and comment');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCommentSubmitting(true);
|
||||||
|
try {
|
||||||
|
await axios.post(`/api/meeting-planner/public/${slug}/comment`, {
|
||||||
|
authorName: commentName.trim(),
|
||||||
|
content: commentContent.trim(),
|
||||||
|
});
|
||||||
|
message.success('Comment added');
|
||||||
|
setCommentContent('');
|
||||||
|
fetchPoll();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to add comment');
|
||||||
|
} finally {
|
||||||
|
setCommentSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !poll) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="404"
|
||||||
|
title="Poll Not Found"
|
||||||
|
subTitle="This scheduling poll may have been removed or the link is invalid."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = poll.status === 'OPEN';
|
||||||
|
const isFinalized = poll.status === 'FINALIZED';
|
||||||
|
const bestScore = poll.options?.length ? Math.max(...poll.options.map((o) => o.score ?? 0)) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 900, margin: '0 auto', padding: isMobile ? '16px 12px' : '24px 16px' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>
|
||||||
|
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||||
|
{poll.title}
|
||||||
|
</Title>
|
||||||
|
{poll.description && (
|
||||||
|
<Paragraph style={{ fontSize: 16, opacity: 0.85, marginBottom: 12 }}>
|
||||||
|
{poll.description}
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color={POLL_STATUS_COLORS[poll.status]}>{POLL_STATUS_LABELS[poll.status]}</Tag>
|
||||||
|
{poll.location && (
|
||||||
|
<Text type="secondary">
|
||||||
|
<EnvironmentOutlined style={{ marginRight: 4 }} />
|
||||||
|
{poll.location}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text type="secondary">
|
||||||
|
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||||||
|
{poll.timezone}
|
||||||
|
</Text>
|
||||||
|
{poll.createdBy && (
|
||||||
|
<Text type="secondary">
|
||||||
|
by {poll.createdBy.name || poll.createdBy.email}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deadline banner */}
|
||||||
|
{poll.votingDeadline && isOpen && (
|
||||||
|
<Alert
|
||||||
|
type={dayjs(poll.votingDeadline).isBefore(dayjs()) ? 'error' : 'info'}
|
||||||
|
message={
|
||||||
|
dayjs(poll.votingDeadline).isBefore(dayjs())
|
||||||
|
? 'Voting deadline has passed'
|
||||||
|
: `Voting deadline: ${dayjs(poll.votingDeadline).format('MMM D, YYYY h:mm A')}`
|
||||||
|
}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Finalized banner */}
|
||||||
|
{isFinalized && poll.finalizedOption && (
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
message="Date Confirmed"
|
||||||
|
description={
|
||||||
|
<Space>
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
<Text strong>
|
||||||
|
{dayjs(poll.finalizedOption.date).format('dddd, MMMM D, YYYY')} — {poll.finalizedOption.startTime}–{poll.finalizedOption.endTime}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Voting */}
|
||||||
|
{isMobile ? (
|
||||||
|
/* Mobile: vertical cards */
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
{poll.options?.map((opt) => (
|
||||||
|
<Card
|
||||||
|
key={opt.id}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginBottom: 12,
|
||||||
|
border: isFinalized && poll.finalizedOptionId === opt.id ? '2px solid #52c41a' : undefined,
|
||||||
|
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82, 196, 26, 0.05)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row justify="space-between" align="middle">
|
||||||
|
<Col>
|
||||||
|
<Text strong>{dayjs(opt.date).format('ddd, MMM D')}</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">{opt.startTime} – {opt.endTime}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{opt.yesCount}Y / {opt.ifNeedBeCount}M / {opt.noCount}N
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{isOpen && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Radio.Group
|
||||||
|
value={votes[opt.id]}
|
||||||
|
onChange={(e) => handleVoteChange(opt.id, e.target.value)}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Radio.Button value="YES" style={{ color: votes[opt.id] === 'YES' ? '#fff' : undefined }}>Yes</Radio.Button>
|
||||||
|
<Radio.Button value="IF_NEED_BE">If Need Be</Radio.Button>
|
||||||
|
<Radio.Button value="NO">No</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Desktop: voting matrix */
|
||||||
|
<Card size="small" style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ padding: '10px 14px', borderBottom: '2px solid #303030', textAlign: 'left', minWidth: 140 }}>
|
||||||
|
Participant
|
||||||
|
</th>
|
||||||
|
{poll.options?.map((opt) => (
|
||||||
|
<th
|
||||||
|
key={opt.id}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderBottom: '2px solid #303030',
|
||||||
|
textAlign: 'center',
|
||||||
|
minWidth: 110,
|
||||||
|
background: isFinalized && poll.finalizedOptionId === opt.id
|
||||||
|
? 'rgba(82, 196, 26, 0.15)'
|
||||||
|
: opt.score === bestScore && bestScore > 0
|
||||||
|
? 'rgba(82, 196, 26, 0.08)'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>{dayjs(opt.date).format('ddd, MMM D')}</div>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7 }}>{opt.startTime}–{opt.endTime}</div>
|
||||||
|
{isFinalized && poll.finalizedOptionId === opt.id && (
|
||||||
|
<Tag color="green" style={{ marginTop: 4, fontSize: 10 }}>Confirmed</Tag>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{poll.voters?.map((voter, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td style={{ padding: '8px 14px', borderBottom: '1px solid #252525' }}>
|
||||||
|
{voter.name}
|
||||||
|
</td>
|
||||||
|
{poll.options?.map((opt) => {
|
||||||
|
const value = voter.votes[opt.id] as PollVoteValue | undefined;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={opt.id}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderBottom: '1px solid #252525',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: value ? `${VOTE_VALUE_COLORS[value]}18` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value && (
|
||||||
|
<Tag
|
||||||
|
color={value === 'YES' ? 'green' : value === 'IF_NEED_BE' ? 'gold' : 'default'}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
>
|
||||||
|
{VOTE_VALUE_LABELS[value]}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{/* Score row */}
|
||||||
|
<tr style={{ fontWeight: 600 }}>
|
||||||
|
<td style={{ padding: '10px 14px', borderTop: '2px solid #303030' }}>Score</td>
|
||||||
|
{poll.options?.map((opt) => (
|
||||||
|
<td
|
||||||
|
key={opt.id}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderTop: '2px solid #303030',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82, 196, 26, 0.12)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 16 }}>{opt.score ?? 0}</div>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7 }}>
|
||||||
|
{opt.yesCount}Y / {opt.ifNeedBeCount}M / {opt.noCount}N
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vote Form */}
|
||||||
|
{isOpen && (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={hasVoted ? 'Update Your Votes' : 'Cast Your Votes'}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
>
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<Input
|
||||||
|
placeholder="Your name"
|
||||||
|
value={voterName}
|
||||||
|
onChange={(e) => setVoterName(e.target.value)}
|
||||||
|
disabled={!!user}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<Input
|
||||||
|
placeholder="Email (optional, for notifications)"
|
||||||
|
value={voterEmail}
|
||||||
|
onChange={(e) => setVoterEmail(e.target.value)}
|
||||||
|
disabled={!!user}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Desktop: inline radio buttons per option */}
|
||||||
|
{!isMobile && poll.options?.map((opt) => (
|
||||||
|
<Row key={opt.id} gutter={12} align="middle" style={{ marginBottom: 8 }}>
|
||||||
|
<Col flex="200px">
|
||||||
|
<Text>{dayjs(opt.date).format('ddd, MMM D')} {opt.startTime}–{opt.endTime}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col flex="auto">
|
||||||
|
<Radio.Group
|
||||||
|
value={votes[opt.id]}
|
||||||
|
onChange={(e) => handleVoteChange(opt.id, e.target.value)}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Radio.Button value="YES">Yes</Radio.Button>
|
||||||
|
<Radio.Button value="IF_NEED_BE">If Need Be</Radio.Button>
|
||||||
|
<Radio.Button value="NO">No</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={handleSubmitVotes}
|
||||||
|
loading={submitting}
|
||||||
|
block
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
{hasVoted ? 'Update Votes' : 'Submit Votes'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments Section */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={`Comments (${poll.comments?.length ?? 0})`}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
>
|
||||||
|
{poll.comments?.map((comment) => (
|
||||||
|
<div key={comment.id} style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #252525' }}>
|
||||||
|
<Row justify="space-between">
|
||||||
|
<Col>
|
||||||
|
<Text strong>{comment.authorName}</Text>
|
||||||
|
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
|
||||||
|
{dayjs(comment.createdAt).format('MMM D, h:mm A')}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Text style={{ display: 'block', marginTop: 4 }}>{comment.content}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{poll.status !== 'CANCELLED' && (
|
||||||
|
<>
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
<Row gutter={[8, 8]}>
|
||||||
|
<Col xs={24} sm={6}>
|
||||||
|
<Input
|
||||||
|
placeholder="Your name"
|
||||||
|
value={commentName}
|
||||||
|
onChange={(e) => setCommentName(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={14}>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
value={commentContent}
|
||||||
|
onChange={(e) => setCommentContent(e.target.value)}
|
||||||
|
rows={1}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={4}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={handleSubmitComment}
|
||||||
|
loading={commentSubmitting}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Post
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Table, Button, Modal, Form, Input, Select, InputNumber, Space, Tag, Progress, App, Typography, Popconfirm, Divider, Alert } from 'antd';
|
import { Table, Button, Drawer, Form, Input, Select, InputNumber, Space, Tag, Progress, App, Typography, Popconfirm, Divider, Alert } from 'antd';
|
||||||
import { PlusOutlined, PlayCircleOutlined, PauseCircleOutlined, CaretRightOutlined, DeleteOutlined, EyeOutlined, SendOutlined, PhoneOutlined } from '@ant-design/icons';
|
import { PlusOutlined, PlayCircleOutlined, PauseCircleOutlined, CaretRightOutlined, DeleteOutlined, EyeOutlined, SendOutlined, PhoneOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -26,9 +26,10 @@ export default function SmsCampaignsPage() {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [contactLists, setContactLists] = useState<SmsContactList[]>([]);
|
const [contactLists, setContactLists] = useState<SmsContactList[]>([]);
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// Preview & Test
|
// Preview & Test
|
||||||
const [testPreview, setTestPreview] = useState('');
|
const [testPreview, setTestPreview] = useState('');
|
||||||
@ -67,16 +68,21 @@ export default function SmsCampaignsPage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [campaigns, fetchCampaigns]);
|
}, [campaigns, fetchCampaigns]);
|
||||||
|
|
||||||
|
const closeDrawer = () => { setDrawerOpen(false); setTestPreview(''); };
|
||||||
|
|
||||||
const handleCreate = async (values: { name: string; messageTemplate: string; contactListId: string; delayBetweenMs: number }) => {
|
const handleCreate = async (values: { name: string; messageTemplate: string; contactListId: string; delayBetweenMs: number }) => {
|
||||||
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.post('/sms/campaigns', values);
|
await api.post('/sms/campaigns', values);
|
||||||
message.success('Campaign created');
|
message.success('Campaign created');
|
||||||
setCreateOpen(false);
|
closeDrawer();
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
fetchCampaigns();
|
fetchCampaigns();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Create failed';
|
const msg = err instanceof Error ? err.message : 'Create failed';
|
||||||
message.error(msg);
|
message.error(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -209,34 +215,47 @@ export default function SmsCampaignsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const drawerWidth = 480;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<div style={{ marginRight: drawerOpen ? drawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
|
||||||
<Button
|
<Space style={{ marginBottom: 16 }}>
|
||||||
type="primary"
|
<Button
|
||||||
icon={<PlusOutlined />}
|
type="primary"
|
||||||
onClick={() => { setCreateOpen(true); fetchContactLists(); }}
|
icon={<PlusOutlined />}
|
||||||
>
|
onClick={() => { setDrawerOpen(true); fetchContactLists(); }}
|
||||||
New Campaign
|
>
|
||||||
</Button>
|
New Campaign
|
||||||
</Space>
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={campaigns}
|
dataSource={campaigns}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{ current: page, total, pageSize: 50, onChange: setPage }}
|
pagination={{ current: page, total, pageSize: 50, onChange: setPage }}
|
||||||
size="middle"
|
size="middle"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create Campaign Modal */}
|
<Drawer
|
||||||
<Modal
|
|
||||||
title="New SMS Campaign"
|
title="New SMS Campaign"
|
||||||
open={createOpen}
|
open={drawerOpen}
|
||||||
onCancel={() => { setCreateOpen(false); setTestPreview(''); }}
|
onClose={closeDrawer}
|
||||||
onOk={() => createForm.submit()}
|
destroyOnHidden
|
||||||
width={600}
|
mask={false}
|
||||||
|
width={drawerWidth}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={closeDrawer}>Cancel</Button>
|
||||||
|
<Button type="primary" loading={saving} onClick={() => createForm.submit()}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ delayBetweenMs: 3000 }}>
|
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ delayBetweenMs: 3000 }}>
|
||||||
<Form.Item name="name" label="Campaign Name" rules={[{ required: true }]}>
|
<Form.Item name="name" label="Campaign Name" rules={[{ required: true }]}>
|
||||||
@ -306,7 +325,7 @@ export default function SmsCampaignsPage() {
|
|||||||
<InputNumber min={1000} max={60000} step={500} style={{ width: '100%' }} />
|
<InputNumber min={1000} max={60000} step={500} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Table, Button, Modal, Form, Input, Space, Upload, App, Typography, Popconfirm, Tabs, Select, Collapse, Tag } from 'antd';
|
import { Table, Button, Drawer, Form, Input, Space, Upload, App, Typography, Popconfirm, Tabs, Select, Collapse, Tag, Modal } from 'antd';
|
||||||
import { PlusOutlined, UploadOutlined, PhoneOutlined, DeleteOutlined, ImportOutlined, DatabaseOutlined, UserOutlined, EnvironmentOutlined, CalendarOutlined, MessageOutlined, ContactsOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
import { PlusOutlined, UploadOutlined, PhoneOutlined, DeleteOutlined, ImportOutlined, DatabaseOutlined, UserOutlined, EnvironmentOutlined, CalendarOutlined, MessageOutlined, ContactsOutlined, UnorderedListOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { SmsContactList, SmsContactListEntry, SmsPaginatedResponse } from '@/types/sms';
|
import type { SmsContactList, SmsContactListEntry, SmsPaginatedResponse } from '@/types/sms';
|
||||||
@ -35,14 +35,24 @@ export default function SmsContactsPage() {
|
|||||||
const [listsPage, setListsPage] = useState(1);
|
const [listsPage, setListsPage] = useState(1);
|
||||||
const [listsLoading, setListsLoading] = useState(true);
|
const [listsLoading, setListsLoading] = useState(true);
|
||||||
|
|
||||||
// --- Modals ---
|
// --- Drawers ---
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [addContactOpen, setAddContactOpen] = useState(false);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [importListId, setImportListId] = useState<string | null>(null);
|
const [importListId, setImportListId] = useState<string | null>(null);
|
||||||
const [csvText, setCsvText] = useState('');
|
const [csvText, setCsvText] = useState('');
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
|
const [addContactForm] = Form.useForm();
|
||||||
|
const [createSaving, setCreateSaving] = useState(false);
|
||||||
|
const [addContactSaving, setAddContactSaving] = useState(false);
|
||||||
|
|
||||||
// Import modal state
|
// --- Selection + bulk actions ---
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
|
const [addToListModalOpen, setAddToListModalOpen] = useState(false);
|
||||||
|
const [addToListTargetId, setAddToListTargetId] = useState<string | undefined>();
|
||||||
|
const [addToListLoading, setAddToListLoading] = useState(false);
|
||||||
|
|
||||||
|
// Import state
|
||||||
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [importLoading, setImportLoading] = useState(false);
|
const [importLoading, setImportLoading] = useState(false);
|
||||||
@ -102,7 +112,7 @@ export default function SmsContactsPage() {
|
|||||||
fetchEntries(1);
|
fetchEntries(1);
|
||||||
}, [fetchEntries]);
|
}, [fetchEntries]);
|
||||||
|
|
||||||
// Load shifts and SMS campaigns for filter dropdowns when import modal opens
|
// Load shifts and SMS campaigns for filter dropdowns when import drawer opens
|
||||||
const loadFilterOptions = useCallback(async () => {
|
const loadFilterOptions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [shiftsRes, campaignsRes] = await Promise.all([
|
const [shiftsRes, campaignsRes] = await Promise.all([
|
||||||
@ -116,17 +126,71 @@ export default function SmsContactsPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const closeCreateDrawer = () => { setCreateOpen(false); };
|
||||||
|
const closeAddContactDrawer = () => { setAddContactOpen(false); };
|
||||||
|
const closeImportDrawer = () => { setImportOpen(false); resetImportState(); };
|
||||||
|
|
||||||
const handleCreate = async (values: { name: string }) => {
|
const handleCreate = async (values: { name: string }) => {
|
||||||
|
setCreateSaving(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('/sms/contacts', values);
|
const { data } = await api.post('/sms/contacts', values);
|
||||||
message.success('Contact list created');
|
message.success('Contact list created');
|
||||||
setCreateOpen(false);
|
closeCreateDrawer();
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
fetchLists();
|
fetchLists();
|
||||||
// Auto-select the new list in the filter
|
// Auto-select the new list in the filter
|
||||||
setFilterListId(data.id);
|
setFilterListId(data.id);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to create list');
|
message.error('Failed to create list');
|
||||||
|
} finally {
|
||||||
|
setCreateSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddContact = async (values: { listId: string; phone: string; name?: string; email?: string }) => {
|
||||||
|
setAddContactSaving(true);
|
||||||
|
try {
|
||||||
|
const { listId, ...body } = values;
|
||||||
|
await api.post(`/sms/contacts/${listId}/entries`, body);
|
||||||
|
message.success('Contact added');
|
||||||
|
closeAddContactDrawer();
|
||||||
|
addContactForm.resetFields();
|
||||||
|
fetchEntries(entriesPage);
|
||||||
|
fetchLists();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to add contact');
|
||||||
|
} finally {
|
||||||
|
setAddContactSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddContactDrawer = () => {
|
||||||
|
// Pre-fill the list if one is selected in the filter
|
||||||
|
if (filterListId) {
|
||||||
|
addContactForm.setFieldsValue({ listId: filterListId });
|
||||||
|
} else if (lists.length === 1) {
|
||||||
|
addContactForm.setFieldsValue({ listId: lists[0]!.id });
|
||||||
|
}
|
||||||
|
setAddContactOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToList = async () => {
|
||||||
|
if (!addToListTargetId || selectedRowKeys.length === 0) return;
|
||||||
|
setAddToListLoading(true);
|
||||||
|
try {
|
||||||
|
const selected = entries.filter(e => selectedRowKeys.includes(e.id));
|
||||||
|
const payload = selected.map(e => ({ phone: e.phone, name: e.name || undefined, email: e.email || undefined }));
|
||||||
|
const { data } = await api.post(`/sms/contacts/${addToListTargetId}/entries/bulk`, { entries: payload });
|
||||||
|
message.success(`Added ${data.imported} contact${data.imported !== 1 ? 's' : ''} to list${data.skipped ? ` (${data.skipped} skipped)` : ''}`);
|
||||||
|
setAddToListModalOpen(false);
|
||||||
|
setAddToListTargetId(undefined);
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
fetchEntries(entriesPage);
|
||||||
|
fetchLists();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to add contacts to list');
|
||||||
|
} finally {
|
||||||
|
setAddToListLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -149,8 +213,7 @@ export default function SmsContactsPage() {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.post(`/sms/contacts/${importListId}/import-csv`, { csv: csvText });
|
const { data } = await api.post(`/sms/contacts/${importListId}/import-csv`, { csv: csvText });
|
||||||
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
|
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
|
||||||
setImportOpen(false);
|
closeImportDrawer();
|
||||||
resetImportState();
|
|
||||||
fetchLists();
|
fetchLists();
|
||||||
fetchEntries(entriesPage);
|
fetchEntries(entriesPage);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@ -167,8 +230,7 @@ export default function SmsContactsPage() {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.post(`/sms/contacts/${importListId}/import-phone`);
|
const { data } = await api.post(`/sms/contacts/${importListId}/import-phone`);
|
||||||
message.success(`Imported ${data.imported} contacts from phone`);
|
message.success(`Imported ${data.imported} contacts from phone`);
|
||||||
setImportOpen(false);
|
closeImportDrawer();
|
||||||
resetImportState();
|
|
||||||
fetchLists();
|
fetchLists();
|
||||||
fetchEntries(entriesPage);
|
fetchEntries(entriesPage);
|
||||||
} catch {
|
} catch {
|
||||||
@ -189,13 +251,13 @@ export default function SmsContactsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openImportModal = (listId?: string) => {
|
const openImportDrawer = (listId?: string) => {
|
||||||
if (listId) {
|
if (listId) {
|
||||||
setImportListId(listId);
|
setImportListId(listId);
|
||||||
} else if (filterListId) {
|
} else if (filterListId) {
|
||||||
setImportListId(filterListId);
|
setImportListId(filterListId);
|
||||||
} else if (lists.length === 1) {
|
} else if (lists.length === 1) {
|
||||||
setImportListId(lists[0].id);
|
setImportListId(lists[0]!.id);
|
||||||
} else {
|
} else {
|
||||||
message.info('Select a list first, or use the filter dropdown to choose one');
|
message.info('Select a list first, or use the filter dropdown to choose one');
|
||||||
return;
|
return;
|
||||||
@ -277,8 +339,7 @@ export default function SmsContactsPage() {
|
|||||||
}
|
}
|
||||||
const { data } = await api.post(`/sms/contacts/${importListId}/import-${source}`, body);
|
const { data } = await api.post(`/sms/contacts/${importListId}/import-${source}`, body);
|
||||||
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
|
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
|
||||||
setImportOpen(false);
|
closeImportDrawer();
|
||||||
resetImportState();
|
|
||||||
fetchLists();
|
fetchLists();
|
||||||
fetchEntries(entriesPage);
|
fetchEntries(entriesPage);
|
||||||
} catch {
|
} catch {
|
||||||
@ -392,7 +453,7 @@ export default function SmsContactsPage() {
|
|||||||
width: 160,
|
width: 160,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button size="small" icon={<DatabaseOutlined />} onClick={() => openImportModal(record.id)}>Import</Button>
|
<Button size="small" icon={<DatabaseOutlined />} onClick={() => openImportDrawer(record.id)}>Import</Button>
|
||||||
<Popconfirm title="Archive this list?" onConfirm={() => handleArchive(record.id)}>
|
<Popconfirm title="Archive this list?" onConfirm={() => handleArchive(record.id)}>
|
||||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
@ -425,95 +486,209 @@ export default function SmsContactsPage() {
|
|||||||
|
|
||||||
const importListName = importListId ? lists.find(l => l.id === importListId)?.name : undefined;
|
const importListName = importListId ? lists.find(l => l.id === importListId)?.name : undefined;
|
||||||
|
|
||||||
|
// Either drawer being open shifts content
|
||||||
|
const anyDrawerOpen = createOpen || addContactOpen || importOpen;
|
||||||
|
const drawerWidth = 480;
|
||||||
|
const importDrawerWidth = 560;
|
||||||
|
const activeDrawerWidth = importOpen ? importDrawerWidth : drawerWidth;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Top toolbar */}
|
<div style={{ marginRight: anyDrawerOpen ? activeDrawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
|
||||||
<Space wrap style={{ marginBottom: 16, width: '100%' }}>
|
{/* Top toolbar */}
|
||||||
<Input.Search
|
<Space wrap style={{ marginBottom: 16, width: '100%' }}>
|
||||||
placeholder="Search phone, name, or email..."
|
<Input.Search
|
||||||
allowClear
|
placeholder="Search phone, name, or email..."
|
||||||
value={searchText}
|
allowClear
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
value={searchText}
|
||||||
style={{ width: 260 }}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
/>
|
style={{ width: 260 }}
|
||||||
<Select
|
/>
|
||||||
placeholder="All Lists"
|
<Select
|
||||||
allowClear
|
placeholder="All Lists"
|
||||||
showSearch
|
allowClear
|
||||||
optionFilterProp="label"
|
showSearch
|
||||||
style={{ width: 200 }}
|
optionFilterProp="label"
|
||||||
value={filterListId}
|
style={{ width: 200 }}
|
||||||
onChange={(v) => setFilterListId(v)}
|
value={filterListId}
|
||||||
options={lists.map(l => ({ value: l.id, label: `${l.name} (${l.totalContacts})` }))}
|
onChange={(v) => setFilterListId(v)}
|
||||||
/>
|
options={lists.map(l => ({ value: l.id, label: `${l.name} (${l.totalContacts})` }))}
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>New List</Button>
|
/>
|
||||||
<Button icon={<ImportOutlined />} onClick={() => openImportModal()}>Import</Button>
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>New List</Button>
|
||||||
</Space>
|
<Button icon={<UserAddOutlined />} onClick={openAddContactDrawer}>Add Contact</Button>
|
||||||
|
<Button icon={<ImportOutlined />} onClick={() => openImportDrawer()}>Import</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
{/* Main contacts table */}
|
{/* Lists management panel */}
|
||||||
<Table
|
<Collapse
|
||||||
rowKey="id"
|
style={{ marginBottom: 16 }}
|
||||||
columns={contactColumns}
|
items={[{
|
||||||
dataSource={entries}
|
key: 'lists',
|
||||||
loading={entriesLoading}
|
label: <span><UnorderedListOutlined /> Manage Lists ({listsTotal})</span>,
|
||||||
pagination={{
|
children: (
|
||||||
current: entriesPage,
|
<Table
|
||||||
total: entriesTotal,
|
rowKey="id"
|
||||||
pageSize: 50,
|
columns={listColumns}
|
||||||
onChange: (p) => fetchEntries(p),
|
dataSource={lists}
|
||||||
showTotal: (t) => `${t} contacts`,
|
loading={listsLoading}
|
||||||
showSizeChanger: false,
|
pagination={{
|
||||||
}}
|
current: listsPage,
|
||||||
size="middle"
|
total: listsTotal,
|
||||||
/>
|
pageSize: 50,
|
||||||
|
onChange: (p) => fetchLists(p),
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Lists management panel */}
|
{/* Selection action bar */}
|
||||||
<Collapse
|
{selectedRowKeys.length > 0 && (
|
||||||
style={{ marginTop: 16 }}
|
<div style={{
|
||||||
items={[{
|
marginBottom: 12,
|
||||||
key: 'lists',
|
padding: '8px 16px',
|
||||||
label: <span><UnorderedListOutlined /> Manage Lists ({listsTotal})</span>,
|
background: 'rgba(22, 119, 255, 0.08)',
|
||||||
children: (
|
borderRadius: 8,
|
||||||
<Table
|
display: 'flex',
|
||||||
rowKey="id"
|
alignItems: 'center',
|
||||||
columns={listColumns}
|
gap: 12,
|
||||||
dataSource={lists}
|
}}>
|
||||||
loading={listsLoading}
|
<Text strong>{selectedRowKeys.length} selected</Text>
|
||||||
pagination={{
|
<Button
|
||||||
current: listsPage,
|
|
||||||
total: listsTotal,
|
|
||||||
pageSize: 50,
|
|
||||||
onChange: (p) => fetchLists(p),
|
|
||||||
showSizeChanger: false,
|
|
||||||
}}
|
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
icon={<PlusOutlined />}
|
||||||
),
|
onClick={() => setAddToListModalOpen(true)}
|
||||||
}]}
|
>
|
||||||
/>
|
Add to List
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="link" onClick={() => setSelectedRowKeys([])}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create List Modal */}
|
{/* Main contacts table */}
|
||||||
<Modal
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={contactColumns}
|
||||||
|
dataSource={entries}
|
||||||
|
loading={entriesLoading}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: setSelectedRowKeys,
|
||||||
|
preserveSelectedRowKeys: true,
|
||||||
|
}}
|
||||||
|
pagination={{
|
||||||
|
current: entriesPage,
|
||||||
|
total: entriesTotal,
|
||||||
|
pageSize: 50,
|
||||||
|
onChange: (p) => fetchEntries(p),
|
||||||
|
showTotal: (t) => `${t} contacts`,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create List Drawer */}
|
||||||
|
<Drawer
|
||||||
title="New Contact List"
|
title="New Contact List"
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onCancel={() => setCreateOpen(false)}
|
onClose={closeCreateDrawer}
|
||||||
onOk={() => createForm.submit()}
|
destroyOnHidden
|
||||||
|
mask={false}
|
||||||
|
width={drawerWidth}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={closeCreateDrawer}>Cancel</Button>
|
||||||
|
<Button type="primary" loading={createSaving} onClick={() => createForm.submit()}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Form form={createForm} layout="vertical" onFinish={handleCreate}>
|
<Form form={createForm} layout="vertical" onFinish={handleCreate}>
|
||||||
<Form.Item name="name" label="List Name" rules={[{ required: true }]}>
|
<Form.Item name="name" label="List Name" rules={[{ required: true }]}>
|
||||||
<Input placeholder="e.g. Ward 6 Supporters" />
|
<Input placeholder="e.g. Ward 6 Supporters" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Add Single Contact Drawer */}
|
||||||
|
<Drawer
|
||||||
|
title="Add Contact"
|
||||||
|
open={addContactOpen}
|
||||||
|
onClose={closeAddContactDrawer}
|
||||||
|
destroyOnHidden
|
||||||
|
mask={false}
|
||||||
|
width={drawerWidth}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={closeAddContactDrawer}>Cancel</Button>
|
||||||
|
<Button type="primary" loading={addContactSaving} onClick={() => addContactForm.submit()}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form form={addContactForm} layout="vertical" onFinish={handleAddContact}>
|
||||||
|
<Form.Item name="listId" label="List" rules={[{ required: true, message: 'Select a contact list' }]}>
|
||||||
|
<Select
|
||||||
|
placeholder="Select a list"
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={lists.map(l => ({ value: l.id, label: l.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="phone" label="Phone Number" rules={[{ required: true, message: 'Phone number is required' }]}>
|
||||||
|
<Input placeholder="e.g. 5551234567" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="name" label="Name">
|
||||||
|
<Input placeholder="e.g. Jane Doe" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="email" label="Email">
|
||||||
|
<Input placeholder="e.g. jane@example.com" type="email" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Add to List Modal */}
|
||||||
|
<Modal
|
||||||
|
title={`Add ${selectedRowKeys.length} contact${selectedRowKeys.length !== 1 ? 's' : ''} to list`}
|
||||||
|
open={addToListModalOpen}
|
||||||
|
onCancel={() => { setAddToListModalOpen(false); setAddToListTargetId(undefined); }}
|
||||||
|
onOk={handleAddToList}
|
||||||
|
okText="Add to List"
|
||||||
|
confirmLoading={addToListLoading}
|
||||||
|
okButtonProps={{ disabled: !addToListTargetId }}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="Select target list"
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ width: '100%', marginTop: 8 }}
|
||||||
|
value={addToListTargetId}
|
||||||
|
onChange={setAddToListTargetId}
|
||||||
|
options={lists.map(l => ({ value: l.id, label: `${l.name} (${l.totalContacts})` }))}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Unified Import Modal */}
|
{/* Import Contacts Drawer */}
|
||||||
<Modal
|
<Drawer
|
||||||
title={`Import Contacts${importListName ? ` → ${importListName}` : ''}`}
|
title={`Import Contacts${importListName ? ` → ${importListName}` : ''}`}
|
||||||
open={importOpen}
|
open={importOpen}
|
||||||
onCancel={() => { setImportOpen(false); resetImportState(); }}
|
onClose={closeImportDrawer}
|
||||||
footer={null}
|
destroyOnHidden
|
||||||
width={640}
|
mask={false}
|
||||||
destroyOnClose
|
width={importDrawerWidth}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
extra={
|
||||||
|
<Button onClick={closeImportDrawer}>Close</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
onChange={() => setPreview(null)}
|
onChange={() => setPreview(null)}
|
||||||
@ -709,7 +884,7 @@ export default function SmsContactsPage() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -471,7 +471,7 @@ export default function SmsSetupPage() {
|
|||||||
<li>Save the API key to ~/.bashrc and ~/.sms-api-key</li>
|
<li>Save the API key to ~/.bashrc and ~/.sms-api-key</li>
|
||||||
<li>Request SMS and Contacts permissions (tap <Text strong>Allow</Text>)</li>
|
<li>Request SMS and Contacts permissions (tap <Text strong>Allow</Text>)</li>
|
||||||
<li>Set up Termux:Boot auto-start (if installed)</li>
|
<li>Set up Termux:Boot auto-start (if installed)</li>
|
||||||
<li>Start the server with the watchdog</li>
|
<li>Install runit service supervisor and start the server</li>
|
||||||
</ul>
|
</ul>
|
||||||
<Paragraph style={{ marginTop: 8 }}>
|
<Paragraph style={{ marginTop: 8 }}>
|
||||||
When done, note the <Text strong>Phone URL</Text> displayed (e.g. <Text code>http://100.x.x.x:5001</Text>) — you'll need it in the next step.
|
When done, note the <Text strong>Phone URL</Text> displayed (e.g. <Text code>http://100.x.x.x:5001</Text>) — you'll need it in the next step.
|
||||||
@ -514,10 +514,10 @@ export default function SmsSetupPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Paragraph style={{ marginBottom: 4 }}>If the server is already running but the API key needs updating, run this on the phone:</Paragraph>
|
<Paragraph style={{ marginBottom: 4 }}>If the server is already running but the API key needs updating, run this on the phone:</Paragraph>
|
||||||
<div style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6 }}>
|
<div style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6 }}>
|
||||||
<CmdLine comment="Update key, restart server" cmd={`export SMS_API_SECRET="${generatedKey}" && sed -i '/SMS_API_SECRET/d' ~/.bashrc && echo 'export SMS_API_SECRET="${generatedKey}"' >> ~/.bashrc && pkill -f termux-sms-api-server.py && sleep 2 && cd ~/sms-server/android && bash sms-watchdog.sh`} />
|
<CmdLine comment="Update key and restart service" cmd={`sed -i '/SMS_API_SECRET/d' ~/.bashrc && echo 'export SMS_API_SECRET="${generatedKey}"' >> ~/.bashrc && source ~/.bashrc && sv restart sms-api`} />
|
||||||
</div>
|
</div>
|
||||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
Or pull latest code and re-run full setup: <Text code copyable={{ text: `cd ~/sms-server && git pull && bash android/setup.sh ${generatedKey}` }}>cd ~/sms-server && git pull && bash android/setup.sh {generatedKey.substring(0, 8)}...</Text>
|
If <Text code>sv</Text> is not installed yet, run the full setup: <Text code copyable={{ text: `cd ~/sms-server && git pull && bash android/setup-services.sh` }}>cd ~/sms-server && git pull && bash android/setup-services.sh</Text>
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -898,7 +898,7 @@ export default function SmsSetupPage() {
|
|||||||
The Termux API server is not responding. This can mean:
|
The Termux API server is not responding. This can mean:
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<ul style={{ marginLeft: 20, marginBottom: 8 }}>
|
<ul style={{ marginLeft: 20, marginBottom: 8 }}>
|
||||||
<li><Text strong>Server not running</Text> — Android may have killed the Termux process. Restart it on the phone: <Text code>cd ~/sms-server/android && bash sms-watchdog.sh</Text></li>
|
<li><Text strong>Server not running</Text> — Android may have killed the Termux process. Open Termux on the phone and check: <Text code>sv status sms-api</Text>. If down, run: <Text code>sv up sms-api</Text></li>
|
||||||
<li><Text strong>API key mismatch</Text> — the key saved here doesn't match the phone's <Text code>SMS_API_SECRET</Text>. Click Reconfigure to generate a new key and update both sides.</li>
|
<li><Text strong>API key mismatch</Text> — the key saved here doesn't match the phone's <Text code>SMS_API_SECRET</Text>. Click Reconfigure to generate a new key and update both sides.</li>
|
||||||
<li><Text strong>Network issue</Text> — the phone may not be reachable. Check Tailscale is connected on the phone.</li>
|
<li><Text strong>Network issue</Text> — the phone may not be reachable. Check Tailscale is connected on the phone.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Table, Button, Modal, Form, Input, Select, Space, Tag, App, Typography, Switch, Tooltip } from 'antd';
|
import { Table, Button, Modal, Drawer, Form, Input, Select, Space, Tag, App, Typography, Switch, Tooltip } from 'antd';
|
||||||
import { PlusOutlined, EditOutlined, CopyOutlined, DeleteOutlined, StarFilled, StarOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, CopyOutlined, DeleteOutlined, StarFilled, StarOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -67,8 +67,8 @@ export default function SmsTemplatesPage() {
|
|||||||
const [categoryFilter, setCategoryFilter] = useState<string | undefined>();
|
const [categoryFilter, setCategoryFilter] = useState<string | undefined>();
|
||||||
const [favoritesOnly, setFavoritesOnly] = useState(false);
|
const [favoritesOnly, setFavoritesOnly] = useState(false);
|
||||||
|
|
||||||
// Modal
|
// Drawer
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@ -100,11 +100,13 @@ export default function SmsTemplatesPage() {
|
|||||||
// Reset to page 1 when filters change
|
// Reset to page 1 when filters change
|
||||||
useEffect(() => { setPage(1); }, [debouncedSearch, categoryFilter, favoritesOnly]);
|
useEffect(() => { setPage(1); }, [debouncedSearch, categoryFilter, favoritesOnly]);
|
||||||
|
|
||||||
|
const closeDrawer = () => { setDrawerOpen(false); setLiveTemplate(''); };
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setLiveTemplate('');
|
setLiveTemplate('');
|
||||||
setModalOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (record: SmsMessageTemplate) => {
|
const openEdit = (record: SmsMessageTemplate) => {
|
||||||
@ -116,7 +118,7 @@ export default function SmsTemplatesPage() {
|
|||||||
category: record.category || undefined,
|
category: record.category || undefined,
|
||||||
});
|
});
|
||||||
setLiveTemplate(record.template);
|
setLiveTemplate(record.template);
|
||||||
setModalOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDuplicate = (record: SmsMessageTemplate) => {
|
const openDuplicate = (record: SmsMessageTemplate) => {
|
||||||
@ -128,7 +130,7 @@ export default function SmsTemplatesPage() {
|
|||||||
category: record.category || undefined,
|
category: record.category || undefined,
|
||||||
});
|
});
|
||||||
setLiveTemplate(record.template);
|
setLiveTemplate(record.template);
|
||||||
setModalOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async (values: { name: string; template: string; description?: string; category?: string }) => {
|
const handleSave = async (values: { name: string; template: string; description?: string; category?: string }) => {
|
||||||
@ -141,7 +143,7 @@ export default function SmsTemplatesPage() {
|
|||||||
await api.post('/sms/templates', values);
|
await api.post('/sms/templates', values);
|
||||||
message.success('Template created');
|
message.success('Template created');
|
||||||
}
|
}
|
||||||
setModalOpen(false);
|
closeDrawer();
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
fetchTemplates();
|
fetchTemplates();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@ -171,7 +173,7 @@ export default function SmsTemplatesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Computed values for modal
|
// Computed values for drawer
|
||||||
const liveVars = useMemo(() => extractVars(liveTemplate), [liveTemplate]);
|
const liveVars = useMemo(() => extractVars(liveTemplate), [liveTemplate]);
|
||||||
const livePreview = useMemo(() => renderPreview(liveTemplate), [liveTemplate]);
|
const livePreview = useMemo(() => renderPreview(liveTemplate), [liveTemplate]);
|
||||||
const charCount = liveTemplate.length;
|
const charCount = liveTemplate.length;
|
||||||
@ -200,17 +202,6 @@ export default function SmsTemplatesPage() {
|
|||||||
width: 120,
|
width: 120,
|
||||||
render: (cat) => cat ? <Tag color={CATEGORY_COLORS[cat] || 'default'}>{cat}</Tag> : <Text type="secondary">-</Text>,
|
render: (cat) => cat ? <Tag color={CATEGORY_COLORS[cat] || 'default'}>{cat}</Tag> : <Text type="secondary">-</Text>,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Template',
|
|
||||||
dataIndex: 'template',
|
|
||||||
ellipsis: true,
|
|
||||||
width: 300,
|
|
||||||
render: (tmpl) => (
|
|
||||||
<Text type="secondary" style={{ fontSize: 12, fontFamily: 'monospace' }}>
|
|
||||||
{tmpl.length > 80 ? `${tmpl.slice(0, 80)}...` : tmpl}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Variables',
|
title: 'Variables',
|
||||||
width: 200,
|
width: 200,
|
||||||
@ -277,55 +268,66 @@ export default function SmsTemplatesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const drawerWidth = 480;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
<div style={{ marginRight: drawerOpen ? drawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
New Template
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||||
</Button>
|
New Template
|
||||||
<Input.Search
|
</Button>
|
||||||
placeholder="Search templates..."
|
<Input.Search
|
||||||
value={search}
|
placeholder="Search templates..."
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
value={search}
|
||||||
allowClear
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
style={{ width: 220 }}
|
allowClear
|
||||||
/>
|
style={{ width: 220 }}
|
||||||
<Select
|
/>
|
||||||
placeholder="Category"
|
<Select
|
||||||
value={categoryFilter}
|
placeholder="Category"
|
||||||
onChange={setCategoryFilter}
|
value={categoryFilter}
|
||||||
allowClear
|
onChange={setCategoryFilter}
|
||||||
style={{ width: 140 }}
|
allowClear
|
||||||
options={[
|
style={{ width: 140 }}
|
||||||
{ value: 'notification', label: 'Notification' },
|
options={[
|
||||||
{ value: 'campaign', label: 'Campaign' },
|
{ value: 'notification', label: 'Notification' },
|
||||||
{ value: 'custom', label: 'Custom' },
|
{ value: 'campaign', label: 'Campaign' },
|
||||||
]}
|
{ value: 'custom', label: 'Custom' },
|
||||||
/>
|
]}
|
||||||
<Space>
|
/>
|
||||||
<Switch size="small" checked={favoritesOnly} onChange={setFavoritesOnly} />
|
<Space>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>Favorites only</Text>
|
<Switch size="small" checked={favoritesOnly} onChange={setFavoritesOnly} />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>Favorites only</Text>
|
||||||
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={templates}
|
dataSource={templates}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{ current: page, total, pageSize: 50, onChange: setPage, showSizeChanger: false }}
|
pagination={{ current: page, total, pageSize: 50, onChange: setPage, showSizeChanger: false }}
|
||||||
size="middle"
|
size="middle"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create / Edit Modal */}
|
<Drawer
|
||||||
<Modal
|
|
||||||
title={editingId ? 'Edit Template' : 'New Template'}
|
title={editingId ? 'Edit Template' : 'New Template'}
|
||||||
open={modalOpen}
|
open={drawerOpen}
|
||||||
onCancel={() => { setModalOpen(false); setLiveTemplate(''); }}
|
onClose={closeDrawer}
|
||||||
onOk={() => form.submit()}
|
destroyOnHidden
|
||||||
confirmLoading={saving}
|
mask={false}
|
||||||
width={640}
|
width={drawerWidth}
|
||||||
destroyOnClose
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={closeDrawer}>Cancel</Button>
|
||||||
|
<Button type="primary" loading={saving} onClick={() => form.submit()}>
|
||||||
|
{editingId ? 'Save' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||||
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Template name is required' }]}>
|
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Template name is required' }]}>
|
||||||
@ -352,7 +354,7 @@ export default function SmsTemplatesPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TextArea
|
<TextArea
|
||||||
rows={5}
|
rows={4}
|
||||||
maxLength={1600}
|
maxLength={1600}
|
||||||
placeholder="Hi {name}, your shift {shiftTitle} is coming up on {shiftDate} at {shiftTime}."
|
placeholder="Hi {name}, your shift {shiftTitle} is coming up on {shiftDate} at {shiftTime}."
|
||||||
onChange={(e) => setLiveTemplate(e.target.value)}
|
onChange={(e) => setLiveTemplate(e.target.value)}
|
||||||
@ -400,7 +402,7 @@ export default function SmsTemplatesPage() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1149,6 +1149,7 @@ export interface SiteSettings {
|
|||||||
enablePeople: boolean;
|
enablePeople: boolean;
|
||||||
enableSocial: boolean;
|
enableSocial: boolean;
|
||||||
enableMeet: boolean;
|
enableMeet: boolean;
|
||||||
|
enableMeetingPlanner: boolean;
|
||||||
autoSyncPeopleToMap: boolean;
|
autoSyncPeopleToMap: boolean;
|
||||||
// SMS connection config (only present from admin endpoint)
|
// SMS connection config (only present from admin endpoint)
|
||||||
smsTermuxApiUrl?: string;
|
smsTermuxApiUrl?: string;
|
||||||
@ -2252,7 +2253,7 @@ export interface DashboardRecentSignupsResult {
|
|||||||
|
|
||||||
export interface UnifiedCalendarItem {
|
export interface UnifiedCalendarItem {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'shift' | 'event';
|
type: 'shift' | 'event' | 'poll';
|
||||||
title: string;
|
title: string;
|
||||||
date: string;
|
date: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
@ -2264,6 +2265,10 @@ export interface UnifiedCalendarItem {
|
|||||||
currentVolunteers?: number;
|
currentVolunteers?: number;
|
||||||
gancioEventId?: number;
|
gancioEventId?: number;
|
||||||
gancioUrl?: string;
|
gancioUrl?: string;
|
||||||
|
pollId?: string;
|
||||||
|
pollSlug?: string;
|
||||||
|
pollStatus?: SchedulingPollStatus;
|
||||||
|
pollVoteCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnifiedCalendarResponse {
|
export interface UnifiedCalendarResponse {
|
||||||
@ -2740,3 +2745,111 @@ export interface ListmonkCampaignsData {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Scheduling Polls (Meeting Planner) ---
|
||||||
|
|
||||||
|
export type SchedulingPollStatus = 'OPEN' | 'CLOSED' | 'FINALIZED' | 'CANCELLED';
|
||||||
|
export type PollVoteValue = 'YES' | 'IF_NEED_BE' | 'NO';
|
||||||
|
|
||||||
|
export const POLL_STATUS_COLORS: Record<SchedulingPollStatus, string> = {
|
||||||
|
OPEN: 'green',
|
||||||
|
CLOSED: 'orange',
|
||||||
|
FINALIZED: 'blue',
|
||||||
|
CANCELLED: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POLL_STATUS_LABELS: Record<SchedulingPollStatus, string> = {
|
||||||
|
OPEN: 'Open',
|
||||||
|
CLOSED: 'Closed',
|
||||||
|
FINALIZED: 'Finalized',
|
||||||
|
CANCELLED: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VOTE_VALUE_COLORS: Record<PollVoteValue, string> = {
|
||||||
|
YES: '#52c41a',
|
||||||
|
IF_NEED_BE: '#faad14',
|
||||||
|
NO: '#d9d9d9',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VOTE_VALUE_LABELS: Record<PollVoteValue, string> = {
|
||||||
|
YES: 'Yes',
|
||||||
|
IF_NEED_BE: 'If Need Be',
|
||||||
|
NO: 'No',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SchedulingPollOption {
|
||||||
|
id: string;
|
||||||
|
pollId: string;
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
startTime: string; // HH:MM
|
||||||
|
endTime: string; // HH:MM
|
||||||
|
sortOrder: number;
|
||||||
|
votes?: SchedulingPollVote[];
|
||||||
|
_count?: { votes: number };
|
||||||
|
// Aggregated vote counts (from API)
|
||||||
|
yesCount?: number;
|
||||||
|
ifNeedBeCount?: number;
|
||||||
|
noCount?: number;
|
||||||
|
score?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulingPollVote {
|
||||||
|
id: string;
|
||||||
|
pollId: string;
|
||||||
|
optionId: string;
|
||||||
|
userId: string | null;
|
||||||
|
voterName: string;
|
||||||
|
voterEmail: string | null;
|
||||||
|
voterToken: string | null;
|
||||||
|
value: PollVoteValue;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulingPollComment {
|
||||||
|
id: string;
|
||||||
|
pollId: string;
|
||||||
|
userId: string | null;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulingPoll {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
location: string | null;
|
||||||
|
status: SchedulingPollStatus;
|
||||||
|
timezone: string;
|
||||||
|
finalizedOptionId: string | null;
|
||||||
|
finalizedOption: SchedulingPollOption | null;
|
||||||
|
convertedShiftId: string | null;
|
||||||
|
convertedGancioEventId: number | null;
|
||||||
|
votingDeadline: string | null;
|
||||||
|
allowAnonymous: boolean;
|
||||||
|
notifyOnVote: boolean;
|
||||||
|
createdByUserId: string;
|
||||||
|
createdBy?: { id: string; name: string | null; email: string };
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
options?: SchedulingPollOption[];
|
||||||
|
votes?: SchedulingPollVote[];
|
||||||
|
comments?: SchedulingPollComment[];
|
||||||
|
_count?: { options: number; votes: number; comments: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollsListResponse {
|
||||||
|
polls: SchedulingPoll[];
|
||||||
|
pagination: PaginationMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollDetailResponse extends SchedulingPoll {
|
||||||
|
options: SchedulingPollOption[];
|
||||||
|
comments: SchedulingPollComment[];
|
||||||
|
voters: Array<{
|
||||||
|
name: string;
|
||||||
|
votes: Record<string, PollVoteValue>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,127 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SchedulingPollStatus" AS ENUM ('OPEN', 'CLOSED', 'FINALIZED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PollVoteValue" AS ENUM ('YES', 'IF_NEED_BE', 'NO');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "site_settings" ADD COLUMN "enable_meeting_planner" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "scheduling_polls" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"location" TEXT,
|
||||||
|
"status" "SchedulingPollStatus" NOT NULL DEFAULT 'OPEN',
|
||||||
|
"timezone" TEXT NOT NULL DEFAULT 'America/Edmonton',
|
||||||
|
"finalized_option_id" TEXT,
|
||||||
|
"converted_shift_id" TEXT,
|
||||||
|
"converted_gancio_event_id" INTEGER,
|
||||||
|
"voting_deadline" TIMESTAMP(3),
|
||||||
|
"allow_anonymous" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"notify_on_vote" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_by_user_id" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "scheduling_polls_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "scheduling_poll_options" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"poll_id" TEXT NOT NULL,
|
||||||
|
"date" DATE NOT NULL,
|
||||||
|
"start_time" TEXT NOT NULL,
|
||||||
|
"end_time" TEXT NOT NULL,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "scheduling_poll_options_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "scheduling_poll_votes" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"poll_id" TEXT NOT NULL,
|
||||||
|
"option_id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT,
|
||||||
|
"voter_name" TEXT NOT NULL,
|
||||||
|
"voter_email" TEXT,
|
||||||
|
"voter_token" TEXT,
|
||||||
|
"value" "PollVoteValue" NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "scheduling_poll_votes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "scheduling_poll_comments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"poll_id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT,
|
||||||
|
"author_name" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "scheduling_poll_comments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "scheduling_polls_slug_key" ON "scheduling_polls"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "scheduling_polls_finalized_option_id_key" ON "scheduling_polls"("finalized_option_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "scheduling_polls_converted_shift_id_key" ON "scheduling_polls"("converted_shift_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "scheduling_polls_created_by_user_id_idx" ON "scheduling_polls"("created_by_user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "scheduling_polls_status_idx" ON "scheduling_polls"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "scheduling_poll_options_poll_id_idx" ON "scheduling_poll_options"("poll_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "scheduling_poll_votes_poll_id_idx" ON "scheduling_poll_votes"("poll_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "scheduling_poll_votes_option_id_user_id_key" ON "scheduling_poll_votes"("option_id", "user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "scheduling_poll_votes_option_id_voter_token_key" ON "scheduling_poll_votes"("option_id", "voter_token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "scheduling_poll_comments_poll_id_idx" ON "scheduling_poll_comments"("poll_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "scheduling_polls" ADD CONSTRAINT "scheduling_polls_finalized_option_id_fkey" FOREIGN KEY ("finalized_option_id") REFERENCES "scheduling_poll_options"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "scheduling_polls" ADD CONSTRAINT "scheduling_polls_converted_shift_id_fkey" FOREIGN KEY ("converted_shift_id") REFERENCES "shifts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "scheduling_polls" ADD CONSTRAINT "scheduling_polls_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "scheduling_poll_options" ADD CONSTRAINT "scheduling_poll_options_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "scheduling_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "scheduling_poll_votes" ADD CONSTRAINT "scheduling_poll_votes_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "scheduling_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "scheduling_poll_votes" ADD CONSTRAINT "scheduling_poll_votes_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "scheduling_poll_options"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "scheduling_poll_votes" ADD CONSTRAINT "scheduling_poll_votes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "scheduling_poll_comments" ADD CONSTRAINT "scheduling_poll_comments_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "scheduling_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "scheduling_poll_comments" ADD CONSTRAINT "scheduling_poll_comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@ -155,6 +155,11 @@ model User {
|
|||||||
// People CRM
|
// People CRM
|
||||||
contact Contact? @relation("UserContact")
|
contact Contact? @relation("UserContact")
|
||||||
|
|
||||||
|
// Scheduling polls
|
||||||
|
schedulingPollsCreated SchedulingPoll[] @relation("PollCreator")
|
||||||
|
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
|
||||||
|
schedulingPollComments SchedulingPollComment[] @relation("PollCommenter")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -686,6 +691,9 @@ model Shift {
|
|||||||
canvassVisits CanvassVisit[]
|
canvassVisits CanvassVisit[]
|
||||||
canvassSessions CanvassSession[]
|
canvassSessions CanvassSession[]
|
||||||
|
|
||||||
|
// Scheduling poll conversion
|
||||||
|
convertedFromPoll SchedulingPoll? @relation("PollConvertedShift")
|
||||||
|
|
||||||
@@index([cutId])
|
@@index([cutId])
|
||||||
@@index([seriesId])
|
@@index([seriesId])
|
||||||
@@map("shifts")
|
@@map("shifts")
|
||||||
@ -889,6 +897,7 @@ model SiteSettings {
|
|||||||
enablePeople Boolean @default(false) @map("enable_people")
|
enablePeople Boolean @default(false) @map("enable_people")
|
||||||
enableSocial Boolean @default(false) @map("enable_social")
|
enableSocial Boolean @default(false) @map("enable_social")
|
||||||
enableMeet Boolean @default(false) @map("enable_meet")
|
enableMeet Boolean @default(false) @map("enable_meet")
|
||||||
|
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
|
||||||
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
|
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
|
||||||
|
|
||||||
// SMS connection config (overrides env vars when non-empty)
|
// SMS connection config (overrides env vars when non-empty)
|
||||||
@ -4294,3 +4303,103 @@ model Meeting {
|
|||||||
|
|
||||||
@@map("meetings")
|
@@map("meetings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SCHEDULING POLLS (Meeting Planner)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum SchedulingPollStatus {
|
||||||
|
OPEN
|
||||||
|
CLOSED
|
||||||
|
FINALIZED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PollVoteValue {
|
||||||
|
YES
|
||||||
|
IF_NEED_BE
|
||||||
|
NO
|
||||||
|
}
|
||||||
|
|
||||||
|
model SchedulingPoll {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
description String? @db.Text
|
||||||
|
location String?
|
||||||
|
status SchedulingPollStatus @default(OPEN)
|
||||||
|
timezone String @default("America/Edmonton")
|
||||||
|
finalizedOptionId String? @unique @map("finalized_option_id")
|
||||||
|
finalizedOption SchedulingPollOption? @relation("FinalizedOption", fields: [finalizedOptionId], references: [id], onDelete: SetNull)
|
||||||
|
convertedShiftId String? @unique @map("converted_shift_id")
|
||||||
|
convertedShift Shift? @relation("PollConvertedShift", fields: [convertedShiftId], references: [id], onDelete: SetNull)
|
||||||
|
convertedGancioEventId Int? @map("converted_gancio_event_id")
|
||||||
|
votingDeadline DateTime? @map("voting_deadline")
|
||||||
|
allowAnonymous Boolean @default(true) @map("allow_anonymous")
|
||||||
|
notifyOnVote Boolean @default(true) @map("notify_on_vote")
|
||||||
|
createdByUserId String @map("created_by_user_id")
|
||||||
|
createdBy User @relation("PollCreator", fields: [createdByUserId], references: [id])
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
options SchedulingPollOption[] @relation("PollOptions")
|
||||||
|
votes SchedulingPollVote[] @relation("PollVotes")
|
||||||
|
comments SchedulingPollComment[] @relation("PollComments")
|
||||||
|
|
||||||
|
@@index([createdByUserId])
|
||||||
|
@@index([status])
|
||||||
|
@@map("scheduling_polls")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SchedulingPollOption {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
pollId String @map("poll_id")
|
||||||
|
poll SchedulingPoll @relation("PollOptions", fields: [pollId], references: [id], onDelete: Cascade)
|
||||||
|
date DateTime @db.Date
|
||||||
|
startTime String @map("start_time") // HH:MM
|
||||||
|
endTime String @map("end_time") // HH:MM
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
|
||||||
|
votes SchedulingPollVote[] @relation("OptionVotes")
|
||||||
|
|
||||||
|
// Reverse 1:1 for finalized option
|
||||||
|
finalizedForPoll SchedulingPoll? @relation("FinalizedOption")
|
||||||
|
|
||||||
|
@@index([pollId])
|
||||||
|
@@map("scheduling_poll_options")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SchedulingPollVote {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
pollId String @map("poll_id")
|
||||||
|
poll SchedulingPoll @relation("PollVotes", fields: [pollId], references: [id], onDelete: Cascade)
|
||||||
|
optionId String @map("option_id")
|
||||||
|
option SchedulingPollOption @relation("OptionVotes", fields: [optionId], references: [id], onDelete: Cascade)
|
||||||
|
userId String? @map("user_id")
|
||||||
|
user User? @relation("PollVoter", fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
voterName String @map("voter_name")
|
||||||
|
voterEmail String? @map("voter_email")
|
||||||
|
voterToken String? @map("voter_token") // anonymous edit access (cuid)
|
||||||
|
value PollVoteValue
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([optionId, userId])
|
||||||
|
@@unique([optionId, voterToken])
|
||||||
|
@@index([pollId])
|
||||||
|
@@map("scheduling_poll_votes")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SchedulingPollComment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
pollId String @map("poll_id")
|
||||||
|
poll SchedulingPoll @relation("PollComments", fields: [pollId], references: [id], onDelete: Cascade)
|
||||||
|
userId String? @map("user_id")
|
||||||
|
user User? @relation("PollCommenter", fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
authorName String @map("author_name")
|
||||||
|
content String @db.Text
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([pollId])
|
||||||
|
@@map("scheduling_poll_comments")
|
||||||
|
}
|
||||||
|
|||||||
@ -445,6 +445,23 @@ async function main() {
|
|||||||
showTitle: true,
|
showTitle: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'default-scheduling-poll',
|
||||||
|
type: 'scheduling-poll',
|
||||||
|
label: 'Scheduling Poll',
|
||||||
|
category: 'Influence',
|
||||||
|
sortOrder: 17,
|
||||||
|
schema: {
|
||||||
|
pollSlug: { type: 'string', label: 'Poll Slug', required: true },
|
||||||
|
showComments: { type: 'boolean', label: 'Show Comments', default: true },
|
||||||
|
title: { type: 'string', label: 'Section Title', default: 'Vote on a Meeting Time' },
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
pollSlug: '',
|
||||||
|
showComments: true,
|
||||||
|
title: 'Vote on a Meeting Time',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const block of defaultBlocks) {
|
for (const block of defaultBlocks) {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { logger } from '../../utils/logger';
|
|||||||
|
|
||||||
export interface UnifiedCalendarItem {
|
export interface UnifiedCalendarItem {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'shift' | 'event';
|
type: 'shift' | 'event' | 'poll';
|
||||||
title: string;
|
title: string;
|
||||||
date: string; // YYYY-MM-DD
|
date: string; // YYYY-MM-DD
|
||||||
startTime: string; // HH:MM
|
startTime: string; // HH:MM
|
||||||
@ -23,6 +23,11 @@ export interface UnifiedCalendarItem {
|
|||||||
// Event-specific
|
// Event-specific
|
||||||
gancioEventId?: number;
|
gancioEventId?: number;
|
||||||
gancioUrl?: string;
|
gancioUrl?: string;
|
||||||
|
// Poll-specific
|
||||||
|
pollId?: string;
|
||||||
|
pollSlug?: string;
|
||||||
|
pollStatus?: string;
|
||||||
|
pollVoteCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnifiedCalendarResponse {
|
export interface UnifiedCalendarResponse {
|
||||||
@ -50,10 +55,11 @@ export const unifiedCalendarService = {
|
|||||||
// Set end to end of day
|
// Set end to end of day
|
||||||
end.setHours(23, 59, 59, 999);
|
end.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
// Fetch shifts and Gancio events in parallel
|
// Fetch shifts, Gancio events, and polls in parallel
|
||||||
const [shifts, gancioEvents] = await Promise.all([
|
const [shifts, gancioEvents, pollItems] = await Promise.all([
|
||||||
this.fetchShifts(start, end),
|
this.fetchShifts(start, end),
|
||||||
this.fetchGancioEvents(start, end),
|
this.fetchGancioEvents(start, end),
|
||||||
|
this.fetchPolls(start, end),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build set of Gancio event IDs that correspond to synced shifts (to deduplicate)
|
// Build set of Gancio event IDs that correspond to synced shifts (to deduplicate)
|
||||||
@ -99,7 +105,7 @@ export const unifiedCalendarService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Merge and group by date
|
// Merge and group by date
|
||||||
const allItems = [...shiftItems, ...eventItems];
|
const allItems = [...shiftItems, ...eventItems, ...pollItems];
|
||||||
allItems.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
allItems.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||||
|
|
||||||
const dates: Record<string, { count: number; items: UnifiedCalendarItem[] }> = {};
|
const dates: Record<string, { count: number; items: UnifiedCalendarItem[] }> = {};
|
||||||
@ -163,6 +169,53 @@ export const unifiedCalendarService = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async fetchPolls(start: Date, end: Date): Promise<UnifiedCalendarItem[]> {
|
||||||
|
try {
|
||||||
|
const polls = await prisma.schedulingPoll.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OPEN', 'FINALIZED'] },
|
||||||
|
options: {
|
||||||
|
some: { date: { gte: start, lte: end } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
options: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
_count: { select: { votes: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: UnifiedCalendarItem[] = [];
|
||||||
|
for (const poll of polls) {
|
||||||
|
// For finalized polls, only show the selected option
|
||||||
|
const optionsToShow = poll.finalizedOptionId
|
||||||
|
? poll.options.filter(o => o.id === poll.finalizedOptionId)
|
||||||
|
: poll.options;
|
||||||
|
|
||||||
|
for (const opt of optionsToShow) {
|
||||||
|
const optDate = opt.date.toISOString().split('T')[0];
|
||||||
|
items.push({
|
||||||
|
id: `poll-${poll.id}-${opt.id}`,
|
||||||
|
type: 'poll',
|
||||||
|
title: poll.title,
|
||||||
|
date: optDate,
|
||||||
|
startTime: opt.startTime,
|
||||||
|
endTime: opt.endTime,
|
||||||
|
location: poll.location,
|
||||||
|
tags: ['scheduling', 'poll'],
|
||||||
|
pollId: poll.id,
|
||||||
|
pollSlug: poll.slug,
|
||||||
|
pollStatus: poll.status,
|
||||||
|
pollVoteCount: poll._count.votes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Failed to fetch polls for calendar:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async fetchGancioEvents(start: Date, end: Date): Promise<GancioEvent[]> {
|
async fetchGancioEvents(start: Date, end: Date): Promise<GancioEvent[]> {
|
||||||
try {
|
try {
|
||||||
const events = await gancioClient.fetchPublicEvents();
|
const events = await gancioClient.fetchPublicEvents();
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import RedisStore from 'rate-limit-redis';
|
||||||
|
import { redis } from '../../config/redis';
|
||||||
|
|
||||||
|
export const pollVoteRateLimit = rateLimit({
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 30,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
store: new RedisStore({
|
||||||
|
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||||
|
prefix: 'rl:poll-vote:',
|
||||||
|
}),
|
||||||
|
message: {
|
||||||
|
error: {
|
||||||
|
message: 'Too many vote submissions, please try again later',
|
||||||
|
code: 'POLL_VOTE_RATE_LIMIT_EXCEEDED',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pollCommentRateLimit = rateLimit({
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 60,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
store: new RedisStore({
|
||||||
|
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||||
|
prefix: 'rl:poll-comment:',
|
||||||
|
}),
|
||||||
|
message: {
|
||||||
|
error: {
|
||||||
|
message: 'Too many comments, please try again later',
|
||||||
|
code: 'POLL_COMMENT_RATE_LIMIT_EXCEEDED',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
192
api/src/modules/meeting-planner/meeting-planner.routes.ts
Normal file
192
api/src/modules/meeting-planner/meeting-planner.routes.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { meetingPlannerService } from './meeting-planner.service';
|
||||||
|
import {
|
||||||
|
createPollSchema,
|
||||||
|
updatePollSchema,
|
||||||
|
addOptionsSchema,
|
||||||
|
submitVotesSchema,
|
||||||
|
submitCommentSchema,
|
||||||
|
finalizePollSchema,
|
||||||
|
convertToShiftSchema,
|
||||||
|
listPollsSchema,
|
||||||
|
} from './meeting-planner.schemas';
|
||||||
|
import { validate } from '../../middleware/validate';
|
||||||
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { pollVoteRateLimit, pollCommentRateLimit } from './meeting-planner.rate-limits';
|
||||||
|
|
||||||
|
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
||||||
|
|
||||||
|
// --- Admin Router ---
|
||||||
|
|
||||||
|
const adminRouter = Router();
|
||||||
|
adminRouter.use(authenticate);
|
||||||
|
adminRouter.use(requireRole(...ADMIN_ROLES));
|
||||||
|
|
||||||
|
// List polls
|
||||||
|
adminRouter.get('/', validate(listPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const result = await meetingPlannerService.findAll(req.query as any);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get poll detail
|
||||||
|
adminRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const poll = await meetingPlannerService.findById(id);
|
||||||
|
res.json(poll);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create poll
|
||||||
|
adminRouter.post('/', validate(createPollSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const poll = await meetingPlannerService.create(req.body, req.user!.id);
|
||||||
|
res.status(201).json(poll);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update poll
|
||||||
|
adminRouter.put('/:id', validate(updatePollSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const poll = await meetingPlannerService.update(id, req.body);
|
||||||
|
res.json(poll);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete poll
|
||||||
|
adminRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
await meetingPlannerService.delete(id);
|
||||||
|
res.json({ message: 'Poll deleted' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add options
|
||||||
|
adminRouter.post('/:id/options', validate(addOptionsSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const poll = await meetingPlannerService.addOptions(id, req.body);
|
||||||
|
res.json(poll);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove option
|
||||||
|
adminRouter.delete('/:id/options/:optionId', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const optionId = req.params.optionId as string;
|
||||||
|
const poll = await meetingPlannerService.removeOption(id, optionId);
|
||||||
|
res.json(poll);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Finalize poll
|
||||||
|
adminRouter.post('/:id/finalize', validate(finalizePollSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const poll = await meetingPlannerService.finalize(id, req.body);
|
||||||
|
res.json(poll);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to shift
|
||||||
|
adminRouter.post('/:id/convert-to-shift', validate(convertToShiftSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const shift = await meetingPlannerService.convertToShift(id, req.body);
|
||||||
|
res.json(shift);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to Gancio event
|
||||||
|
adminRouter.post('/:id/convert-to-event', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const result = await meetingPlannerService.convertToEvent(id);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete comment
|
||||||
|
adminRouter.delete('/:id/comments/:commentId', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const commentId = req.params.commentId as string;
|
||||||
|
await meetingPlannerService.deleteComment(id, commentId);
|
||||||
|
res.json({ message: 'Comment deleted' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Public Router ---
|
||||||
|
|
||||||
|
const publicRouter = Router();
|
||||||
|
|
||||||
|
// Public listing of open polls
|
||||||
|
publicRouter.get('/public', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const result = await meetingPlannerService.findAll({
|
||||||
|
status: 'OPEN',
|
||||||
|
limit: 50,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// View poll by slug
|
||||||
|
publicRouter.get('/public/:slug', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const slug = req.params.slug as string;
|
||||||
|
const poll = await meetingPlannerService.findBySlug(slug);
|
||||||
|
res.json(poll);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit votes
|
||||||
|
publicRouter.post('/public/:slug/vote', pollVoteRateLimit, validate(submitVotesSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const slug = req.params.slug as string;
|
||||||
|
// Try to get userId from optional auth header
|
||||||
|
let userId: string | undefined;
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
const jwt = await import('jsonwebtoken');
|
||||||
|
const { env } = await import('../../config/env');
|
||||||
|
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
|
||||||
|
userId = decoded.id;
|
||||||
|
}
|
||||||
|
} catch { /* not authenticated, that's fine */ }
|
||||||
|
|
||||||
|
const result = await meetingPlannerService.submitVotes(slug, req.body, userId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add comment
|
||||||
|
publicRouter.post('/public/:slug/comment', pollCommentRateLimit, validate(submitCommentSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const slug = req.params.slug as string;
|
||||||
|
let userId: string | undefined;
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
const jwt = await import('jsonwebtoken');
|
||||||
|
const { env } = await import('../../config/env');
|
||||||
|
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
|
||||||
|
userId = decoded.id;
|
||||||
|
}
|
||||||
|
} catch { /* not authenticated */ }
|
||||||
|
|
||||||
|
const comment = await meetingPlannerService.addComment(slug, req.body, userId);
|
||||||
|
res.status(201).json(comment);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export { adminRouter as meetingPlannerAdminRouter, publicRouter as meetingPlannerPublicRouter };
|
||||||
77
api/src/modules/meeting-planner/meeting-planner.schemas.ts
Normal file
77
api/src/modules/meeting-planner/meeting-planner.schemas.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { SchedulingPollStatus, PollVoteValue } from '@prisma/client';
|
||||||
|
|
||||||
|
export const createPollSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
|
description: z.string().max(2000).optional(),
|
||||||
|
location: z.string().max(500).optional(),
|
||||||
|
timezone: z.string().default('America/Edmonton'),
|
||||||
|
allowAnonymous: z.boolean().optional().default(true),
|
||||||
|
notifyOnVote: z.boolean().optional().default(true),
|
||||||
|
votingDeadline: z.string().datetime().optional(),
|
||||||
|
options: z.array(z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
|
||||||
|
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
|
||||||
|
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
|
||||||
|
})).min(2, 'At least 2 options required').max(20, 'Maximum 20 options'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updatePollSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200).optional(),
|
||||||
|
description: z.string().max(2000).nullable().optional(),
|
||||||
|
location: z.string().max(500).nullable().optional(),
|
||||||
|
timezone: z.string().optional(),
|
||||||
|
allowAnonymous: z.boolean().optional(),
|
||||||
|
notifyOnVote: z.boolean().optional(),
|
||||||
|
votingDeadline: z.string().datetime().nullable().optional(),
|
||||||
|
status: z.nativeEnum(SchedulingPollStatus).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addOptionsSchema = z.object({
|
||||||
|
options: z.array(z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
|
||||||
|
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
|
||||||
|
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
|
||||||
|
})).min(1).max(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const submitVotesSchema = z.object({
|
||||||
|
voterName: z.string().min(1, 'Name is required').max(100),
|
||||||
|
voterEmail: z.string().email().max(200).optional(),
|
||||||
|
voterToken: z.string().optional(),
|
||||||
|
votes: z.array(z.object({
|
||||||
|
optionId: z.string().min(1),
|
||||||
|
value: z.nativeEnum(PollVoteValue),
|
||||||
|
})).min(1, 'At least one vote required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const submitCommentSchema = z.object({
|
||||||
|
authorName: z.string().min(1, 'Name is required').max(100),
|
||||||
|
content: z.string().min(1, 'Comment is required').max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const finalizePollSchema = z.object({
|
||||||
|
optionId: z.string().min(1, 'Option ID is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const convertToShiftSchema = z.object({
|
||||||
|
maxVolunteers: z.number().int().min(1).default(10),
|
||||||
|
isPublic: z.boolean().optional().default(true),
|
||||||
|
cutId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listPollsSchema = z.object({
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
search: z.string().optional(),
|
||||||
|
status: z.nativeEnum(SchedulingPollStatus).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreatePollInput = z.infer<typeof createPollSchema>;
|
||||||
|
export type UpdatePollInput = z.infer<typeof updatePollSchema>;
|
||||||
|
export type AddOptionsInput = z.infer<typeof addOptionsSchema>;
|
||||||
|
export type SubmitVotesInput = z.infer<typeof submitVotesSchema>;
|
||||||
|
export type SubmitCommentInput = z.infer<typeof submitCommentSchema>;
|
||||||
|
export type FinalizePollInput = z.infer<typeof finalizePollSchema>;
|
||||||
|
export type ConvertToShiftInput = z.infer<typeof convertToShiftSchema>;
|
||||||
|
export type ListPollsInput = z.infer<typeof listPollsSchema>;
|
||||||
491
api/src/modules/meeting-planner/meeting-planner.service.ts
Normal file
491
api/src/modules/meeting-planner/meeting-planner.service.ts
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
import { Prisma, PollVoteValue } from '@prisma/client';
|
||||||
|
import { prisma } from '../../config/database';
|
||||||
|
import { AppError } from '../../middleware/error-handler';
|
||||||
|
import { emailService } from '../../services/email.service';
|
||||||
|
import { generateSlug } from '../../utils/slug';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
import type {
|
||||||
|
CreatePollInput,
|
||||||
|
UpdatePollInput,
|
||||||
|
AddOptionsInput,
|
||||||
|
SubmitVotesInput,
|
||||||
|
SubmitCommentInput,
|
||||||
|
FinalizePollInput,
|
||||||
|
ConvertToShiftInput,
|
||||||
|
ListPollsInput,
|
||||||
|
} from './meeting-planner.schemas';
|
||||||
|
|
||||||
|
const pollInclude = {
|
||||||
|
options: { orderBy: { sortOrder: 'asc' as const } },
|
||||||
|
createdBy: { select: { id: true, name: true, email: true } },
|
||||||
|
_count: { select: { options: true, votes: true, comments: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const pollDetailInclude = {
|
||||||
|
options: {
|
||||||
|
orderBy: { sortOrder: 'asc' as const },
|
||||||
|
include: {
|
||||||
|
votes: { orderBy: { createdAt: 'asc' as const } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
comments: { orderBy: { createdAt: 'asc' as const } },
|
||||||
|
createdBy: { select: { id: true, name: true, email: true } },
|
||||||
|
_count: { select: { options: true, votes: true, comments: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function aggregateVotes(options: Array<{ id: string; votes: Array<{ value: PollVoteValue }> }>) {
|
||||||
|
return options.map((opt) => {
|
||||||
|
let yesCount = 0;
|
||||||
|
let ifNeedBeCount = 0;
|
||||||
|
let noCount = 0;
|
||||||
|
for (const v of opt.votes) {
|
||||||
|
if (v.value === 'YES') yesCount++;
|
||||||
|
else if (v.value === 'IF_NEED_BE') ifNeedBeCount++;
|
||||||
|
else noCount++;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...opt,
|
||||||
|
yesCount,
|
||||||
|
ifNeedBeCount,
|
||||||
|
noCount,
|
||||||
|
score: yesCount * 2 + ifNeedBeCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupVotesByVoter(votes: Array<{
|
||||||
|
voterName: string;
|
||||||
|
voterToken: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
optionId: string;
|
||||||
|
value: PollVoteValue;
|
||||||
|
}>) {
|
||||||
|
const voterMap = new Map<string, { name: string; votes: Record<string, PollVoteValue> }>();
|
||||||
|
for (const vote of votes) {
|
||||||
|
const key = vote.userId || vote.voterToken || vote.voterName;
|
||||||
|
if (!voterMap.has(key)) {
|
||||||
|
voterMap.set(key, { name: vote.voterName, votes: {} });
|
||||||
|
}
|
||||||
|
voterMap.get(key)!.votes[vote.optionId] = vote.value;
|
||||||
|
}
|
||||||
|
return Array.from(voterMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const meetingPlannerService = {
|
||||||
|
async findAll(filters: ListPollsInput) {
|
||||||
|
const { page, limit, search, status } = filters;
|
||||||
|
const where: Prisma.SchedulingPollWhereInput = {};
|
||||||
|
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [polls, total] = await Promise.all([
|
||||||
|
prisma.schedulingPoll.findMany({
|
||||||
|
where,
|
||||||
|
include: pollInclude,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.schedulingPoll.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
polls,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async findById(id: string) {
|
||||||
|
const poll = await prisma.schedulingPoll.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: pollDetailInclude,
|
||||||
|
});
|
||||||
|
if (!poll) throw new AppError(404, 'Poll not found');
|
||||||
|
|
||||||
|
const optionsWithCounts = aggregateVotes(poll.options);
|
||||||
|
const allVotes = poll.options.flatMap((opt) =>
|
||||||
|
opt.votes.map((v) => ({ ...v, optionId: opt.id }))
|
||||||
|
);
|
||||||
|
const voters = groupVotesByVoter(allVotes);
|
||||||
|
|
||||||
|
return { ...poll, options: optionsWithCounts, voters };
|
||||||
|
},
|
||||||
|
|
||||||
|
async findBySlug(slug: string) {
|
||||||
|
const poll = await prisma.schedulingPoll.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
include: pollDetailInclude,
|
||||||
|
});
|
||||||
|
if (!poll) throw new AppError(404, 'Poll not found');
|
||||||
|
|
||||||
|
const optionsWithCounts = aggregateVotes(poll.options);
|
||||||
|
const allVotes = poll.options.flatMap((opt) =>
|
||||||
|
opt.votes.map((v) => ({ ...v, optionId: opt.id }))
|
||||||
|
);
|
||||||
|
const voters = groupVotesByVoter(allVotes);
|
||||||
|
|
||||||
|
return { ...poll, options: optionsWithCounts, voters };
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: CreatePollInput, userId: string) {
|
||||||
|
const slug = generateSlug(data.title);
|
||||||
|
|
||||||
|
const poll = await prisma.schedulingPoll.create({
|
||||||
|
data: {
|
||||||
|
slug,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
location: data.location,
|
||||||
|
timezone: data.timezone,
|
||||||
|
allowAnonymous: data.allowAnonymous,
|
||||||
|
notifyOnVote: data.notifyOnVote,
|
||||||
|
votingDeadline: data.votingDeadline ? new Date(data.votingDeadline) : null,
|
||||||
|
createdByUserId: userId,
|
||||||
|
options: {
|
||||||
|
create: data.options.map((opt, i) => ({
|
||||||
|
date: new Date(opt.date),
|
||||||
|
startTime: opt.startTime,
|
||||||
|
endTime: opt.endTime,
|
||||||
|
sortOrder: i,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: pollInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
return poll;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, data: UpdatePollInput) {
|
||||||
|
const existing = await prisma.schedulingPoll.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new AppError(404, 'Poll not found');
|
||||||
|
|
||||||
|
const updateData: Prisma.SchedulingPollUncheckedUpdateInput = {};
|
||||||
|
if (data.title !== undefined) updateData.title = data.title;
|
||||||
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
|
if (data.location !== undefined) updateData.location = data.location;
|
||||||
|
if (data.timezone !== undefined) updateData.timezone = data.timezone;
|
||||||
|
if (data.allowAnonymous !== undefined) updateData.allowAnonymous = data.allowAnonymous;
|
||||||
|
if (data.notifyOnVote !== undefined) updateData.notifyOnVote = data.notifyOnVote;
|
||||||
|
if (data.votingDeadline !== undefined) {
|
||||||
|
updateData.votingDeadline = data.votingDeadline ? new Date(data.votingDeadline) : null;
|
||||||
|
}
|
||||||
|
if (data.status !== undefined) updateData.status = data.status;
|
||||||
|
|
||||||
|
return prisma.schedulingPoll.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: pollInclude,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
const existing = await prisma.schedulingPoll.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new AppError(404, 'Poll not found');
|
||||||
|
await prisma.schedulingPoll.delete({ where: { id } });
|
||||||
|
},
|
||||||
|
|
||||||
|
async addOptions(pollId: string, data: AddOptionsInput) {
|
||||||
|
const poll = await prisma.schedulingPoll.findUnique({
|
||||||
|
where: { id: pollId },
|
||||||
|
include: { options: true },
|
||||||
|
});
|
||||||
|
if (!poll) throw new AppError(404, 'Poll not found');
|
||||||
|
if (poll.status !== 'OPEN') throw new AppError(400, 'Cannot add options to a non-open poll');
|
||||||
|
|
||||||
|
const maxSort = poll.options.reduce((max, o) => Math.max(max, o.sortOrder), -1);
|
||||||
|
|
||||||
|
await prisma.schedulingPollOption.createMany({
|
||||||
|
data: data.options.map((opt, i) => ({
|
||||||
|
pollId,
|
||||||
|
date: new Date(opt.date),
|
||||||
|
startTime: opt.startTime,
|
||||||
|
endTime: opt.endTime,
|
||||||
|
sortOrder: maxSort + 1 + i,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(pollId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeOption(pollId: string, optionId: string) {
|
||||||
|
const option = await prisma.schedulingPollOption.findFirst({
|
||||||
|
where: { id: optionId, pollId },
|
||||||
|
});
|
||||||
|
if (!option) throw new AppError(404, 'Option not found');
|
||||||
|
|
||||||
|
await prisma.schedulingPollOption.delete({ where: { id: optionId } });
|
||||||
|
return this.findById(pollId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitVotes(slug: string, data: SubmitVotesInput, userId?: string) {
|
||||||
|
const poll = await prisma.schedulingPoll.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
include: { options: true, createdBy: { select: { email: true, name: true } } },
|
||||||
|
});
|
||||||
|
if (!poll) throw new AppError(404, 'Poll not found');
|
||||||
|
if (poll.status !== 'OPEN') throw new AppError(400, 'This poll is no longer accepting votes');
|
||||||
|
if (poll.votingDeadline && new Date() > poll.votingDeadline) {
|
||||||
|
throw new AppError(400, 'The voting deadline has passed');
|
||||||
|
}
|
||||||
|
if (!poll.allowAnonymous && !userId) {
|
||||||
|
throw new AppError(401, 'This poll requires authentication to vote');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all optionIds belong to this poll
|
||||||
|
const optionIds = new Set(poll.options.map((o) => o.id));
|
||||||
|
for (const vote of data.votes) {
|
||||||
|
if (!optionIds.has(vote.optionId)) {
|
||||||
|
throw new AppError(400, `Invalid option ID: ${vote.optionId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token for anonymous voters (or reuse existing)
|
||||||
|
const voterToken = userId ? null : (data.voterToken || generateVoterToken());
|
||||||
|
|
||||||
|
// Upsert votes in a transaction
|
||||||
|
await prisma.$transaction(
|
||||||
|
data.votes.map((vote) => {
|
||||||
|
if (userId) {
|
||||||
|
return prisma.schedulingPollVote.upsert({
|
||||||
|
where: { optionId_userId: { optionId: vote.optionId, userId } },
|
||||||
|
create: {
|
||||||
|
pollId: poll.id,
|
||||||
|
optionId: vote.optionId,
|
||||||
|
userId,
|
||||||
|
voterName: data.voterName,
|
||||||
|
voterEmail: data.voterEmail,
|
||||||
|
value: vote.value,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
voterName: data.voterName,
|
||||||
|
voterEmail: data.voterEmail,
|
||||||
|
value: vote.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return prisma.schedulingPollVote.upsert({
|
||||||
|
where: { optionId_voterToken: { optionId: vote.optionId, voterToken: voterToken! } },
|
||||||
|
create: {
|
||||||
|
pollId: poll.id,
|
||||||
|
optionId: vote.optionId,
|
||||||
|
voterName: data.voterName,
|
||||||
|
voterEmail: data.voterEmail,
|
||||||
|
voterToken,
|
||||||
|
value: vote.value,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
voterName: data.voterName,
|
||||||
|
voterEmail: data.voterEmail,
|
||||||
|
value: vote.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify organizer
|
||||||
|
if (poll.notifyOnVote) {
|
||||||
|
this.notifyOrganizer(poll.createdBy.email, poll.title, data.voterName).catch((err) =>
|
||||||
|
logger.error('Failed to send vote notification', { error: err })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { voterToken };
|
||||||
|
},
|
||||||
|
|
||||||
|
async addComment(slug: string, data: SubmitCommentInput, userId?: string) {
|
||||||
|
const poll = await prisma.schedulingPoll.findUnique({ where: { slug } });
|
||||||
|
if (!poll) throw new AppError(404, 'Poll not found');
|
||||||
|
|
||||||
|
return prisma.schedulingPollComment.create({
|
||||||
|
data: {
|
||||||
|
pollId: poll.id,
|
||||||
|
userId,
|
||||||
|
authorName: data.authorName,
|
||||||
|
content: data.content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteComment(pollId: string, commentId: string) {
|
||||||
|
const comment = await prisma.schedulingPollComment.findFirst({
|
||||||
|
where: { id: commentId, pollId },
|
||||||
|
});
|
||||||
|
if (!comment) throw new AppError(404, 'Comment not found');
|
||||||
|
await prisma.schedulingPollComment.delete({ where: { id: commentId } });
|
||||||
|
},
|
||||||
|
|
||||||
|
async finalize(id: string, data: FinalizePollInput) {
|
||||||
|
const poll = await prisma.schedulingPoll.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { options: true },
|
||||||
|
});
|
||||||
|
if (!poll) throw new AppError(404, 'Poll not found');
|
||||||
|
if (poll.status === 'FINALIZED') throw new AppError(400, 'Poll is already finalized');
|
||||||
|
|
||||||
|
const option = poll.options.find((o) => o.id === data.optionId);
|
||||||
|
if (!option) throw new AppError(400, 'Option not found in this poll');
|
||||||
|
|
||||||
|
const updated = await prisma.schedulingPoll.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'FINALIZED',
|
||||||
|
finalizedOptionId: data.optionId,
|
||||||
|
},
|
||||||
|
include: pollDetailInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify all voters with emails
|
||||||
|
this.notifyVotersFinalized(updated).catch((err) =>
|
||||||
|
logger.error('Failed to send finalization notifications', { error: err })
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
|
||||||
|
async convertToShift(id: string, data: ConvertToShiftInput) {
|
||||||
|
const poll = await prisma.schedulingPoll.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { options: true },
|
||||||
|
});
|
||||||
|
if (!poll) throw new AppError(404, 'Poll not found');
|
||||||
|
if (poll.status !== 'FINALIZED') throw new AppError(400, 'Poll must be finalized before converting');
|
||||||
|
if (poll.convertedShiftId) throw new AppError(400, 'Poll has already been converted to a shift');
|
||||||
|
if (!poll.finalizedOptionId) throw new AppError(400, 'No finalized option selected');
|
||||||
|
|
||||||
|
const option = poll.options.find((o) => o.id === poll.finalizedOptionId);
|
||||||
|
if (!option) throw new AppError(400, 'Finalized option not found');
|
||||||
|
|
||||||
|
const [shift] = await prisma.$transaction([
|
||||||
|
prisma.shift.create({
|
||||||
|
data: {
|
||||||
|
title: poll.title,
|
||||||
|
description: poll.description,
|
||||||
|
date: option.date,
|
||||||
|
startTime: option.startTime,
|
||||||
|
endTime: option.endTime,
|
||||||
|
location: poll.location,
|
||||||
|
maxVolunteers: data.maxVolunteers,
|
||||||
|
isPublic: data.isPublic,
|
||||||
|
cutId: data.cutId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await prisma.schedulingPoll.update({
|
||||||
|
where: { id },
|
||||||
|
data: { convertedShiftId: shift.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return shift;
|
||||||
|
},
|
||||||
|
|
||||||
|
async convertToEvent(id: string) {
|
||||||
|
const poll = await prisma.schedulingPoll.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { options: true },
|
||||||
|
});
|
||||||
|
if (!poll) throw new AppError(404, 'Poll not found');
|
||||||
|
if (poll.status !== 'FINALIZED') throw new AppError(400, 'Poll must be finalized before converting');
|
||||||
|
if (poll.convertedGancioEventId) throw new AppError(400, 'Poll has already been converted to an event');
|
||||||
|
if (!poll.finalizedOptionId) throw new AppError(400, 'No finalized option selected');
|
||||||
|
|
||||||
|
const option = poll.options.find((o) => o.id === poll.finalizedOptionId);
|
||||||
|
if (!option) throw new AppError(400, 'Finalized option not found');
|
||||||
|
|
||||||
|
// Dynamically import gancio client to avoid hard dependency
|
||||||
|
const { gancioClient } = await import('../../services/gancio.client');
|
||||||
|
const eventId = await gancioClient.createEvent({
|
||||||
|
title: poll.title,
|
||||||
|
description: poll.description,
|
||||||
|
location: poll.location,
|
||||||
|
date: option.date,
|
||||||
|
startTime: option.startTime,
|
||||||
|
endTime: option.endTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!eventId) throw new AppError(500, 'Failed to create Gancio event');
|
||||||
|
|
||||||
|
await prisma.schedulingPoll.update({
|
||||||
|
where: { id },
|
||||||
|
data: { convertedGancioEventId: eventId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { gancioEventId: eventId };
|
||||||
|
},
|
||||||
|
|
||||||
|
async notifyOrganizer(email: string, pollTitle: string, voterName: string) {
|
||||||
|
try {
|
||||||
|
await emailService.sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: `New vote on "${pollTitle}"`,
|
||||||
|
html: `<p><strong>${escapeHtml(voterName)}</strong> voted on your scheduling poll "<strong>${escapeHtml(pollTitle)}</strong>".</p>`,
|
||||||
|
text: `${voterName} voted on your scheduling poll "${pollTitle}".`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to send vote notification email', { error: err });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async notifyVotersFinalized(poll: any) {
|
||||||
|
const finalOption = poll.options.find((o: any) => o.id === poll.finalizedOptionId);
|
||||||
|
if (!finalOption) return;
|
||||||
|
|
||||||
|
const dateStr = new Date(finalOption.date).toLocaleDateString('en-CA', {
|
||||||
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||||
|
});
|
||||||
|
const timeStr = `${finalOption.startTime} - ${finalOption.endTime}`;
|
||||||
|
|
||||||
|
// Collect unique voter emails
|
||||||
|
const voterEmails = new Set<string>();
|
||||||
|
for (const opt of poll.options) {
|
||||||
|
for (const vote of opt.votes) {
|
||||||
|
if (vote.voterEmail) voterEmails.add(vote.voterEmail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const email of voterEmails) {
|
||||||
|
try {
|
||||||
|
await emailService.sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: `Date confirmed for "${poll.title}"`,
|
||||||
|
html: `<p>The date for "<strong>${escapeHtml(poll.title)}</strong>" has been confirmed:</p>
|
||||||
|
<p><strong>${dateStr}</strong><br/>${timeStr}</p>
|
||||||
|
${poll.location ? `<p>Location: ${escapeHtml(poll.location)}</p>` : ''}`,
|
||||||
|
text: `The date for "${poll.title}" has been confirmed:\n${dateStr}\n${timeStr}${poll.location ? `\nLocation: ${poll.location}` : ''}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to send finalization email', { error: err, email });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateVoterToken(): string {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let token = '';
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
token += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
@ -56,6 +56,7 @@ export const updateSiteSettingsSchema = z.object({
|
|||||||
enablePeople: z.boolean().optional(),
|
enablePeople: z.boolean().optional(),
|
||||||
enableSocial: z.boolean().optional(),
|
enableSocial: z.boolean().optional(),
|
||||||
enableMeet: z.boolean().optional(),
|
enableMeet: z.boolean().optional(),
|
||||||
|
enableMeetingPlanner: z.boolean().optional(),
|
||||||
autoSyncPeopleToMap: z.boolean().optional(),
|
autoSyncPeopleToMap: z.boolean().optional(),
|
||||||
|
|
||||||
// SMS connection config
|
// SMS connection config
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { authenticate } from '../../../middleware/auth.middleware';
|
|||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
import { smsContactsService } from './sms-contacts.service';
|
import { smsContactsService } from './sms-contacts.service';
|
||||||
import { createContactListSchema, updateContactListSchema, createContactEntrySchema } from './sms-contacts.schemas';
|
import { createContactListSchema, updateContactListSchema, createContactEntrySchema, bulkAddEntriesSchema } from './sms-contacts.schemas';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -144,6 +144,14 @@ router.post('/:id/entries', validate(createContactEntrySchema), async (req, res,
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/sms/contacts/:id/entries/bulk — add multiple entries at once
|
||||||
|
router.post('/:id/entries/bulk', validate(bulkAddEntriesSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await smsContactsService.addEntriesBulk(req.params.id as string, req.body.entries);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /api/sms/contacts/:id/entries/:entryId — remove an entry
|
// DELETE /api/sms/contacts/:id/entries/:entryId — remove an entry
|
||||||
router.delete('/:id/entries/:entryId', async (req, res, next) => {
|
router.delete('/:id/entries/:entryId', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -15,6 +15,15 @@ export const createContactEntrySchema = z.object({
|
|||||||
customFields: z.record(z.string()).optional(),
|
customFields: z.record(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const bulkAddEntriesSchema = z.object({
|
||||||
|
entries: z.array(z.object({
|
||||||
|
phone: z.string().min(7).max(20),
|
||||||
|
name: z.string().max(200).optional(),
|
||||||
|
email: z.string().email().max(200).optional(),
|
||||||
|
})).min(1).max(1000),
|
||||||
|
});
|
||||||
|
|
||||||
export type CreateContactListInput = z.infer<typeof createContactListSchema>;
|
export type CreateContactListInput = z.infer<typeof createContactListSchema>;
|
||||||
export type UpdateContactListInput = z.infer<typeof updateContactListSchema>;
|
export type UpdateContactListInput = z.infer<typeof updateContactListSchema>;
|
||||||
export type CreateContactEntryInput = z.infer<typeof createContactEntrySchema>;
|
export type CreateContactEntryInput = z.infer<typeof createContactEntrySchema>;
|
||||||
|
export type BulkAddEntriesInput = z.infer<typeof bulkAddEntriesSchema>;
|
||||||
|
|||||||
@ -197,6 +197,32 @@ export const smsContactsService = {
|
|||||||
return entry;
|
return entry;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async addEntriesBulk(listId: string, entries: { phone: string; name?: string; email?: string }[]) {
|
||||||
|
let imported = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const phone = normalizePhone(entry.phone);
|
||||||
|
if (!phone) { skipped++; continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.smsContactListEntry.upsert({
|
||||||
|
where: { listId_phone: { listId, phone } },
|
||||||
|
create: { listId, phone, name: entry.name, email: entry.email },
|
||||||
|
update: { name: entry.name, email: entry.email },
|
||||||
|
});
|
||||||
|
imported++;
|
||||||
|
} catch {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = await prisma.smsContactListEntry.count({ where: { listId } });
|
||||||
|
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
|
||||||
|
|
||||||
|
return { imported, skipped, total };
|
||||||
|
},
|
||||||
|
|
||||||
async deleteEntry(id: string) {
|
async deleteEntry(id: string) {
|
||||||
const entry = await prisma.smsContactListEntry.delete({ where: { id } });
|
const entry = await prisma.smsContactListEntry.delete({ where: { id } });
|
||||||
// Update total count
|
// Update total count
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import { mapSettingsRouter } from './modules/map/settings/settings.routes';
|
|||||||
import { qrRouter } from './modules/qr/qr.routes';
|
import { qrRouter } from './modules/qr/qr.routes';
|
||||||
import { listmonkRouter } from './modules/listmonk/listmonk.routes';
|
import { listmonkRouter } from './modules/listmonk/listmonk.routes';
|
||||||
import { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.routes';
|
import { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.routes';
|
||||||
|
import { meetingPlannerAdminRouter, meetingPlannerPublicRouter } from './modules/meeting-planner/meeting-planner.routes';
|
||||||
import { pagesPublicRouter } from './modules/pages/pages-public.routes';
|
import { pagesPublicRouter } from './modules/pages/pages-public.routes';
|
||||||
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
|
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
|
||||||
import { blocksRouter } from './modules/pages/blocks.routes';
|
import { blocksRouter } from './modules/pages/blocks.routes';
|
||||||
@ -211,6 +212,8 @@ app.use('/api/map/shifts', shiftsAdminRouter); // Admin shift CRUD (au
|
|||||||
app.use('/api/map/geocoding', geocodingRouter); // Geocoding search (MAP_ADMIN+)
|
app.use('/api/map/geocoding', geocodingRouter); // Geocoding search (MAP_ADMIN+)
|
||||||
app.use('/api/map/settings', mapSettingsRouter); // Map settings (public GET, auth PUT)
|
app.use('/api/map/settings', mapSettingsRouter); // Map settings (public GET, auth PUT)
|
||||||
app.use('/api/map/events', eventsPublicRouter); // Public map events from Gancio (no auth)
|
app.use('/api/map/events', eventsPublicRouter); // Public map events from Gancio (no auth)
|
||||||
|
app.use('/api/meeting-planner', meetingPlannerPublicRouter); // Public poll viewing + voting (no auth)
|
||||||
|
app.use('/api/meeting-planner', meetingPlannerAdminRouter); // Admin poll CRUD (auth required)
|
||||||
app.use('/api/qr', qrRouter); // QR code generation (public)
|
app.use('/api/qr', qrRouter); // QR code generation (public)
|
||||||
app.use('/api/listmonk', listmonkWebhookRouter); // Listmonk webhook (shared secret, no JWT)
|
app.use('/api/listmonk', listmonkWebhookRouter); // Listmonk webhook (shared secret, no JWT)
|
||||||
app.use('/api/listmonk', listmonkRouter); // Listmonk newsletter sync (SUPER_ADMIN)
|
app.use('/api/listmonk', listmonkRouter); // Listmonk newsletter sync (SUPER_ADMIN)
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
Subproject commit 2457662e12b5fd4c2e62a22503f3ffd93dc5e303
|
Subproject commit d9be9c961d4ffcf32abac81fd32589abfb146fd3
|
||||||
189
mkdocs/docs/assets/js/scheduling-poll.js
Normal file
189
mkdocs/docs/assets/js/scheduling-poll.js
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Scheduling Poll Block Hydration for MkDocs
|
||||||
|
*
|
||||||
|
* Scans for .scheduling-poll-block elements, fetches poll data from the API,
|
||||||
|
* and renders a read-only poll summary with a "Vote Now" link to the full
|
||||||
|
* interactive voting page on the app.
|
||||||
|
*
|
||||||
|
* Follows the gancio-events.js hydration pattern.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function getApiUrl() {
|
||||||
|
// env-config.js injects these globals
|
||||||
|
if (window.PAYMENT_API_URL) return window.PAYMENT_API_URL;
|
||||||
|
if (window.API_URL) return window.API_URL;
|
||||||
|
|
||||||
|
var host = window.location.hostname;
|
||||||
|
if (host !== 'localhost' && host.indexOf('.') !== -1) {
|
||||||
|
var parts = host.split('.');
|
||||||
|
var base = parts.slice(-2).join('.');
|
||||||
|
return window.location.protocol + '//api.' + base;
|
||||||
|
}
|
||||||
|
return 'http://localhost:4000';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAppUrl() {
|
||||||
|
if (window.APP_URL) return window.APP_URL;
|
||||||
|
|
||||||
|
var host = window.location.hostname;
|
||||||
|
if (host !== 'localhost' && host.indexOf('.') !== -1) {
|
||||||
|
var parts = host.split('.');
|
||||||
|
var base = parts.slice(-2).join('.');
|
||||||
|
return window.location.protocol + '//app.' + base;
|
||||||
|
}
|
||||||
|
return 'http://localhost:3000';
|
||||||
|
}
|
||||||
|
|
||||||
|
var STATUS_COLORS = {
|
||||||
|
OPEN: '#52c41a',
|
||||||
|
CLOSED: '#fa8c16',
|
||||||
|
FINALIZED: '#1890ff',
|
||||||
|
CANCELLED: '#ff4d4f',
|
||||||
|
};
|
||||||
|
|
||||||
|
var STATUS_LABELS = {
|
||||||
|
OPEN: 'Open for Voting',
|
||||||
|
CLOSED: 'Closed',
|
||||||
|
FINALIZED: 'Date Confirmed',
|
||||||
|
CANCELLED: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
var d = new Date(dateStr + 'T00:00:00');
|
||||||
|
var days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
return days[d.getDay()] + ', ' + months[d.getMonth()] + ' ' + d.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateBlocks() {
|
||||||
|
var blocks = document.querySelectorAll('.scheduling-poll-block');
|
||||||
|
if (blocks.length === 0) return;
|
||||||
|
|
||||||
|
var apiUrl = getApiUrl();
|
||||||
|
var appUrl = getAppUrl();
|
||||||
|
|
||||||
|
blocks.forEach(function (block) {
|
||||||
|
// Skip if already hydrated
|
||||||
|
if (block.getAttribute('data-hydrated') === 'true') return;
|
||||||
|
|
||||||
|
var slug = block.getAttribute('data-poll-slug');
|
||||||
|
if (!slug) return;
|
||||||
|
|
||||||
|
var showComments = block.getAttribute('data-show-comments') !== 'false';
|
||||||
|
var title = block.getAttribute('data-title') || '';
|
||||||
|
|
||||||
|
block.setAttribute('data-hydrated', 'true');
|
||||||
|
block.innerHTML = '<div style="text-align:center; padding:20px; opacity:0.6;">Loading poll...</div>';
|
||||||
|
|
||||||
|
fetch(apiUrl + '/api/meeting-planner/public/' + encodeURIComponent(slug))
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) throw new Error('Poll not found');
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(function (poll) {
|
||||||
|
var statusColor = STATUS_COLORS[poll.status] || '#666';
|
||||||
|
var statusLabel = STATUS_LABELS[poll.status] || poll.status;
|
||||||
|
var isFinalized = poll.status === 'FINALIZED';
|
||||||
|
var options = poll.options || [];
|
||||||
|
var bestScore = 0;
|
||||||
|
options.forEach(function (o) {
|
||||||
|
if ((o.score || 0) > bestScore) bestScore = o.score || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
// Title
|
||||||
|
if (title) {
|
||||||
|
html += '<h2 style="text-align:center; margin:0 0 8px; font-size:1.5rem;">' + title + '</h2>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll title + status
|
||||||
|
html += '<h3 style="margin:0 0 8px; font-size:1.2rem;">' + poll.title + '</h3>';
|
||||||
|
if (poll.description) {
|
||||||
|
html += '<p style="opacity:0.75; margin:0 0 8px; line-height:1.5;">' + poll.description + '</p>';
|
||||||
|
}
|
||||||
|
html += '<div style="margin-bottom:12px;">';
|
||||||
|
html += '<span style="display:inline-block; padding:2px 10px; border-radius:4px; font-size:12px; font-weight:600; background:' + statusColor + '22; color:' + statusColor + '; border:1px solid ' + statusColor + '44;">' + statusLabel + '</span>';
|
||||||
|
if (poll.location) {
|
||||||
|
html += ' <span style="font-size:13px; opacity:0.65; margin-left:8px;">' + poll.location + '</span>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Finalized banner
|
||||||
|
if (isFinalized && poll.finalizedOption) {
|
||||||
|
html += '<div style="padding:10px 14px; border-radius:6px; background:rgba(82,196,26,0.1); border:1px solid rgba(82,196,26,0.3); margin-bottom:12px; color:#52c41a;">';
|
||||||
|
html += '<strong>Confirmed:</strong> ' + formatDate(poll.finalizedOption.date) + ' — ' + poll.finalizedOption.startTime + '–' + poll.finalizedOption.endTime;
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options table
|
||||||
|
if (options.length > 0) {
|
||||||
|
html += '<div style="overflow-x:auto; margin-bottom:12px;">';
|
||||||
|
html += '<table style="width:100%; border-collapse:collapse; font-size:13px;">';
|
||||||
|
html += '<thead><tr>';
|
||||||
|
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:left;">Date / Time</th>';
|
||||||
|
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">Yes</th>';
|
||||||
|
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">If Need Be</th>';
|
||||||
|
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">No</th>';
|
||||||
|
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">Score</th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
options.forEach(function (opt) {
|
||||||
|
var isBest = bestScore > 0 && (opt.score || 0) === bestScore;
|
||||||
|
var isConfirmed = isFinalized && poll.finalizedOptionId === opt.id;
|
||||||
|
var rowBg = isConfirmed ? 'rgba(82,196,26,0.08)' : isBest ? 'rgba(82,196,26,0.04)' : '';
|
||||||
|
html += '<tr style="background:' + rowBg + ';">';
|
||||||
|
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1);">';
|
||||||
|
html += '<strong>' + formatDate(opt.date) + '</strong><br><span style="font-size:11px; opacity:0.7;">' + opt.startTime + '–' + opt.endTime + '</span>';
|
||||||
|
if (isConfirmed) html += ' <span style="color:#52c41a; font-size:10px; font-weight:600;">✓</span>';
|
||||||
|
html += '</td>';
|
||||||
|
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; color:#52c41a;">' + (opt.yesCount || 0) + '</td>';
|
||||||
|
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; color:#faad14;">' + (opt.ifNeedBeCount || 0) + '</td>';
|
||||||
|
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; color:#d9d9d9;">' + (opt.noCount || 0) + '</td>';
|
||||||
|
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; font-weight:600;">' + (opt.score || 0) + '</td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments count
|
||||||
|
if (showComments && poll.comments && poll.comments.length > 0) {
|
||||||
|
html += '<p style="font-size:13px; opacity:0.65;">' + poll.comments.length + ' comment' + (poll.comments.length !== 1 ? 's' : '') + '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vote Now CTA
|
||||||
|
if (poll.status === 'OPEN') {
|
||||||
|
html += '<div style="text-align:center; margin-top:16px;">';
|
||||||
|
html += '<a href="' + appUrl + '/poll/' + encodeURIComponent(slug) + '" target="_blank" rel="noopener noreferrer" ';
|
||||||
|
html += 'style="display:inline-block; padding:12px 32px; background:#fa8c16; color:#fff; text-decoration:none; border-radius:6px; font-weight:600; font-size:14px;">';
|
||||||
|
html += 'Vote Now →</a></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
block.innerHTML = '<div style="max-width:700px; margin:0 auto;">' + html + '</div>';
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
block.innerHTML = '<div style="text-align:center; padding:24px; opacity:0.5;">' +
|
||||||
|
'<p>Poll unavailable</p>' +
|
||||||
|
'<a href="' + appUrl + '/poll/' + encodeURIComponent(slug) + '" target="_blank" rel="noopener noreferrer" style="color:#fa8c16;">View poll →</a>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial hydration
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', hydrateBlocks);
|
||||||
|
} else {
|
||||||
|
hydrateBlocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-hydrate on MkDocs SPA navigation
|
||||||
|
if (typeof document$ !== 'undefined') {
|
||||||
|
document$.subscribe(function () {
|
||||||
|
setTimeout(hydrateBlocks, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@ -95,10 +95,23 @@ The setup script automatically:
|
|||||||
- Saves the API key to `~/.bashrc`
|
- Saves the API key to `~/.bashrc`
|
||||||
- Requests SMS and Contacts permissions (tap **Allow** when prompted)
|
- Requests SMS and Contacts permissions (tap **Allow** when prompted)
|
||||||
- Creates a Termux:Boot auto-start script (if Termux:Boot is installed)
|
- Creates a Termux:Boot auto-start script (if Termux:Boot is installed)
|
||||||
- Starts the SMS server with the watchdog (auto-restarts on crash)
|
- Starts the SMS server
|
||||||
|
|
||||||
When done, note the **Phone URL** displayed (e.g. `http://100.64.0.5:5001`).
|
When done, note the **Phone URL** displayed (e.g. `http://100.64.0.5:5001`).
|
||||||
|
|
||||||
|
#### Recommended: Install Service Supervisor
|
||||||
|
|
||||||
|
After initial setup, install `termux-services` for reliable process management. This uses runit, a proper UNIX service supervisor that automatically restarts the server if it crashes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/sms-server && bash android/setup-services.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This registers two supervised services:
|
||||||
|
|
||||||
|
- **sms-api** — Flask SMS API server (port 5001)
|
||||||
|
- **sshd-custom** — SSH daemon for remote management (port 8022)
|
||||||
|
|
||||||
### Step 4: Prevent Android from Killing Termux
|
### Step 4: Prevent Android from Killing Termux
|
||||||
|
|
||||||
This is **required** for the server to run reliably in the background:
|
This is **required** for the server to run reliably in the background:
|
||||||
@ -115,17 +128,35 @@ To pull the latest server code and re-run setup:
|
|||||||
cd ~/sms-server && git pull && bash android/setup.sh YOUR_API_KEY_HERE
|
cd ~/sms-server && git pull && bash android/setup.sh YOUR_API_KEY_HERE
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Control
|
### Service Management
|
||||||
|
|
||||||
|
If you installed `termux-services` (recommended):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check if the server is running
|
# Check status
|
||||||
curl http://127.0.0.1:5001/health
|
sv status sms-api
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
sv restart sms-api
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
sv down sms-api
|
||||||
|
|
||||||
|
# Start
|
||||||
|
sv up sms-api
|
||||||
|
|
||||||
# View logs
|
# View logs
|
||||||
tail -f ~/logs/sms-api.log
|
tail -f ~/logs/sms-api.log
|
||||||
|
|
||||||
# Stop the server
|
# Health check
|
||||||
pkill -f sms-watchdog.sh && pkill -f termux-sms-api-server.py
|
curl http://127.0.0.1:5001/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `termux-services` (legacy watchdog):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if the server is running
|
||||||
|
curl http://127.0.0.1:5001/health
|
||||||
|
|
||||||
# Restart manually
|
# Restart manually
|
||||||
cd ~/sms-server/android && bash sms-watchdog.sh
|
cd ~/sms-server/android && bash sms-watchdog.sh
|
||||||
@ -353,8 +384,8 @@ export SMS_API_SECRET='correct-key-from-admin-panel'
|
|||||||
echo 'export SMS_API_SECRET="correct-key-from-admin-panel"' >> ~/.bashrc
|
echo 'export SMS_API_SECRET="correct-key-from-admin-panel"' >> ~/.bashrc
|
||||||
|
|
||||||
# Restart the server
|
# Restart the server
|
||||||
pkill -f termux-sms-api-server.py
|
sv restart sms-api
|
||||||
cd ~/sms-server/android && python termux-sms-api-server.py
|
# Or without termux-services: pkill -f termux-sms-api-server.py && cd ~/sms-server/android && python termux-sms-api-server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### SMS not sending
|
### SMS not sending
|
||||||
@ -372,12 +403,13 @@ cd ~/sms-server/android && python termux-sms-api-server.py
|
|||||||
|
|
||||||
**Symptoms:** Server stops after some time, especially when phone screen is off.
|
**Symptoms:** Server stops after some time, especially when phone screen is off.
|
||||||
|
|
||||||
**Fix:** Disable battery optimization for Termux:
|
**Fix:**
|
||||||
|
|
||||||
1. Android Settings → Apps → Termux → Battery → **Unrestricted**
|
1. **Install `termux-services`** (if not already): `bash ~/sms-server/android/setup-services.sh` — this uses runit, a proper service supervisor that auto-restarts the server immediately if it crashes
|
||||||
2. Lock Termux in recent apps (long-press app card → Lock)
|
2. **Disable battery optimization:** Android Settings → Apps → Termux → Battery → **Unrestricted**
|
||||||
3. Some phones: Settings → Battery → Battery Optimization → find Termux → Don't Optimize
|
3. **Lock Termux in recent apps** — long-press the app card → Lock/Pin
|
||||||
4. Samsung: Settings → Device Care → Battery → App Power Management → add Termux to "Never sleeping apps"
|
4. Samsung: also add Termux, Termux:API, and Termux:Boot to **Settings → Device Care → Battery → Never Sleeping Apps**
|
||||||
|
5. **Acquire wake lock:** Run `termux-wake-lock` in Termux (included in boot script)
|
||||||
|
|
||||||
### Server won't start — "Missing SMS_API_SECRET"
|
### Server won't start — "Missing SMS_API_SECRET"
|
||||||
|
|
||||||
@ -425,6 +457,6 @@ cd ~/sms-server
|
|||||||
git pull
|
git pull
|
||||||
|
|
||||||
# Restart the server
|
# Restart the server
|
||||||
pkill -f termux-sms-api-server.py
|
sv restart sms-api
|
||||||
cd android && python termux-sms-api-server.py
|
# Or without termux-services: pkill -f termux-sms-api-server.py && cd android && python termux-sms-api-server.py
|
||||||
```
|
```
|
||||||
|
|||||||
@ -183,6 +183,7 @@ Listmonk handles newsletter/marketing campaigns. Sync with the main platform is
|
|||||||
| `LISTMONK_ADMIN_USER` | `v2-api` | Same as `LISTMONK_API_USER` (used by the sync service). |
|
| `LISTMONK_ADMIN_USER` | `v2-api` | Same as `LISTMONK_API_USER` (used by the sync service). |
|
||||||
| `LISTMONK_ADMIN_PASSWORD` | — | Same as `LISTMONK_API_TOKEN`. |
|
| `LISTMONK_ADMIN_PASSWORD` | — | Same as `LISTMONK_API_TOKEN`. |
|
||||||
| `LISTMONK_SYNC_ENABLED` | `false` | :material-flask: Set to `true` to sync participants/locations/users to Listmonk lists. |
|
| `LISTMONK_SYNC_ENABLED` | `false` | :material-flask: Set to `true` to sync participants/locations/users to Listmonk lists. |
|
||||||
|
| `LISTMONK_WEBHOOK_SECRET` | *(empty)* | Shared secret for Listmonk webhook callbacks. |
|
||||||
| `LISTMONK_PROXY_PORT` | `9002` | Nginx proxy port for Listmonk. |
|
| `LISTMONK_PROXY_PORT` | `9002` | Nginx proxy port for Listmonk. |
|
||||||
|
|
||||||
??? example "Listmonk SMTP settings"
|
??? example "Listmonk SMTP settings"
|
||||||
@ -253,6 +254,7 @@ Self-hosted Git repository. Optional service.
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
|
| `GITEA_URL` | `http://gitea-changemaker:3000` | Internal container URL for Gitea. |
|
||||||
| `GITEA_PORT` / `GITEA_WEB_PORT` | `3030` | Gitea web UI port. |
|
| `GITEA_PORT` / `GITEA_WEB_PORT` | `3030` | Gitea web UI port. |
|
||||||
| `GITEA_SSH_PORT` | `2222` | Gitea SSH port for git operations. |
|
| `GITEA_SSH_PORT` | `2222` | Gitea SSH port for git operations. |
|
||||||
| `GITEA_DB_TYPE` | `mysql` | Database type (Gitea uses its own MySQL). |
|
| `GITEA_DB_TYPE` | `mysql` | Database type (Gitea uses its own MySQL). |
|
||||||
@ -264,6 +266,18 @@ Self-hosted Git repository. Optional service.
|
|||||||
| `GITEA_ROOT_URL` | `https://git.cmlite.org` | Public-facing URL for Gitea. |
|
| `GITEA_ROOT_URL` | `https://git.cmlite.org` | Public-facing URL for Gitea. |
|
||||||
| `GITEA_DOMAIN` | `git.cmlite.org` | Domain used in git clone URLs. |
|
| `GITEA_DOMAIN` | `git.cmlite.org` | Domain used in git clone URLs. |
|
||||||
|
|
||||||
|
??? example "Gitea Docs Comments"
|
||||||
|
Enable comments on MkDocs documentation pages, backed by Gitea Issues.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `GITEA_COMMENTS_ENABLED` | `false` | :material-flask: Enable comments on MkDocs pages. |
|
||||||
|
| `GITEA_API_TOKEN` | *(empty)* | Personal access token with repo write scope. Create in Gitea → Settings → Applications. |
|
||||||
|
| `GITEA_COMMENTS_REPO_OWNER` | *(empty)* | Gitea username that owns the docs-comments repo. |
|
||||||
|
| `GITEA_COMMENTS_REPO_NAME` | `docs-comments` | Repository name (auto-created via admin setup). |
|
||||||
|
| `GITEA_OAUTH_CLIENT_ID` | *(empty)* | OAuth2 application client ID (create in Gitea → Settings → Applications → OAuth2). |
|
||||||
|
| `GITEA_OAUTH_CLIENT_SECRET` | *(empty)* | OAuth2 application client secret. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## n8n (Workflow Automation) :material-tune-variant:
|
## n8n (Workflow Automation) :material-tune-variant:
|
||||||
@ -382,6 +396,48 @@ Self-hosted event management platform. Uses the shared PostgreSQL database (auto
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Jitsi Meet (Video Conferencing) :material-flask:
|
||||||
|
|
||||||
|
Self-hosted video conferencing with JWT authentication. Integrates with Rocket.Chat for in-channel video calls.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `ENABLE_MEET` | `false` | :material-flask: Set to `true` to enable the Jitsi Meet integration. The initial default; once saved in admin Settings, the DB value is authoritative. |
|
||||||
|
| `JITSI_APP_ID` | `changemaker` | JWT application ID. Must match across Jitsi Prosody, Rocket.Chat app settings, and `JWT_ACCEPTED_ISSUERS`/`JWT_ACCEPTED_AUDIENCES`. |
|
||||||
|
| `JITSI_APP_SECRET` | — | :material-alert-circle:{ .text-red } JWT secret for signing Jitsi tokens. Generate with `openssl rand -hex 32`. Shared between Jitsi Prosody, Rocket.Chat, and the API. |
|
||||||
|
| `JITSI_JICOFO_AUTH_PASSWORD` | — | Internal XMPP password for Jicofo (conference focus). Generate with `openssl rand -hex 16`. |
|
||||||
|
| `JITSI_JVB_AUTH_PASSWORD` | — | Internal XMPP password for JVB (video bridge). Generate with `openssl rand -hex 16`. |
|
||||||
|
| `JITSI_EMBED_PORT` | `8893` | Port for iframe embedding in admin. |
|
||||||
|
| `JITSI_URL` | `http://jitsi-web-changemaker:80` | Internal container URL. |
|
||||||
|
| `JVB_ADVERTISE_IP` | *(empty)* | Server's public IP address. **Required in production** for NAT traversal so remote participants can connect. |
|
||||||
|
| `JVB_PORT` | `10000` | UDP port for media traffic. Must be open in your firewall. |
|
||||||
|
|
||||||
|
!!! warning "Production requirements"
|
||||||
|
- `JVB_ADVERTISE_IP` must be set to your server's public IP for calls to work outside the local network.
|
||||||
|
- Port `10000/udp` must be open in your firewall for media traffic.
|
||||||
|
- Calls must go through the production domain (not localhost) for SSL/JWT to work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SMS Campaigns (Termux Android Bridge) :material-flask:
|
||||||
|
|
||||||
|
Send SMS messages via an Android phone running the Termux API server. The phone acts as an SMS gateway.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `ENABLE_SMS` | `false` | :material-flask: Set to `true` to enable SMS campaigns. The initial default; once saved in admin Settings, the DB value is authoritative. |
|
||||||
|
| `TERMUX_API_URL` | `http://10.0.0.193:5001` | URL of the Termux API server running on the Android phone. |
|
||||||
|
| `TERMUX_API_KEY` | *(empty)* | API key for authenticating with the Termux server (HMAC auth via `X-API-Key` header). |
|
||||||
|
| `SMS_DELAY_BETWEEN_MS` | `3000` | Delay between sending individual SMS messages (ms). Prevents carrier throttling. |
|
||||||
|
| `SMS_MAX_RETRIES` | `3` | Maximum retry attempts for failed SMS sends. |
|
||||||
|
| `SMS_RESPONSE_SYNC_INTERVAL_MS` | `30000` | How often to poll the phone's inbox for responses (ms). |
|
||||||
|
| `SMS_DEVICE_MONITOR_INTERVAL_MS` | `30000` | How often to check device health — battery, connectivity (ms). |
|
||||||
|
|
||||||
|
!!! tip "GUI configuration"
|
||||||
|
The Termux API URL and API key can also be configured from **Admin → Settings → SMS**. Database values override these env vars when set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MailHog (Development Email)
|
## MailHog (Development Email)
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@ -474,6 +530,20 @@ docker compose --profile monitoring up -d
|
|||||||
| `GOTIFY_PORT` | `8889` | Gotify push notification port. |
|
| `GOTIFY_PORT` | `8889` | Gotify push notification port. |
|
||||||
| `GOTIFY_ADMIN_USER` | `admin` | Gotify admin username. |
|
| `GOTIFY_ADMIN_USER` | `admin` | Gotify admin username. |
|
||||||
| `GOTIFY_ADMIN_PASSWORD` | `admin` | :material-tune-variant: Change in production. |
|
| `GOTIFY_ADMIN_PASSWORD` | `admin` | :material-tune-variant: Change in production. |
|
||||||
|
| `GRAFANA_EMBED_PORT` | `8894` | Port for iframe embedding Grafana in admin. |
|
||||||
|
| `ALERTMANAGER_EMBED_PORT` | `8895` | Port for iframe embedding Alertmanager in admin. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bunker Ops (Fleet Management) :material-flask:
|
||||||
|
|
||||||
|
Remote metrics push for managing multiple Changemaker Lite instances from a central monitoring server.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `INSTANCE_LABEL` | *(empty)* | Unique label for this instance (used as a Prometheus metric label). Falls back to `DOMAIN` if empty. |
|
||||||
|
| `BUNKER_OPS_ENABLED` | `false` | :material-flask: Enable remote metrics push to a central VictoriaMetrics server. |
|
||||||
|
| `BUNKER_OPS_REMOTE_WRITE_URL` | *(empty)* | VictoriaMetrics `remote_write` endpoint (e.g., `https://ops.example.com/api/v1/write`). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -516,6 +586,11 @@ echo "ROCKETCHAT_ADMIN_PASSWORD=$(openssl rand -hex 16)"
|
|||||||
|
|
||||||
# Gancio
|
# Gancio
|
||||||
echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
|
echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
|
||||||
|
|
||||||
|
# Jitsi Meet
|
||||||
|
echo "JITSI_APP_SECRET=$(openssl rand -hex 32)"
|
||||||
|
echo "JITSI_JICOFO_AUTH_PASSWORD=$(openssl rand -hex 16)"
|
||||||
|
echo "JITSI_JVB_AUTH_PASSWORD=$(openssl rand -hex 16)"
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
@ -551,6 +626,8 @@ echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
|
|||||||
ENABLE_MEDIA_FEATURES=true
|
ENABLE_MEDIA_FEATURES=true
|
||||||
ENABLE_PAYMENTS=true
|
ENABLE_PAYMENTS=true
|
||||||
ENABLE_CHAT=true
|
ENABLE_CHAT=true
|
||||||
|
ENABLE_MEET=true
|
||||||
|
ENABLE_SMS=true
|
||||||
LISTMONK_SYNC_ENABLED=true
|
LISTMONK_SYNC_ENABLED=true
|
||||||
GANCIO_SYNC_ENABLED=true
|
GANCIO_SYNC_ENABLED=true
|
||||||
LISTMONK_DB_PASSWORD=...
|
LISTMONK_DB_PASSWORD=...
|
||||||
@ -564,6 +641,10 @@ echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
|
|||||||
VAULTWARDEN_ADMIN_TOKEN=...
|
VAULTWARDEN_ADMIN_TOKEN=...
|
||||||
ROCKETCHAT_ADMIN_PASSWORD=...
|
ROCKETCHAT_ADMIN_PASSWORD=...
|
||||||
GANCIO_ADMIN_PASSWORD=...
|
GANCIO_ADMIN_PASSWORD=...
|
||||||
|
JITSI_APP_SECRET=...
|
||||||
|
JITSI_JICOFO_AUTH_PASSWORD=...
|
||||||
|
JITSI_JVB_AUTH_PASSWORD=...
|
||||||
|
JVB_ADVERTISE_IP=your.public.ip.here
|
||||||
EMAIL_TEST_MODE=false
|
EMAIL_TEST_MODE=false
|
||||||
SMTP_HOST=smtp.your-provider.com
|
SMTP_HOST=smtp.your-provider.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
|
|||||||
@ -87,6 +87,7 @@ extra_javascript:
|
|||||||
- assets/js/image-gallery.js
|
- assets/js/image-gallery.js
|
||||||
- assets/js/gancio-events.js
|
- assets/js/gancio-events.js
|
||||||
- assets/js/payment-widgets.js
|
- assets/js/payment-widgets.js
|
||||||
|
- assets/js/scheduling-poll.js
|
||||||
- javascripts/ad-widgets.js
|
- javascripts/ad-widgets.js
|
||||||
- javascripts/docs-comments.js
|
- javascripts/docs-comments.js
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user