Compare commits
3 Commits
8564f8af83
...
3025ef3d21
| Author | SHA1 | Date | |
|---|---|---|---|
| 3025ef3d21 | |||
|
|
6a807a7cc6 | ||
|
|
fe75912f23 |
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|||||||
import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js';
|
import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js';
|
||||||
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, getNow, convertBooleansForDb, toDbDate } from '../lib/utils.js';
|
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -151,10 +151,11 @@ eventsRouter.get('/', async (c) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const normalized = normalizeEvent(event);
|
const normalized = normalizeEvent(event);
|
||||||
|
const bookedCount = ticketCount?.count || 0;
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount: ticketCount?.count || 0,
|
bookedCount,
|
||||||
availableSeats: normalized.capacity - (ticketCount?.count || 0),
|
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -189,11 +190,12 @@ eventsRouter.get('/:id', async (c) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const normalized = normalizeEvent(event);
|
const normalized = normalizeEvent(event);
|
||||||
|
const bookedCount = ticketCount?.count || 0;
|
||||||
return c.json({
|
return c.json({
|
||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount: ticketCount?.count || 0,
|
bookedCount,
|
||||||
availableSeats: normalized.capacity - (ticketCount?.count || 0),
|
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -277,7 +279,7 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
|||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount,
|
||||||
availableSeats: normalized.capacity - bookedCount,
|
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -308,7 +310,7 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
|||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount,
|
||||||
availableSeats: normalized.capacity - bookedCount,
|
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||||
isFeatured: false,
|
isFeatured: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|||||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
||||||
import { eq, and, sql } from 'drizzle-orm';
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
|
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||||
@@ -87,15 +87,16 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const availableSeats = event.capacity - (existingTicketCount?.count || 0);
|
const confirmedCount = existingTicketCount?.count || 0;
|
||||||
|
const availableSeats = calculateAvailableSeats(event.capacity, confirmedCount);
|
||||||
|
|
||||||
if (availableSeats <= 0) {
|
if (isEventSoldOut(event.capacity, confirmedCount)) {
|
||||||
return c.json({ error: 'Event is sold out' }, 400);
|
return c.json({ error: 'Event is sold out' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticketCount > availableSeats) {
|
if (ticketCount > availableSeats) {
|
||||||
return c.json({
|
return c.json({
|
||||||
error: `Not enough seats available. Only ${availableSeats} spot(s) remaining.`
|
error: `Not enough seats available. Only ${availableSeats} spot(s) remaining.`,
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,22 +970,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check capacity
|
// Admin create at door: bypass capacity check (allow over-capacity for walk-ins)
|
||||||
const ticketCount = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(tickets)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((tickets as any).eventId, data.eventId),
|
|
||||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if ((ticketCount?.count || 0) >= event.capacity) {
|
|
||||||
return c.json({ error: 'Event is at capacity' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
@@ -1117,22 +1103,7 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check capacity
|
// Admin manual ticket: bypass capacity check (allow over-capacity for admin-created tickets)
|
||||||
const existingCount = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(tickets)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((tickets as any).eventId, data.eventId),
|
|
||||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if ((existingCount?.count || 0) >= event.capacity) {
|
|
||||||
return c.json({ error: 'Event is at capacity' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
const attendeeEmail = data.email.trim();
|
const attendeeEmail = data.email.trim();
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ const usersRouter = new Hono<{ Variables: { user: UserContext } }>();
|
|||||||
|
|
||||||
const updateUserSchema = z.object({
|
const updateUserSchema = z.object({
|
||||||
name: z.string().min(2).optional(),
|
name: z.string().min(2).optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(),
|
role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(),
|
||||||
languagePreference: z.enum(['en', 'es']).optional(),
|
languagePreference: z.enum(['en', 'es']).optional(),
|
||||||
|
accountStatus: z.enum(['active', 'unclaimed', 'suspended']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all users (admin only)
|
// Get all users (admin only)
|
||||||
@@ -33,6 +35,9 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
|||||||
phone: (users as any).phone,
|
phone: (users as any).phone,
|
||||||
role: (users as any).role,
|
role: (users as any).role,
|
||||||
languagePreference: (users as any).languagePreference,
|
languagePreference: (users as any).languagePreference,
|
||||||
|
isClaimed: (users as any).isClaimed,
|
||||||
|
rucNumber: (users as any).rucNumber,
|
||||||
|
accountStatus: (users as any).accountStatus,
|
||||||
createdAt: (users as any).createdAt,
|
createdAt: (users as any).createdAt,
|
||||||
}).from(users);
|
}).from(users);
|
||||||
|
|
||||||
@@ -64,6 +69,9 @@ usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
|||||||
phone: (users as any).phone,
|
phone: (users as any).phone,
|
||||||
role: (users as any).role,
|
role: (users as any).role,
|
||||||
languagePreference: (users as any).languagePreference,
|
languagePreference: (users as any).languagePreference,
|
||||||
|
isClaimed: (users as any).isClaimed,
|
||||||
|
rucNumber: (users as any).rucNumber,
|
||||||
|
accountStatus: (users as any).accountStatus,
|
||||||
createdAt: (users as any).createdAt,
|
createdAt: (users as any).createdAt,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
@@ -88,10 +96,16 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
|||||||
return c.json({ error: 'Forbidden' }, 403);
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only admin can change roles
|
// Only admin can change roles, email, and account status
|
||||||
if (data.role && currentUser.role !== 'admin') {
|
if (data.role && currentUser.role !== 'admin') {
|
||||||
delete data.role;
|
delete data.role;
|
||||||
}
|
}
|
||||||
|
if (data.email && currentUser.role !== 'admin') {
|
||||||
|
delete data.email;
|
||||||
|
}
|
||||||
|
if (data.accountStatus && currentUser.role !== 'admin') {
|
||||||
|
delete data.accountStatus;
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await dbGet(
|
const existing = await dbGet(
|
||||||
(db as any).select().from(users).where(eq((users as any).id, id))
|
(db as any).select().from(users).where(eq((users as any).id, id))
|
||||||
@@ -114,6 +128,10 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
|||||||
phone: (users as any).phone,
|
phone: (users as any).phone,
|
||||||
role: (users as any).role,
|
role: (users as any).role,
|
||||||
languagePreference: (users as any).languagePreference,
|
languagePreference: (users as any).languagePreference,
|
||||||
|
isClaimed: (users as any).isClaimed,
|
||||||
|
rucNumber: (users as any).rucNumber,
|
||||||
|
accountStatus: (users as any).accountStatus,
|
||||||
|
createdAt: (users as any).createdAt,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq((users as any).id, id))
|
.where(eq((users as any).id, id))
|
||||||
|
|||||||
@@ -157,7 +157,25 @@ export default function BookingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bookedCount = eventRes.event.bookedCount ?? 0;
|
||||||
|
const capacity = eventRes.event.capacity ?? 0;
|
||||||
|
const soldOut = bookedCount >= capacity;
|
||||||
|
if (soldOut) {
|
||||||
|
toast.error(t('events.details.soldOut'));
|
||||||
|
router.push(`/events/${eventRes.event.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spotsLeft = Math.max(0, capacity - bookedCount);
|
||||||
setEvent(eventRes.event);
|
setEvent(eventRes.event);
|
||||||
|
// Cap quantity by available spots (never allow requesting more than spotsLeft)
|
||||||
|
setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft)));
|
||||||
|
setAttendees((prev) => {
|
||||||
|
const newQty = Math.min(initialQuantity, Math.max(1, spotsLeft));
|
||||||
|
const need = Math.max(0, newQty - 1);
|
||||||
|
if (need === prev.length) return prev;
|
||||||
|
return Array(need).fill(null).map((_, i) => prev[i] ?? { firstName: '', lastName: '' });
|
||||||
|
});
|
||||||
setPaymentConfig(paymentRes.paymentOptions);
|
setPaymentConfig(paymentRes.paymentOptions);
|
||||||
|
|
||||||
// Set default payment method based on what's enabled
|
// Set default payment method based on what's enabled
|
||||||
@@ -513,7 +531,8 @@ export default function BookingPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSoldOut = event.availableSeats === 0;
|
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
||||||
|
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
||||||
|
|
||||||
// Get title and description based on payment method
|
// Get title and description based on payment method
|
||||||
const getSuccessContent = () => {
|
const getSuccessContent = () => {
|
||||||
@@ -1035,7 +1054,7 @@ export default function BookingPage() {
|
|||||||
{!event.externalBookingEnabled && (
|
{!event.externalBookingEnabled && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
|
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
<span>{event.availableSeats} {t('events.details.spotsLeft')}</span>
|
<span>{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
// Max tickets is remaining capacity
|
// Spots left: never negative; sold out when confirmed >= capacity
|
||||||
const maxTickets = Math.max(1, event.availableSeats || 1);
|
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
||||||
|
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
||||||
|
const maxTickets = isSoldOut ? 0 : Math.max(1, spotsLeft);
|
||||||
|
|
||||||
const decreaseQuantity = () => {
|
const decreaseQuantity = () => {
|
||||||
setTicketQuantity(prev => Math.max(1, prev - 1));
|
setTicketQuantity(prev => Math.max(1, prev - 1));
|
||||||
@@ -68,7 +70,6 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSoldOut = event.availableSeats === 0;
|
|
||||||
const isCancelled = event.status === 'cancelled';
|
const isCancelled = event.status === 'cancelled';
|
||||||
// Only calculate isPastEvent after mount to avoid hydration mismatch
|
// Only calculate isPastEvent after mount to avoid hydration mismatch
|
||||||
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
|
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
|
||||||
@@ -154,7 +155,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
|
|
||||||
{!event.externalBookingEnabled && (
|
{!event.externalBookingEnabled && (
|
||||||
<p className="mt-4 text-center text-sm text-gray-500">
|
<p className="mt-4 text-center text-sm text-gray-500">
|
||||||
{event.availableSeats} {t('events.details.spotsLeft')}
|
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -257,7 +258,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
|
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ function generateEventJsonLd(event: Event) {
|
|||||||
'@type': 'Offer',
|
'@type': 'Offer',
|
||||||
price: event.price,
|
price: event.price,
|
||||||
priceCurrency: event.currency,
|
priceCurrency: event.currency,
|
||||||
availability: event.availableSeats && event.availableSeats > 0
|
availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0
|
||||||
? 'https://schema.org/InStock'
|
? 'https://schema.org/InStock'
|
||||||
: 'https://schema.org/SoldOut',
|
: 'https://schema.org/SoldOut',
|
||||||
url: `${siteUrl}/events/${event.id}`,
|
url: `${siteUrl}/events/${event.id}`,
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export default function EventsPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserGroupIcon className="w-4 h-4" />
|
<UserGroupIcon className="w-4 h-4" />
|
||||||
<span>
|
<span>
|
||||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
{Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -619,7 +619,7 @@ export default function AdminEventDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Capacity</p>
|
<p className="font-medium">Capacity</p>
|
||||||
<p className="text-gray-600">{confirmedCount + checkedInCount} / {event.capacity} spots filled</p>
|
<p className="text-gray-600">{confirmedCount + checkedInCount} / {event.capacity} spots filled</p>
|
||||||
<p className="text-sm text-gray-500">{event.capacity - confirmedCount - checkedInCount} spots remaining</p>
|
<p className="text-sm text-gray-500">{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -113,12 +113,12 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Low capacity warnings */}
|
{/* Low capacity warnings */}
|
||||||
{data?.upcomingEvents
|
{data?.upcomingEvents
|
||||||
.filter(event => {
|
.filter(event => {
|
||||||
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0));
|
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
|
||||||
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
|
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
|
||||||
return percentFull >= 80 && availableSeats > 0;
|
return percentFull >= 80 && spotsLeft > 0;
|
||||||
})
|
})
|
||||||
.map(event => {
|
.map(event => {
|
||||||
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0));
|
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
|
||||||
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
|
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -130,7 +130,7 @@ export default function AdminDashboardPage() {
|
|||||||
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
|
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium">{event.title}</span>
|
<span className="text-sm font-medium">{event.title}</span>
|
||||||
<p className="text-xs text-gray-500">Only {availableSeats} spots left ({percentFull}% full)</p>
|
<p className="text-xs text-gray-500">Only {spotsLeft} spots left ({percentFull}% full)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge badge-warning">Low capacity</span>
|
<span className="badge badge-warning">Low capacity</span>
|
||||||
@@ -140,7 +140,7 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
{/* Sold out events */}
|
{/* Sold out events */}
|
||||||
{data?.upcomingEvents
|
{data?.upcomingEvents
|
||||||
.filter(event => (event.availableSeats ?? (event.capacity - (event.bookedCount || 0))) === 0)
|
.filter(event => Math.max(0, event.capacity - (event.bookedCount || 0)) === 0)
|
||||||
.map(event => (
|
.map(event => (
|
||||||
<Link
|
<Link
|
||||||
key={event.id}
|
key={event.id}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { useLanguage } from '@/context/LanguageContext';
|
|||||||
import { usersApi, User } from '@/lib/api';
|
import { usersApi, User } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { TrashIcon } from '@heroicons/react/24/outline';
|
import Input from '@/components/ui/Input';
|
||||||
|
import { TrashIcon, PencilSquareIcon } from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
@@ -13,6 +14,16 @@ export default function AdminUsersPage() {
|
|||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [roleFilter, setRoleFilter] = useState<string>('');
|
const [roleFilter, setRoleFilter] = useState<string>('');
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
role: '' as User['role'],
|
||||||
|
languagePreference: '' as string,
|
||||||
|
accountStatus: '' as string,
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
@@ -51,6 +62,51 @@ export default function AdminUsersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEditModal = (user: User) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setEditForm({
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
phone: user.phone || '',
|
||||||
|
role: user.role,
|
||||||
|
languagePreference: user.languagePreference || '',
|
||||||
|
accountStatus: user.accountStatus || 'active',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingUser) return;
|
||||||
|
|
||||||
|
if (!editForm.name.trim() || editForm.name.trim().length < 2) {
|
||||||
|
toast.error('Name must be at least 2 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editForm.email.trim()) {
|
||||||
|
toast.error('Email is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await usersApi.update(editingUser.id, {
|
||||||
|
name: editForm.name.trim(),
|
||||||
|
email: editForm.email.trim(),
|
||||||
|
phone: editForm.phone.trim() || undefined,
|
||||||
|
role: editForm.role,
|
||||||
|
languagePreference: editForm.languagePreference || undefined,
|
||||||
|
accountStatus: editForm.accountStatus || undefined,
|
||||||
|
} as Partial<User>);
|
||||||
|
toast.success('User updated successfully');
|
||||||
|
setEditingUser(null);
|
||||||
|
loadUsers();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to update user');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -162,6 +218,13 @@ export default function AdminUsersPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openEditModal(user)}
|
||||||
|
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<PencilSquareIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(user.id)}
|
onClick={() => handleDelete(user.id)}
|
||||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||||
@@ -178,6 +241,94 @@ export default function AdminUsersPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Edit User Modal */}
|
||||||
|
{editingUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto p-6">
|
||||||
|
<h2 className="text-xl font-bold text-primary-dark mb-6">Edit User</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleEditSubmit} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
|
required
|
||||||
|
minLength={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={editForm.email}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
value={editForm.phone}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-dark mb-1.5">Role</label>
|
||||||
|
<select
|
||||||
|
value={editForm.role}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, role: e.target.value as User['role'] })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="user">{t('admin.users.roles.user')}</option>
|
||||||
|
<option value="staff">{t('admin.users.roles.staff')}</option>
|
||||||
|
<option value="marketing">{t('admin.users.roles.marketing')}</option>
|
||||||
|
<option value="organizer">{t('admin.users.roles.organizer')}</option>
|
||||||
|
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-dark mb-1.5">Language Preference</label>
|
||||||
|
<select
|
||||||
|
value={editForm.languagePreference}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, languagePreference: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">Not set</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="es">Español</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-dark mb-1.5">Account Status</label>
|
||||||
|
<select
|
||||||
|
value={editForm.accountStatus}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, accountStatus: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="unclaimed">Unclaimed</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 justify-end mt-6 pt-4 border-t border-secondary-light-gray">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEditingUser(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={saving}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user