feat: staff can edit registration responses
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -9,12 +9,77 @@ import {
|
|||||||
} from './ui/dialog';
|
} from './ui/dialog';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Card } from './ui/card';
|
import { Card } from './ui/card';
|
||||||
|
import { Checkbox } from './ui/checkbox';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
import { Textarea } from './ui/textarea';
|
||||||
import { User, Mail, Phone, Calendar, UserCheck, Clock, FileText } from 'lucide-react';
|
import { User, Mail, Phone, Calendar, UserCheck, Clock, FileText } from 'lucide-react';
|
||||||
import StatusBadge from './StatusBadge';
|
import StatusBadge from './StatusBadge';
|
||||||
|
import api from '../utils/api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
|
const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
const autoSaveTimeoutRef = useRef(null);
|
||||||
|
const pendingSaveRef = useRef(false);
|
||||||
|
|
||||||
|
const leadSourceOptions = [
|
||||||
|
'Current member',
|
||||||
|
'Friend',
|
||||||
|
'OutSmart Magazine',
|
||||||
|
'Search engine (Google etc.)',
|
||||||
|
"I've known about LOAF for a long time",
|
||||||
|
'Other'
|
||||||
|
];
|
||||||
|
|
||||||
|
const volunteerOptions = [
|
||||||
|
'Welcoming new members at events',
|
||||||
|
'Sending out birthday cards',
|
||||||
|
'Care Team Calls',
|
||||||
|
'Sharing ideas for events',
|
||||||
|
'Researching grants',
|
||||||
|
'Applying for grants',
|
||||||
|
'Assisting with TeatherLOAFers',
|
||||||
|
'Assisting with ActiveLOAFers',
|
||||||
|
'Assisting with weekday Lunch Bunch',
|
||||||
|
'Uploading Photos to the Website',
|
||||||
|
'Assisting with eNewsletter',
|
||||||
|
'Other administrative task'
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !user) return;
|
||||||
|
const nextFormData = {
|
||||||
|
lead_sources: Array.isArray(user.lead_sources) ? user.lead_sources : [],
|
||||||
|
partner_first_name: user.partner_first_name || '',
|
||||||
|
partner_last_name: user.partner_last_name || '',
|
||||||
|
partner_is_member: Boolean(user.partner_is_member),
|
||||||
|
partner_plan_to_become_member: Boolean(user.partner_plan_to_become_member),
|
||||||
|
newsletter_publish_name: Boolean(user.newsletter_publish_name),
|
||||||
|
newsletter_publish_photo: Boolean(user.newsletter_publish_photo),
|
||||||
|
newsletter_publish_birthday: Boolean(user.newsletter_publish_birthday),
|
||||||
|
newsletter_publish_none: Boolean(user.newsletter_publish_none),
|
||||||
|
referred_by_member_name: user.referred_by_member_name || '',
|
||||||
|
volunteer_interests: Array.isArray(user.volunteer_interests) ? user.volunteer_interests : [],
|
||||||
|
scholarship_requested: Boolean(user.scholarship_requested),
|
||||||
|
scholarship_reason: user.scholarship_reason || ''
|
||||||
|
};
|
||||||
|
setFormData(nextFormData);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
}, [open, user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (autoSaveTimeoutRef.current) {
|
||||||
|
clearTimeout(autoSaveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
if (!dateString) return '—';
|
if (!dateString) return '—';
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
@@ -44,6 +109,91 @@ const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
|
|||||||
return phone;
|
return phone;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveProfile = async (showToast = true) => {
|
||||||
|
if (!formData) return;
|
||||||
|
if (isSaving) {
|
||||||
|
pendingSaveRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await api.put('/users/profile', {
|
||||||
|
lead_sources: formData.lead_sources,
|
||||||
|
partner_first_name: formData.partner_first_name,
|
||||||
|
partner_last_name: formData.partner_last_name,
|
||||||
|
partner_is_member: formData.partner_is_member,
|
||||||
|
partner_plan_to_become_member: formData.partner_plan_to_become_member,
|
||||||
|
newsletter_publish_name: formData.newsletter_publish_name,
|
||||||
|
newsletter_publish_photo: formData.newsletter_publish_photo,
|
||||||
|
newsletter_publish_birthday: formData.newsletter_publish_birthday,
|
||||||
|
newsletter_publish_none: formData.newsletter_publish_none,
|
||||||
|
referred_by_member_name: formData.referred_by_member_name,
|
||||||
|
volunteer_interests: formData.volunteer_interests,
|
||||||
|
scholarship_requested: formData.scholarship_requested,
|
||||||
|
scholarship_reason: formData.scholarship_reason
|
||||||
|
});
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
if (showToast) {
|
||||||
|
toast.success('Registration details saved');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (showToast) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to save registration details');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
if (pendingSaveRef.current) {
|
||||||
|
pendingSaveRef.current = false;
|
||||||
|
saveProfile(showToast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleAutoSave = () => {
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
if (autoSaveTimeoutRef.current) {
|
||||||
|
clearTimeout(autoSaveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
autoSaveTimeoutRef.current = setTimeout(() => {
|
||||||
|
saveProfile(false);
|
||||||
|
}, 800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => {
|
||||||
|
const next = { ...prev, [name]: value };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
scheduleAutoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = (name, checked) => {
|
||||||
|
setFormData(prev => ({ ...prev, [name]: checked }));
|
||||||
|
scheduleAutoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeadSourceChange = (source) => {
|
||||||
|
setFormData(prev => {
|
||||||
|
const sources = prev.lead_sources.includes(source)
|
||||||
|
? prev.lead_sources.filter((item) => item !== source)
|
||||||
|
: [...prev.lead_sources, source];
|
||||||
|
return { ...prev, lead_sources: sources };
|
||||||
|
});
|
||||||
|
scheduleAutoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolunteerChange = (option) => {
|
||||||
|
setFormData(prev => {
|
||||||
|
const interests = prev.volunteer_interests.includes(option)
|
||||||
|
? prev.volunteer_interests.filter((item) => item !== option)
|
||||||
|
: [...prev.volunteer_interests, option];
|
||||||
|
return { ...prev, volunteer_interests: interests };
|
||||||
|
});
|
||||||
|
scheduleAutoSave();
|
||||||
|
};
|
||||||
|
|
||||||
const InfoRow = ({ icon: Icon, label, value }) => (
|
const InfoRow = ({ icon: Icon, label, value }) => (
|
||||||
<div className="flex items-start gap-3 py-3 border-b border-[var(--neutral-800)] last:border-b-0">
|
<div className="flex items-start gap-3 py-3 border-b border-[var(--neutral-800)] last:border-b-0">
|
||||||
<div className="h-10 w-10 rounded-lg bg-[var(--lavender-400)] flex items-center justify-center flex-shrink-0">
|
<div className="h-10 w-10 rounded-lg bg-[var(--lavender-400)] flex items-center justify-center flex-shrink-0">
|
||||||
@@ -109,10 +259,214 @@ const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
|
|||||||
Registration Details
|
Registration Details
|
||||||
</h3>
|
</h3>
|
||||||
<InfoRow icon={Calendar} label="Registration Date" value={formatDate(user.created_at)} />
|
<InfoRow icon={Calendar} label="Registration Date" value={formatDate(user.created_at)} />
|
||||||
<InfoRow icon={UserCheck} label="Referred By" value={user.referred_by_member_name} />
|
<InfoRow icon={UserCheck} label="Referred By" value={formData?.referred_by_member_name} />
|
||||||
<InfoRow icon={Clock} label="Email Verification Expires" value={formatDateTime(user.email_verification_expires_at)} />
|
<InfoRow icon={Clock} label="Email Verification Expires" value={formatDateTime(user.email_verification_expires_at)} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{formData && (
|
||||||
|
<>
|
||||||
|
{/* How Did You Hear About Us */}
|
||||||
|
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
How Did You Hear About Us? *
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{leadSourceOptions.map((source) => (
|
||||||
|
<div key={source} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`lead_${source}`}
|
||||||
|
checked={formData.lead_sources.includes(source)}
|
||||||
|
onCheckedChange={() => handleLeadSourceChange(source)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`lead_${source}`} className="text-base cursor-pointer">
|
||||||
|
{source}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Partner Information */}
|
||||||
|
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Partner Information (Optional)
|
||||||
|
</h3>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="partner_first_name">Partner First Name</Label>
|
||||||
|
<Input
|
||||||
|
id="partner_first_name"
|
||||||
|
name="partner_first_name"
|
||||||
|
value={formData.partner_first_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="partner_last_name">Partner Last Name</Label>
|
||||||
|
<Input
|
||||||
|
id="partner_last_name"
|
||||||
|
name="partner_last_name"
|
||||||
|
value={formData.partner_last_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="partner_is_member"
|
||||||
|
checked={formData.partner_is_member}
|
||||||
|
onCheckedChange={(checked) => handleCheckboxChange('partner_is_member', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="partner_is_member" className="text-base cursor-pointer">
|
||||||
|
Is your partner already a member?
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="partner_plan_to_become_member"
|
||||||
|
checked={formData.partner_plan_to_become_member}
|
||||||
|
onCheckedChange={(checked) => handleCheckboxChange('partner_plan_to_become_member', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="partner_plan_to_become_member" className="text-base cursor-pointer">
|
||||||
|
Does your partner plan to become a member?
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Newsletter Preferences */}
|
||||||
|
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Newsletter Publication Preferences *
|
||||||
|
</h3>
|
||||||
|
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Please check what information may be published in LOAF Newsletter
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="newsletter_publish_name"
|
||||||
|
checked={formData.newsletter_publish_name}
|
||||||
|
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_name', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="newsletter_publish_name" className="text-base cursor-pointer">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="newsletter_publish_photo"
|
||||||
|
checked={formData.newsletter_publish_photo}
|
||||||
|
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_photo', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="newsletter_publish_photo" className="text-base cursor-pointer">
|
||||||
|
Photo (added later in profile)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="newsletter_publish_birthday"
|
||||||
|
checked={formData.newsletter_publish_birthday}
|
||||||
|
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_birthday', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="newsletter_publish_birthday" className="text-base cursor-pointer">
|
||||||
|
Birthday
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="newsletter_publish_none"
|
||||||
|
checked={formData.newsletter_publish_none}
|
||||||
|
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_none', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="newsletter_publish_none" className="text-base cursor-pointer">
|
||||||
|
Do not publish any of my information
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Referral */}
|
||||||
|
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Referral
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="referred_by_member_name">Name of a LOAF Member who already knows you</Label>
|
||||||
|
<Input
|
||||||
|
id="referred_by_member_name"
|
||||||
|
name="referred_by_member_name"
|
||||||
|
value={formData.referred_by_member_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="Enter member name or email"
|
||||||
|
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
If referred by a current member, you may skip the event attendance requirement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Volunteer Interests */}
|
||||||
|
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Volunteer Interests (Optional)
|
||||||
|
</h3>
|
||||||
|
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
I may at some time be interested in volunteering with LOAF in the following ways (training is provided)
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{volunteerOptions.map((option) => (
|
||||||
|
<div key={option} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`volunteer_${option}`}
|
||||||
|
checked={formData.volunteer_interests.includes(option)}
|
||||||
|
onCheckedChange={() => handleVolunteerChange(option)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`volunteer_${option}`} className="text-base cursor-pointer">
|
||||||
|
{option}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Scholarship Request */}
|
||||||
|
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="scholarship_requested"
|
||||||
|
checked={formData.scholarship_requested}
|
||||||
|
onCheckedChange={(checked) => handleCheckboxChange('scholarship_requested', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="scholarship_requested" className="text-base cursor-pointer font-semibold">
|
||||||
|
I am requesting for scholarship
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Scholarship information is kept confidential
|
||||||
|
</p>
|
||||||
|
{formData.scholarship_requested && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Label htmlFor="scholarship_reason">Please explain your situation *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="scholarship_reason"
|
||||||
|
name="scholarship_reason"
|
||||||
|
value={formData.scholarship_reason}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="Tell us why you're requesting a scholarship..."
|
||||||
|
rows={4}
|
||||||
|
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Additional Information (if available) */}
|
{/* Additional Information (if available) */}
|
||||||
{(user.address || user.city || user.state || user.zip_code) && (
|
{(user.address || user.city || user.state || user.zip_code) && (
|
||||||
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||||
@@ -156,6 +510,19 @@ const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
<div className="flex-1 text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{isSaving && 'Saving changes...'}
|
||||||
|
{!isSaving && hasUnsavedChanges && 'Unsaved changes'}
|
||||||
|
{!isSaving && !hasUnsavedChanges && 'All changes saved'}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveProfile(true)}
|
||||||
|
disabled={!hasUnsavedChanges || isSaving}
|
||||||
|
className="rounded-xl border-2 border-[var(--neutral-800)] bg-white text-[var(--purple-ink)] hover:bg-[var(--lavender-300)]"
|
||||||
|
>
|
||||||
|
Save All
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
|
|||||||
Reference in New Issue
Block a user