d024bf7fa3
- 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
271 lines
9.7 KiB
TypeScript
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;
|