feat:add birthdate and postcode

This commit is contained in:
2026-03-07 02:46:14 +01:00
parent ac466a7f0e
commit dcf21a80e2
8 changed files with 303 additions and 75 deletions

View File

@@ -15,6 +15,8 @@ interface PerformerErrors {
lastName?: string;
email?: string;
phone?: string;
postcode?: string;
birthdate?: string;
artForm?: string;
isOver16?: string;
}
@@ -41,6 +43,8 @@ export function PerformerForm({
lastName: prefillLastName,
email: prefillEmail,
phone: "",
postcode: "",
birthdate: "",
artForm: "",
experience: "",
isOver16: false,
@@ -71,6 +75,8 @@ export function PerformerForm({
lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email),
phone: validatePhone(data.phone),
postcode: !data.postcode.trim() ? "Postcode is verplicht" : undefined,
birthdate: !data.birthdate ? "Geboortedatum is verplicht" : undefined,
artForm: !data.artForm.trim() ? "Kunstvorm is verplicht" : undefined,
isOver16: !data.isOver16
? "Je moet 16 jaar of ouder zijn om op te treden"
@@ -82,6 +88,8 @@ export function PerformerForm({
lastName: true,
email: true,
phone: true,
postcode: true,
birthdate: true,
artForm: true,
isOver16: true,
});
@@ -110,6 +118,14 @@ export function PerformerForm({
),
email: validateEmail(name === "email" ? value : data.email),
phone: validatePhone(name === "phone" ? value : data.phone),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
artForm:
name === "artForm" && !value.trim()
? "Kunstvorm is verplicht"
@@ -129,6 +145,14 @@ export function PerformerForm({
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
artForm: !value.trim() ? "Kunstvorm is verplicht" : undefined,
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
@@ -145,6 +169,8 @@ export function PerformerForm({
lastName: data.lastName.trim(),
email: data.email.trim(),
phone: data.phone.trim() || undefined,
postcode: data.postcode.trim(),
birthdate: data.birthdate,
registrationType: "performer",
artForm: data.artForm.trim() || undefined,
experience: data.experience.trim() || undefined,
@@ -306,6 +332,54 @@ export function PerformerForm({
</div>
</div>
{/* Postcode + Birthdate row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="p-postcode" className="text-white text-xl">
Postcode <span className="text-red-300">*</span>
</label>
<input
type="text"
id="p-postcode"
name="postcode"
value={data.postcode}
onChange={handleChange}
onBlur={handleBlur}
placeholder="1234 AB"
autoComplete="postal-code"
aria-required="true"
aria-invalid={touched.postcode && !!errors.postcode}
className={inputCls(!!touched.postcode && !!errors.postcode)}
/>
{touched.postcode && errors.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors.postcode}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="p-birthdate" className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</label>
<input
type="date"
id="p-birthdate"
name="birthdate"
value={data.birthdate}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="bday"
aria-invalid={touched.birthdate && !!errors.birthdate}
className={`${inputCls(!!touched.birthdate && !!errors.birthdate)} [color-scheme:dark]`}
/>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
</div>
{/* Performer-specific fields */}
<div className="border border-amber-400/20 bg-amber-400/5 p-6">
<p className="mb-5 text-amber-300/80 text-sm uppercase tracking-wider">

View File

@@ -21,6 +21,8 @@ interface WatcherErrors {
lastName?: string;
email?: string;
phone?: string;
postcode?: string;
birthdate?: string;
}
interface Props {
@@ -251,6 +253,8 @@ export function WatcherForm({
lastName: prefillLastName,
email: prefillEmail,
phone: "",
postcode: "",
birthdate: "",
extraQuestions: "",
});
const [errors, setErrors] = useState<WatcherErrors>({});
@@ -306,6 +310,8 @@ export function WatcherForm({
lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email),
phone: validatePhone(data.phone),
postcode: !data.postcode.trim() ? "Postcode is verplicht" : undefined,
birthdate: !data.birthdate ? "Geboortedatum is verplicht" : undefined,
};
setErrors(fieldErrs);
setTouched({
@@ -313,6 +319,8 @@ export function WatcherForm({
lastName: true,
email: true,
phone: true,
postcode: true,
birthdate: true,
});
const { errors: gErrs, valid: gValid } = validateGuests(guests);
setGuestErrors(gErrs);
@@ -330,6 +338,14 @@ export function WatcherForm({
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
}
@@ -345,6 +361,14 @@ export function WatcherForm({
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
}
@@ -386,6 +410,8 @@ export function WatcherForm({
lastName: data.lastName.trim(),
email: data.email.trim(),
phone: data.phone.trim() || undefined,
postcode: data.postcode.trim(),
birthdate: data.birthdate,
registrationType: "watcher",
guests: guests.map((g) => ({
firstName: g.firstName.trim(),
@@ -585,6 +611,54 @@ export function WatcherForm({
</div>
</div>
{/* Postcode + Birthdate row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="w-postcode" className="text-white text-xl">
Postcode <span className="text-red-300">*</span>
</label>
<input
type="text"
id="w-postcode"
name="postcode"
value={data.postcode}
onChange={handleChange}
onBlur={handleBlur}
placeholder="1234 AB"
autoComplete="postal-code"
aria-required="true"
aria-invalid={touched.postcode && !!errors.postcode}
className={inputCls(!!touched.postcode && !!errors.postcode)}
/>
{touched.postcode && errors.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors.postcode}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="w-birthdate" className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</label>
<input
type="date"
id="w-birthdate"
name="birthdate"
value={data.birthdate}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="bday"
aria-invalid={touched.birthdate && !!errors.birthdate}
className={`${inputCls(!!touched.birthdate && !!errors.birthdate)} [color-scheme:dark]`}
/>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
</div>
{/* Guests */}
<GuestList
guests={guests}

View File

@@ -581,79 +581,6 @@ function AdminPage() {
<CardDescription className="text-pink-300/70 text-xs sm:text-sm">
Vrijwillige Gifts
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-white sm:text-3xl lg:text-4xl">
{stats?.today ?? 0}
</CardTitle>
</CardHeader>
<CardContent>
<span className="text-white/40 text-xs sm:text-sm">
Nieuwe registraties vandaag
</span>
</CardContent>
</Card>
<Card className="border-amber-400/20 bg-amber-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-amber-300/70">
Artiesten
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-amber-300 sm:text-3xl lg:text-4xl">
{performerCount}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{stats?.byArtForm.slice(0, 2).map((item) => (
<div
key={item.artForm}
className="flex items-center justify-between text-xs"
>
<span className="truncate text-amber-300/70">
{item.artForm || "Onbekend"}
</span>
<span className="text-amber-300">{item.count}</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-teal-400/20 bg-teal-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-teal-300/70">
Bezoekers
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-teal-300 sm:text-3xl lg:text-4xl">
{watcherCount}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-xs">
{totalGuestCount > 0 && (
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Inclusief gasten</span>
<span className="text-teal-300">+{totalGuestCount}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Totaal aanwezig</span>
<span className="font-semibold text-teal-300">
{totalWatcherAttendees}
</span>
</div>
<div className="hidden sm:flex sm:items-center sm:justify-between">
<span className="text-teal-300/70">Drinkkaart</span>
<span className="text-teal-300">{totalDrinkCardValue}</span>
</div>
</div>
</CardContent>
</Card>
<Card className="border-pink-400/20 bg-pink-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-pink-300/70">
Vrijwillige Gifts
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-pink-300 sm:text-3xl lg:text-4xl">
{Math.round(totalGiftRevenue / 100)}
</CardTitle>
@@ -833,6 +760,21 @@ function AdminPage() {
<th className={thClass} onClick={() => handleSort("datum")}>
Datum <SortIcon col="datum" />
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Postcode
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Geboortedatum
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Ervaring
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
16+
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Opmerkingen
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Link
</th>
@@ -842,7 +784,7 @@ function AdminPage() {
{registrationsQuery.isLoading ? (
<tr>
<td
colSpan={10}
colSpan={15}
className="px-4 py-8 text-center text-white/60"
>
Laden...
@@ -851,7 +793,7 @@ function AdminPage() {
) : sortedRegistrations.length === 0 ? (
<tr>
<td
colSpan={10}
colSpan={15}
className="px-4 py-8 text-center text-white/60"
>
Geen registraties gevonden
@@ -946,6 +888,34 @@ function AdminPage() {
<td className="px-4 py-3 text-sm text-white/50 tabular-nums">
{dateLabel}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{reg.postcode || "-"}
</td>
<td className="px-4 py-3 text-sm text-white/70 tabular-nums">
{reg.birthdate || "-"}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{isPerformer ? reg.experience || "-" : "-"}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{isPerformer ? (
reg.isOver16 ? (
<span className="text-green-400">Ja</span>
) : (
<span className="text-red-400">Nee</span>
)
) : (
"-"
)}
</td>
<td className="max-w-[200px] px-4 py-3 text-sm text-white/60">
<span
className="block truncate"
title={reg.extraQuestions ?? undefined}
>
{reg.extraQuestions || "-"}
</span>
</td>
<td className="px-4 py-3">
{reg.managementToken ? (
<button
@@ -1102,6 +1072,44 @@ function AdminPage() {
)}
</div>
)}
{reg.postcode && (
<div className="text-white/70">
<span className="text-white/40">Postcode:</span>{" "}
{reg.postcode}
</div>
)}
{reg.birthdate && (
<div className="text-white/70">
<span className="text-white/40">
Geboortedatum:
</span>{" "}
{reg.birthdate}
</div>
)}
{isPerformer && reg.experience && (
<div className="text-white/70">
<span className="text-white/40">Ervaring:</span>{" "}
{reg.experience}
</div>
)}
{isPerformer && (
<div className="text-white/70">
<span className="text-white/40">16+:</span>{" "}
{reg.isOver16 ? (
<span className="text-green-400">Ja</span>
) : (
<span className="text-red-400">Nee</span>
)}
</div>
)}
{reg.extraQuestions && (
<div className="w-full text-white/60">
<span className="text-white/40">
Opmerkingen:
</span>{" "}
{reg.extraQuestions}
</div>
)}
<div className="text-white/50">{dateLabel}</div>
</div>
</div>

View File

@@ -117,6 +117,8 @@ interface EditFormProps {
lastName: string;
email: string;
phone: string | null;
postcode: string | null;
birthdate: string | null;
registrationType: string;
artForm: string | null;
experience: string | null;
@@ -140,6 +142,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: initialData.lastName,
email: initialData.email,
phone: initialData.phone ?? "",
postcode: initialData.postcode ?? "",
birthdate: initialData.birthdate ?? "",
registrationType: initialType,
artForm: initialData.artForm ?? "",
experience: initialData.experience ?? "",
@@ -179,6 +183,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: formData.lastName.trim(),
email: formData.email.trim(),
phone: formData.phone.trim() || undefined,
postcode: formData.postcode.trim() || "",
birthdate: formData.birthdate || "",
registrationType: formData.registrationType,
artForm: performer ? formData.artForm.trim() || undefined : undefined,
experience: performer
@@ -271,6 +277,42 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
/>
</div>
{/* Postcode + Birthdate */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label htmlFor="postcode" className="mb-2 block text-white">
Postcode *
</label>
<input
id="postcode"
type="text"
required
value={formData.postcode}
onChange={(e) =>
setFormData((p) => ({ ...p, postcode: e.target.value }))
}
autoComplete="postal-code"
className={inputCls(false)}
/>
</div>
<div>
<label htmlFor="birthdate" className="mb-2 block text-white">
Geboortedatum *
</label>
<input
id="birthdate"
type="date"
required
value={formData.birthdate}
onChange={(e) =>
setFormData((p) => ({ ...p, birthdate: e.target.value }))
}
autoComplete="bday"
className={`${inputCls(false)} [color-scheme:dark]`}
/>
</div>
</div>
{/* Registration type toggle */}
<div>
<p className="mb-3 text-white">Type inschrijving</p>
@@ -626,6 +668,14 @@ function ManageRegistrationPage() {
<p className="text-sm text-white/50">Telefoon</p>
<p className="text-lg text-white">{data.phone || "—"}</p>
</div>
<div>
<p className="text-sm text-white/50">Postcode</p>
<p className="text-lg text-white">{data.postcode || "—"}</p>
</div>
<div>
<p className="text-sm text-white/50">Geboortedatum</p>
<p className="text-lg text-white">{data.birthdate || "—"}</p>
</div>
</div>
{isPerformer && (

View File

@@ -225,6 +225,8 @@ const coreRegistrationFields = {
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
postcode: z.string().min(1),
birthdate: z.string().min(1),
registrationType: registrationTypeSchema.default("watcher"),
artForm: z.string().optional(),
experience: z.string().optional(),
@@ -277,6 +279,8 @@ export const appRouter = {
lastName: input.lastName,
email: input.email,
phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null,
@@ -364,6 +368,8 @@ export const appRouter = {
lastName: input.lastName,
email: input.email,
phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null,
@@ -515,6 +521,8 @@ export const appRouter = {
"Last Name",
"Email",
"Phone",
"Postcode",
"Birthdate",
"Type",
"Art Form",
"Experience",
@@ -543,6 +551,8 @@ export const appRouter = {
r.lastName,
r.email,
r.phone || "",
r.postcode || "",
r.birthdate || "",
r.registrationType,
r.artForm || "",
r.experience || "",

View File

@@ -0,0 +1,2 @@
ALTER TABLE registration ADD COLUMN postcode TEXT;
ALTER TABLE registration ADD COLUMN birthdate TEXT;

View File

@@ -29,6 +29,13 @@
"when": 1772530000000,
"tag": "0003_add_guests",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1741388400000,
"tag": "0008_add_postcode_birthdate",
"breakpoints": true
}
]
}

View File

@@ -21,6 +21,9 @@ export const registration = sqliteTable(
drinkCardValue: integer("drink_card_value").default(0),
// Guests: JSON array of {firstName, lastName, email?, phone?} objects
guests: text("guests"),
// Contact / demographic
postcode: text("postcode"),
birthdate: text("birthdate"),
// Shared
extraQuestions: text("extra_questions"),
managementToken: text("management_token").unique(),