Add Guest List & Quota Assignment UI - Complete admin interface
This commit is contained in:
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
|
||||||
import EventsPage from './pages/admin/EventsPage';
|
import EventsPage from './pages/admin/EventsPage';
|
||||||
import EventDetailPage from './pages/admin/EventDetailPage';
|
import EventDetailPage from './pages/admin/EventDetailPage';
|
||||||
|
import GroupDetailPage from './pages/admin/GroupDetailPage';
|
||||||
import ScannerPage from './pages/staff/ScannerPage';
|
import ScannerPage from './pages/staff/ScannerPage';
|
||||||
import GuestQrPage from './pages/guest/GuestQrPage';
|
import GuestQrPage from './pages/guest/GuestQrPage';
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<EventsPage />} />
|
<Route path="/" element={<EventsPage />} />
|
||||||
<Route path="/events/:id" element={<EventDetailPage />} />
|
<Route path="/events/:id" element={<EventDetailPage />} />
|
||||||
|
<Route path="/groups/:id" element={<GroupDetailPage />} />
|
||||||
<Route path="/staff" element={<ScannerPage />} />
|
<Route path="/staff" element={<ScannerPage />} />
|
||||||
<Route path="/guest" element={<GuestQrPage />} />
|
<Route path="/guest" element={<GuestQrPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -50,15 +50,16 @@ export const useCreatePerson = (groupId: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAssignQuota = (personId: string) => {
|
export const useAssignQuota = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (data: AssignQuotaRequest) => {
|
mutationFn: async ({ personId, data }: { personId: string; data: AssignQuotaRequest }) => {
|
||||||
const response = await peopleApi.assignQuota(personId, data);
|
const response = await peopleApi.assignQuota(personId, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['people', personId] });
|
queryClient.invalidateQueries({ queryKey: ['people', variables.personId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['groups'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useEvent } from '../../hooks/useEvents';
|
import { useEvent } from '../../hooks/useEvents';
|
||||||
import { useGroups, useCreateGroup, useCreatePerson } from '../../hooks/useGroups';
|
import { useGroups, useCreateGroup } from '../../hooks/useGroups';
|
||||||
import { useProducts, useCreateProduct } from '../../hooks/useProducts';
|
import { useProducts, useCreateProduct } from '../../hooks/useProducts';
|
||||||
import { ProductType, type CreateGroupRequest, type CreatePersonRequest, type CreateProductRequest } from '../../lib/types';
|
import { ProductType, type CreateGroupRequest, type CreateProductRequest } from '../../lib/types';
|
||||||
|
|
||||||
export default function EventDetailPage() {
|
export default function EventDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data: event } = useEvent(id!);
|
const { data: event } = useEvent(id!);
|
||||||
const { data: groups } = useGroups(id!);
|
const { data: groups } = useGroups(id!);
|
||||||
const { data: products } = useProducts(id!);
|
const { data: products } = useProducts(id!);
|
||||||
@@ -91,7 +92,11 @@ export default function EventDetailPage() {
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{groups?.map((group) => (
|
{groups?.map((group) => (
|
||||||
<div key={group.id} className="bg-white p-4 rounded-lg shadow">
|
<div
|
||||||
|
key={group.id}
|
||||||
|
onClick={() => navigate(`/groups/${group.id}`)}
|
||||||
|
className="bg-white p-4 rounded-lg shadow cursor-pointer hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
<h3 className="font-semibold text-lg">{group.name}</h3>
|
<h3 className="font-semibold text-lg">{group.name}</h3>
|
||||||
{group.contactPersonName && (
|
{group.contactPersonName && (
|
||||||
<p className="text-sm text-gray-600">{group.contactPersonName}</p>
|
<p className="text-sm text-gray-600">{group.contactPersonName}</p>
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useGroup, useCreatePerson, useAssignQuota } from '../../hooks/useGroups';
|
||||||
|
import { useProducts } from '../../hooks/useProducts';
|
||||||
|
import type { CreatePersonRequest, Person } from '../../lib/types';
|
||||||
|
|
||||||
|
export default function GroupDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { data: group, refetch } = useGroup(id!);
|
||||||
|
const createPerson = useCreatePerson(id!);
|
||||||
|
const assignQuota = useAssignQuota();
|
||||||
|
|
||||||
|
const [showPersonForm, setShowPersonForm] = useState(false);
|
||||||
|
const [personForm, setPersonForm] = useState<CreatePersonRequest>({ name: '' });
|
||||||
|
const [selectedPerson, setSelectedPerson] = useState<Person | null>(null);
|
||||||
|
const [quotaProductId, setQuotaProductId] = useState('');
|
||||||
|
const [quotaAmount, setQuotaAmount] = useState(1);
|
||||||
|
|
||||||
|
// Get event ID from group data
|
||||||
|
const eventId = group?.eventId || '';
|
||||||
|
const { data: products } = useProducts(eventId);
|
||||||
|
|
||||||
|
const handleCreatePerson = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await createPerson.mutateAsync(personForm);
|
||||||
|
setPersonForm({ name: '' });
|
||||||
|
setShowPersonForm(false);
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssignQuota = async (personId: string) => {
|
||||||
|
if (!quotaProductId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assignQuota.mutateAsync({
|
||||||
|
personId,
|
||||||
|
data: {
|
||||||
|
productId: quotaProductId,
|
||||||
|
initialAmount: quotaAmount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
alert('Quota assigned successfully!');
|
||||||
|
setQuotaProductId('');
|
||||||
|
setQuotaAmount(1);
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to assign quota');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!group) return <div className="p-8">Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">{group.name}</h1>
|
||||||
|
{group.contactPersonName && (
|
||||||
|
<p className="text-gray-600 mt-2">Contact: {group.contactPersonName}</p>
|
||||||
|
)}
|
||||||
|
{group.contactEmail && (
|
||||||
|
<p className="text-gray-600">{group.contactEmail}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-2xl font-semibold">People ({group.people?.length || 0})</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPersonForm(!showPersonForm)}
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{showPersonForm ? 'Cancel' : 'Add Person'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPersonForm && (
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Add New Person</h3>
|
||||||
|
<form onSubmit={handleCreatePerson} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={personForm.name}
|
||||||
|
onChange={(e) => setPersonForm({ ...personForm, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={personForm.email || ''}
|
||||||
|
onChange={(e) => setPersonForm({ ...personForm, email: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Phone Number</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={personForm.phoneNumber || ''}
|
||||||
|
onChange={(e) => setPersonForm({ ...personForm, phoneNumber: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createPerson.isPending}
|
||||||
|
className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createPerson.isPending ? 'Adding...' : 'Add Person'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{group.people?.map((person) => (
|
||||||
|
<div key={person.id} className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{person.name}</h3>
|
||||||
|
{person.email && <p className="text-sm text-gray-600">{person.email}</p>}
|
||||||
|
{person.phoneNumber && <p className="text-sm text-gray-600">{person.phoneNumber}</p>}
|
||||||
|
<p className="text-xs text-gray-500 mt-2 font-mono">QR: {person.qrCode}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPerson(selectedPerson?.id === person.id ? null : person)}
|
||||||
|
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{selectedPerson?.id === person.id ? 'Close' : 'Assign Quota'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{person.quotas && person.quotas.length > 0 && (
|
||||||
|
<div className="border-t pt-4 mt-4">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 mb-2">Current Quotas</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{person.quotas.map((quota) => (
|
||||||
|
<div key={quota.productId} className="flex justify-between text-sm">
|
||||||
|
<span>{quota.productName}</span>
|
||||||
|
<span className="font-semibold">{quota.remainingAmount}/{quota.initialAmount}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedPerson?.id === person.id && (
|
||||||
|
<div className="border-t pt-4 mt-4">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 mb-3">Assign New Quota</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<select
|
||||||
|
value={quotaProductId}
|
||||||
|
onChange={(e) => setQuotaProductId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Select Product</option>
|
||||||
|
{products?.map((product) => (
|
||||||
|
<option key={product.id} value={product.id}>
|
||||||
|
{product.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={quotaAmount}
|
||||||
|
onChange={(e) => setQuotaAmount(parseInt(e.target.value))}
|
||||||
|
placeholder="Amount"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAssignQuota(person.id)}
|
||||||
|
disabled={!quotaProductId || assignQuota.isPending}
|
||||||
|
className="w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
{assignQuota.isPending ? 'Assigning...' : 'Assign Quota'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(!group.people || group.people.length === 0) && !showPersonForm && (
|
||||||
|
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-gray-500">No people in this group yet.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPersonForm(true)}
|
||||||
|
className="mt-4 text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
Add the first person
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user