Files
sasa-membership/frontend/src/components/ProfileQuestionsForm.tsx
T
nathanb d024bf7fa3 stuff changed:
- ui has been made 'kinda better' (after making it worse for a while lol
- ESP rfid readers are now supported [ill upload the code for them in another repo later]
- admin system has been secured a bit better and seems to be working well
2026-05-08 20:46:58 +01:00

271 lines
9.7 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { ProfileAnswerInput, ProfileQuestionForUser } from '../services/membershipService';
import {
answerToComparable,
canEditProfileQuestion,
isProfileQuestionVisible,
ProfileQuestionAnswerValue
} from '../utils/profileQuestionLogic';
interface ProfileQuestionsFormProps {
title: string;
description?: string;
questions: ProfileQuestionForUser[];
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
saveLabel?: string;
allowAdminManagedEdit?: boolean;
surface?: 'member' | 'admin';
}
const formatAnswerForDisplay = (question: ProfileQuestionForUser, value: ProfileQuestionAnswerValue): string => {
if (value === null || value === undefined || value === '') return 'Not set';
if (question.input_type === 'boolean') return value === true || value === 'true' ? 'Yes' : 'No';
if (question.input_type === 'select') {
const matchingOption = question.options.find((option) => option.value === String(value));
return matchingOption?.label || String(value);
}
return String(value);
};
const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
title,
description,
questions,
onSave,
saveLabel = 'Save Answers',
allowAdminManagedEdit = false,
surface = 'admin'
}) => {
const initialAnswers = useMemo(() => {
const values: Record<number, ProfileQuestionAnswerValue> = {};
questions.forEach((question) => {
values[question.id] = question.answer ?? null;
});
return values;
}, [questions]);
const [answers, setAnswers] = useState<Record<number, ProfileQuestionAnswerValue>>(initialAnswers);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const pageSize = 10;
useEffect(() => {
setAnswers(initialAnswers);
setSuccessMessage(null);
setError(null);
}, [initialAnswers]);
const visibleQuestions = useMemo(() => {
const byId = new Map<number, ProfileQuestionForUser>();
questions.forEach((question) => byId.set(question.id, question));
return questions.filter((question) => isProfileQuestionVisible(question, byId, answers));
}, [questions, answers]);
const filteredQuestions = useMemo(() => {
const searchTerm = search.trim().toLowerCase();
return visibleQuestions.filter((question) => {
if (!searchTerm) return true;
return (
question.label.toLowerCase().includes(searchTerm) ||
question.key.toLowerCase().includes(searchTerm) ||
(question.help_text || '').toLowerCase().includes(searchTerm)
);
});
}, [visibleQuestions, search]);
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
const paginatedQuestions = useMemo(() => {
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
return filteredQuestions.slice(start, start + pageSize);
}, [filteredQuestions, page, totalPages]);
const setAnswerValue = (questionId: number, value: ProfileQuestionAnswerValue) => {
setAnswers((prev) => ({ ...prev, [questionId]: value }));
setSuccessMessage(null);
setError(null);
};
useEffect(() => {
setPage(1);
}, [search]);
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccessMessage(null);
try {
const visibleQuestionIds = new Set(visibleQuestions.map((question) => question.id));
const changedAnswers: ProfileAnswerInput[] = questions
.filter((question) => canEditProfileQuestion(question, allowAdminManagedEdit) && visibleQuestionIds.has(question.id))
.filter((question) => answerToComparable(answers[question.id] ?? null) !== answerToComparable(initialAnswers[question.id] ?? null))
.map((question) => ({
question_id: question.id,
value: answers[question.id] ?? null
}));
await onSave(changedAnswers);
setSuccessMessage(changedAnswers.length > 0 ? 'Saved successfully.' : 'No changes to save.');
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to save answers.');
} finally {
setSaving(false);
}
};
const renderField = (question: ProfileQuestionForUser) => {
const value = answers[question.id] ?? null;
const disabled = !canEditProfileQuestion(question, allowAdminManagedEdit) || saving;
if (disabled && !saving) {
return <div className="profile-question-readonly">{formatAnswerForDisplay(question, value)}</div>;
}
if (question.input_type === 'boolean') {
return (
<select
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : event.target.value === 'true')}
disabled={disabled}
className="profile-question-input"
>
<option value="">Prefer not to say</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
);
}
if (question.input_type === 'select') {
return (
<select
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
className="profile-question-input"
>
<option value="">Select an option</option>
{question.options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
);
}
if (question.input_type === 'date') {
return (
<input
type="date"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
className="profile-question-input"
/>
);
}
if (question.input_type === 'number') {
return (
<input
type="number"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : Number(event.target.value))}
disabled={disabled}
placeholder={question.placeholder || ''}
className="profile-question-input"
/>
);
}
return (
<input
type="text"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
placeholder={question.placeholder || ''}
className="profile-question-input"
/>
);
};
return (
<div className={`card profile-questions-form ${surface === 'member' ? 'member-surface' : 'admin-surface'}`}>
<h3 className="profile-questions-title">{title}</h3>
{description && <p className="profile-questions-description">{description}</p>}
<div className="profile-questions-search">
<input
type="text"
placeholder="Search questions..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="profile-question-input"
/>
</div>
{error && <div className="alert alert-error">{error}</div>}
{successMessage && <div className="alert alert-success">{successMessage}</div>}
{filteredQuestions.length === 0 ? (
<p className="profile-questions-empty">No questions available.</p>
) : (
<div className="profile-questions-list">
{paginatedQuestions.map((question) => (
<div
key={question.id}
className={`profile-question-row ${surface === 'member' ? 'profile-question-row-member' : 'profile-question-row-admin'}`}
>
<div className="profile-question-meta">
<label className="profile-question-label">
{question.label}
{question.is_required && <span className="profile-question-required"> *</span>}
{question.admin_only_edit && <span className="admin-inline-badge">Admin Managed</span>}
</label>
{question.help_text && (
<p className="profile-question-help">{question.help_text}</p>
)}
</div>
<div className="profile-question-answer">{renderField(question)}</div>
{!canEditProfileQuestion(question, allowAdminManagedEdit) && (
<p className="profile-question-lock-note">
This field can only be changed by an admin.
</p>
)}
</div>
))}
</div>
)}
{filteredQuestions.length > pageSize && (
<div className="profile-questions-pagination">
<span className="profile-questions-page-copy">
Page {Math.min(page, totalPages)} of {totalPages} ({filteredQuestions.length} questions)
</span>
<div className="profile-questions-pager-buttons">
<button className="btn btn-secondary profile-questions-pager-button" disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
Previous
</button>
<button className="btn btn-secondary profile-questions-pager-button" disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
Next
</button>
</div>
</div>
)}
<div className="profile-questions-actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : saveLabel}
</button>
</div>
</div>
);
};
export default ProfileQuestionsForm;