fix
This commit is contained in:
@@ -4,6 +4,7 @@ import React, { useRef, useState, useCallback } from 'react';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Select, { MultiValue, StylesConfig } from 'react-select';
|
import Select, { MultiValue, StylesConfig } from 'react-select';
|
||||||
import { ProfileField } from '@/services/profile-sections.service';
|
import { ProfileField } from '@/services/profile-sections.service';
|
||||||
|
import { agentsService } from '@/services/agents.service';
|
||||||
import { FormInput } from './FormInput';
|
import { FormInput } from './FormInput';
|
||||||
import { FormCheckbox } from './FormCheckbox';
|
import { FormCheckbox } from './FormCheckbox';
|
||||||
import { FormTextarea } from './FormTextarea';
|
import { FormTextarea } from './FormTextarea';
|
||||||
@@ -157,6 +158,7 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
|||||||
return (
|
return (
|
||||||
<FileUpload
|
<FileUpload
|
||||||
field={field}
|
field={field}
|
||||||
|
fieldSlug={field.slug}
|
||||||
value={(value as UploadedFile[]) || []}
|
value={(value as UploadedFile[]) || []}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
@@ -449,11 +451,12 @@ interface UploadingFile {
|
|||||||
// File Upload Component
|
// File Upload Component
|
||||||
interface FileUploadProps {
|
interface FileUploadProps {
|
||||||
field: ProfileField;
|
field: ProfileField;
|
||||||
|
fieldSlug: string;
|
||||||
value: UploadedFile[];
|
value: UploadedFile[];
|
||||||
onChange: (value: UploadedFile[]) => void;
|
onChange: (value: UploadedFile[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileUpload({ field, value, onChange }: FileUploadProps) {
|
function FileUpload({ field, fieldSlug, value, onChange }: FileUploadProps) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
|
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
|
||||||
@@ -501,7 +504,20 @@ function FileUpload({ field, value, onChange }: FileUploadProps) {
|
|||||||
|
|
||||||
// Remove from uploading and add to uploaded
|
// Remove from uploading and add to uploaded
|
||||||
setUploadingFiles(prev => prev.filter(f => f.id !== tempId));
|
setUploadingFiles(prev => prev.filter(f => f.id !== tempId));
|
||||||
onChange([...value, uploadedFile]);
|
const newFiles = [...value, uploadedFile];
|
||||||
|
onChange(newFiles);
|
||||||
|
|
||||||
|
// Auto-save to database immediately
|
||||||
|
try {
|
||||||
|
await agentsService.saveFieldValues([{
|
||||||
|
fieldSlug: fieldSlug,
|
||||||
|
value: newFiles as unknown as Record<string, unknown>[],
|
||||||
|
}]);
|
||||||
|
} catch (saveError) {
|
||||||
|
console.error('Failed to save file to database:', saveError);
|
||||||
|
// File is still in S3 and displayed, but database save failed
|
||||||
|
// User can manually save later via Save button
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload failed:', error);
|
console.error('Upload failed:', error);
|
||||||
setUploadingFiles(prev =>
|
setUploadingFiles(prev =>
|
||||||
@@ -582,7 +598,18 @@ function FileUpload({ field, value, onChange }: FileUploadProps) {
|
|||||||
// Continue to remove from local state even if server delete fails
|
// Continue to remove from local state even if server delete fails
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(value.filter(f => f.id !== fileId));
|
const newFiles = value.filter(f => f.id !== fileId);
|
||||||
|
onChange(newFiles);
|
||||||
|
|
||||||
|
// Auto-save to database immediately
|
||||||
|
try {
|
||||||
|
await agentsService.saveFieldValues([{
|
||||||
|
fieldSlug: fieldSlug,
|
||||||
|
value: newFiles as unknown as Record<string, unknown>[],
|
||||||
|
}]);
|
||||||
|
} catch (saveError) {
|
||||||
|
console.error('Failed to save file removal to database:', saveError);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveUploadingFile = (tempId: string) => {
|
const handleRemoveUploadingFile = (tempId: string) => {
|
||||||
|
|||||||
275
src/app/(agent)/agent/edit/components/RepeatableSection.tsx
Normal file
275
src/app/(agent)/agent/edit/components/RepeatableSection.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { ProfileSection } from '@/services/profile-sections.service';
|
||||||
|
import DynamicField from './DynamicField';
|
||||||
|
|
||||||
|
// Map section slugs to icon paths
|
||||||
|
const sectionIcons: Record<string, string> = {
|
||||||
|
'basic-information': '/assets/icons/basic-information-icon.svg',
|
||||||
|
'contact-information': '/assets/icons/email-orange-icon.svg',
|
||||||
|
'location': '/assets/icons/location-orange-icon.svg',
|
||||||
|
'professional-info': '/assets/icons/certification-icon.svg',
|
||||||
|
'specialization': '/assets/icons/home-icon.svg',
|
||||||
|
'experience': '/assets/icons/star-icon.svg',
|
||||||
|
'availability': '/assets/icons/availability-clock-icon.svg',
|
||||||
|
'biography': '/assets/icons/bio-icon.svg',
|
||||||
|
'attached-documents': '/assets/icons/attachment-icon.svg',
|
||||||
|
'hobbies': '/assets/icons/heart-icon.svg',
|
||||||
|
'testimonials': '/assets/icons/quote-icon.svg',
|
||||||
|
'certification': '/assets/icons/certification-icon.svg',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map icon names (from database) to icon paths
|
||||||
|
const iconNameToPath: Record<string, string> = {
|
||||||
|
'star': '/assets/icons/star-icon.svg',
|
||||||
|
'home': '/assets/icons/home-icon.svg',
|
||||||
|
'location': '/assets/icons/location-orange-icon.svg',
|
||||||
|
'heart': '/assets/icons/heart-icon.svg',
|
||||||
|
'clock': '/assets/icons/availability-clock-icon.svg',
|
||||||
|
'user': '/assets/icons/basic-information-icon.svg',
|
||||||
|
'email': '/assets/icons/email-orange-icon.svg',
|
||||||
|
'phone': '/assets/icons/phone-orange-icon.svg',
|
||||||
|
'document': '/assets/icons/attachment-icon.svg',
|
||||||
|
'certificate': '/assets/icons/certification-icon.svg',
|
||||||
|
'certification': '/assets/icons/certification-icon.svg',
|
||||||
|
'quote': '/assets/icons/quote-icon.svg',
|
||||||
|
'bio': '/assets/icons/bio-icon.svg',
|
||||||
|
'professional': '/assets/icons/professional-icon.svg',
|
||||||
|
'wallet': '/assets/icons/wallet-icon.svg',
|
||||||
|
'chart': '/assets/icons/chart-icon.svg',
|
||||||
|
'dollar': '/assets/icons/dollar-icon.svg',
|
||||||
|
'calendar': '/assets/icons/calendar-icon.svg',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default icon for sections without a specific icon
|
||||||
|
const defaultIcon = '/assets/icons/professional-icon.svg';
|
||||||
|
|
||||||
|
// Helper to check if a string is an emoji
|
||||||
|
const isEmojiString = (str: string): boolean => {
|
||||||
|
const emojiRegex = /^[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]/u;
|
||||||
|
return emojiRegex.test(str);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type for a single entry's data
|
||||||
|
export type RepeatableEntryData = Record<string, unknown>;
|
||||||
|
|
||||||
|
interface RepeatableSectionProps {
|
||||||
|
section: ProfileSection;
|
||||||
|
entries: RepeatableEntryData[];
|
||||||
|
onChange: (sectionSlug: string, entries: RepeatableEntryData[]) => void;
|
||||||
|
errors?: Record<string, Record<string, string>>; // errors[entryIndex][fieldSlug]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RepeatableSection({
|
||||||
|
section,
|
||||||
|
entries,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
}: RepeatableSectionProps) {
|
||||||
|
const [expandedEntries, setExpandedEntries] = useState<Set<number>>(new Set([0]));
|
||||||
|
|
||||||
|
// Determine icon source
|
||||||
|
let iconPath: string = defaultIcon;
|
||||||
|
let isEmoji = false;
|
||||||
|
|
||||||
|
if (section.icon) {
|
||||||
|
if (section.icon.startsWith('/') || section.icon.startsWith('http')) {
|
||||||
|
iconPath = section.icon;
|
||||||
|
} else if (isEmojiString(section.icon)) {
|
||||||
|
isEmoji = true;
|
||||||
|
} else if (iconNameToPath[section.icon.toLowerCase()]) {
|
||||||
|
iconPath = iconNameToPath[section.icon.toLowerCase()];
|
||||||
|
} else {
|
||||||
|
iconPath = sectionIcons[section.slug] || defaultIcon;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
iconPath = sectionIcons[section.slug] || defaultIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort fields by sortOrder and filter active, non-search-only fields
|
||||||
|
const sortedFields = [...(section.fields || [])].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
const activeFields = sortedFields.filter(f => f.isActive && !f.isSearchableOnly);
|
||||||
|
|
||||||
|
if (activeFields.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure at least one empty entry exists
|
||||||
|
const displayEntries = entries.length > 0 ? entries : [{}];
|
||||||
|
|
||||||
|
const handleAddEntry = () => {
|
||||||
|
const newEntries = [...displayEntries, {}];
|
||||||
|
onChange(section.slug, newEntries);
|
||||||
|
// Expand the new entry
|
||||||
|
setExpandedEntries(prev => new Set([...prev, newEntries.length - 1]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveEntry = (index: number) => {
|
||||||
|
if (displayEntries.length <= 1) return; // Keep at least one entry
|
||||||
|
const newEntries = displayEntries.filter((_, i) => i !== index);
|
||||||
|
onChange(section.slug, newEntries);
|
||||||
|
// Update expanded entries
|
||||||
|
setExpandedEntries(prev => {
|
||||||
|
const newSet = new Set<number>();
|
||||||
|
prev.forEach(i => {
|
||||||
|
if (i < index) newSet.add(i);
|
||||||
|
else if (i > index) newSet.add(i - 1);
|
||||||
|
});
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFieldChange = (entryIndex: number, fieldSlug: string, value: unknown) => {
|
||||||
|
const newEntries = [...displayEntries];
|
||||||
|
newEntries[entryIndex] = {
|
||||||
|
...newEntries[entryIndex],
|
||||||
|
[fieldSlug]: value,
|
||||||
|
};
|
||||||
|
onChange(section.slug, newEntries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = (index: number) => {
|
||||||
|
setExpandedEntries(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(index)) {
|
||||||
|
newSet.delete(index);
|
||||||
|
} else {
|
||||||
|
newSet.add(index);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEntryTitle = (entry: RepeatableEntryData, index: number): string => {
|
||||||
|
// Try to get a meaningful title from the first text field
|
||||||
|
for (const field of activeFields) {
|
||||||
|
if (field.fieldType === 'TEXT' && entry[field.slug]) {
|
||||||
|
return String(entry[field.slug]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${section.name} ${index + 1}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={section.slug}
|
||||||
|
className="bg-white rounded-[20px] p-6 shadow-[0px_10px_20px_rgba(217,217,217,0.5)] scroll-mt-[100px]"
|
||||||
|
>
|
||||||
|
{/* Section Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-[40px] h-[40px] flex items-center justify-center">
|
||||||
|
{isEmoji ? (
|
||||||
|
<span className="text-2xl">{section.icon}</span>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={iconPath}
|
||||||
|
alt={section.name}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[18px] font-bold font-serif text-[#00293D]">
|
||||||
|
{section.name}
|
||||||
|
{section.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</h2>
|
||||||
|
{section.description && (
|
||||||
|
<p className="text-[12px] text-gray-500">{section.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[12px] text-[#00293D]/60 bg-[#E58625]/10 px-3 py-1 rounded-full">
|
||||||
|
{displayEntries.length} {displayEntries.length === 1 ? 'entry' : 'entries'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entries */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{displayEntries.map((entry, entryIndex) => {
|
||||||
|
const isExpanded = expandedEntries.has(entryIndex);
|
||||||
|
const entryTitle = getEntryTitle(entry, entryIndex);
|
||||||
|
const entryErrors = errors?.[entryIndex] || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entryIndex}
|
||||||
|
className="border border-[#00293D]/10 rounded-[15px] overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Entry Header - Collapsible */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 bg-[#F8F9FA] cursor-pointer hover:bg-[#F0F1F3] transition-colors"
|
||||||
|
onClick={() => toggleExpand(entryIndex)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-6 h-6 flex items-center justify-center text-[#00293D]/60"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span className="text-[14px] font-semibold font-serif text-[#00293D]">
|
||||||
|
{entryTitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{displayEntries.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveEntry(entryIndex);
|
||||||
|
}}
|
||||||
|
className="text-red-500 hover:text-red-700 text-[12px] font-medium flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entry Fields - Collapsible */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{activeFields.map((field) => (
|
||||||
|
<DynamicField
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
value={entry[field.slug]}
|
||||||
|
onChange={(fieldSlug, value) => handleFieldChange(entryIndex, fieldSlug, value)}
|
||||||
|
error={entryErrors[field.slug]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Entry Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddEntry}
|
||||||
|
className="mt-4 w-full py-3 border-2 border-dashed border-[#E58625]/40 rounded-[15px] text-[14px] font-semibold font-serif text-[#E58625] hover:border-[#E58625] hover:bg-[#E58625]/5 transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Another {section.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ export { FileUpload, AttachedFile } from './FileUpload';
|
|||||||
export { TagInput } from './TagInput';
|
export { TagInput } from './TagInput';
|
||||||
export { default as DynamicField } from './DynamicField';
|
export { default as DynamicField } from './DynamicField';
|
||||||
export { default as DynamicSection } from './DynamicSection';
|
export { default as DynamicSection } from './DynamicSection';
|
||||||
|
export { default as RepeatableSection } from './RepeatableSection';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { QuickLinks } from './components';
|
import { QuickLinks } from './components';
|
||||||
import DynamicSection from './components/DynamicSection';
|
import DynamicSection from './components/DynamicSection';
|
||||||
|
import RepeatableSection, { RepeatableEntryData } from './components/RepeatableSection';
|
||||||
import { profileSectionsService, ProfileSection, AgentTypeSectionsResponse } from '@/services/profile-sections.service';
|
import { profileSectionsService, ProfileSection, AgentTypeSectionsResponse } from '@/services/profile-sections.service';
|
||||||
import { agentsService, AgentProfile, FieldValueInput } from '@/services/agents.service';
|
import { agentsService, AgentProfile, FieldValueInput } from '@/services/agents.service';
|
||||||
|
|
||||||
@@ -19,7 +20,10 @@ export default function EditProfilePage() {
|
|||||||
|
|
||||||
// Form data keyed by field slug
|
// Form data keyed by field slug
|
||||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||||
|
// Repeatable sections data keyed by section slug
|
||||||
|
const [repeatableData, setRepeatableData] = useState<Record<string, RepeatableEntryData[]>>({});
|
||||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||||
|
const [repeatableErrors, setRepeatableErrors] = useState<Record<string, Record<string, Record<string, string>>>>({});
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
// Fetch agent profile and sections on mount
|
// Fetch agent profile and sections on mount
|
||||||
@@ -96,6 +100,7 @@ export default function EditProfilePage() {
|
|||||||
// Fetch existing field values from the API
|
// Fetch existing field values from the API
|
||||||
try {
|
try {
|
||||||
const fieldValuesResponse = await agentsService.getFieldValues();
|
const fieldValuesResponse = await agentsService.getFieldValues();
|
||||||
|
console.log('Field values from API:', fieldValuesResponse.fieldValues);
|
||||||
if (fieldValuesResponse.fieldValues) {
|
if (fieldValuesResponse.fieldValues) {
|
||||||
fieldValuesResponse.fieldValues.forEach(fv => {
|
fieldValuesResponse.fieldValues.forEach(fv => {
|
||||||
// Only set if value exists and is not null/undefined
|
// Only set if value exists and is not null/undefined
|
||||||
@@ -109,8 +114,30 @@ export default function EditProfilePage() {
|
|||||||
console.log('No existing field values found, using defaults');
|
console.log('No existing field values found, using defaults');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('initialData after field values:', initialData);
|
||||||
|
|
||||||
setFormData(initialData);
|
setFormData(initialData);
|
||||||
|
|
||||||
|
// Initialize repeatable sections data
|
||||||
|
const initialRepeatableData: Record<string, RepeatableEntryData[]> = {};
|
||||||
|
sortedSections.forEach(section => {
|
||||||
|
if (section.isRepeatable) {
|
||||||
|
// Check if there's existing data for this section (stored as `${sectionSlug}_entries`)
|
||||||
|
const entriesKey = `${section.slug}_entries`;
|
||||||
|
console.log(`Checking repeatable section: ${section.slug}, entriesKey: ${entriesKey}`);
|
||||||
|
const existingEntries = initialData[entriesKey];
|
||||||
|
console.log(`Existing entries for ${entriesKey}:`, existingEntries);
|
||||||
|
if (Array.isArray(existingEntries) && existingEntries.length > 0) {
|
||||||
|
initialRepeatableData[section.slug] = existingEntries as RepeatableEntryData[];
|
||||||
|
} else {
|
||||||
|
// Initialize with one empty entry
|
||||||
|
initialRepeatableData[section.slug] = [{}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('initialRepeatableData:', initialRepeatableData);
|
||||||
|
setRepeatableData(initialRepeatableData);
|
||||||
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to fetch data:', err);
|
console.error('Failed to fetch data:', err);
|
||||||
|
|
||||||
@@ -139,32 +166,69 @@ export default function EditProfilePage() {
|
|||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setFormErrors({});
|
setFormErrors({});
|
||||||
|
setRepeatableErrors({});
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields for regular sections
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
sections.forEach(section => {
|
const repErrors: Record<string, Record<string, Record<string, string>>> = {};
|
||||||
section.fields?.forEach(field => {
|
|
||||||
if (field.isRequired && field.isActive && !field.isSearchableOnly) {
|
|
||||||
const value = formData[field.slug];
|
|
||||||
const isEmpty =
|
|
||||||
value === undefined ||
|
|
||||||
value === null ||
|
|
||||||
value === '' ||
|
|
||||||
(Array.isArray(value) && value.length === 0);
|
|
||||||
|
|
||||||
if (isEmpty) {
|
sections.forEach(section => {
|
||||||
errors[field.slug] = `${field.name} is required`;
|
if (section.isRepeatable) {
|
||||||
|
// Validate repeatable section entries
|
||||||
|
const entries = repeatableData[section.slug] || [{}];
|
||||||
|
entries.forEach((entry, entryIndex) => {
|
||||||
|
section.fields?.forEach(field => {
|
||||||
|
if (field.isRequired && field.isActive && !field.isSearchableOnly) {
|
||||||
|
const value = entry[field.slug];
|
||||||
|
const isEmpty =
|
||||||
|
value === undefined ||
|
||||||
|
value === null ||
|
||||||
|
value === '' ||
|
||||||
|
(Array.isArray(value) && value.length === 0);
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
if (!repErrors[section.slug]) repErrors[section.slug] = {};
|
||||||
|
if (!repErrors[section.slug][entryIndex]) repErrors[section.slug][entryIndex] = {};
|
||||||
|
repErrors[section.slug][entryIndex][field.slug] = `${field.name} is required`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Validate regular section fields
|
||||||
|
section.fields?.forEach(field => {
|
||||||
|
if (field.isRequired && field.isActive && !field.isSearchableOnly) {
|
||||||
|
const value = formData[field.slug];
|
||||||
|
const isEmpty =
|
||||||
|
value === undefined ||
|
||||||
|
value === null ||
|
||||||
|
value === '' ||
|
||||||
|
(Array.isArray(value) && value.length === 0);
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
errors[field.slug] = `${field.name} is required`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Object.keys(errors).length > 0) {
|
const hasErrors = Object.keys(errors).length > 0;
|
||||||
|
const hasRepeatableErrors = Object.keys(repErrors).length > 0;
|
||||||
|
|
||||||
|
if (hasErrors || hasRepeatableErrors) {
|
||||||
setFormErrors(errors);
|
setFormErrors(errors);
|
||||||
|
setRepeatableErrors(repErrors);
|
||||||
// Scroll to first error
|
// Scroll to first error
|
||||||
const firstErrorField = Object.keys(errors)[0];
|
if (hasErrors) {
|
||||||
const errorElement = document.querySelector(`[data-field-slug="${firstErrorField}"]`);
|
const firstErrorField = Object.keys(errors)[0];
|
||||||
errorElement?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
const errorElement = document.querySelector(`[data-field-slug="${firstErrorField}"]`);
|
||||||
|
errorElement?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
} else if (hasRepeatableErrors) {
|
||||||
|
const firstSection = Object.keys(repErrors)[0];
|
||||||
|
const sectionElement = document.getElementById(firstSection);
|
||||||
|
sectionElement?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +238,21 @@ export default function EditProfilePage() {
|
|||||||
value: value as string | number | boolean | string[] | Record<string, unknown> | null,
|
value: value as string | number | boolean | string[] | Record<string, unknown> | null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Add repeatable section data as a special field value
|
||||||
|
Object.entries(repeatableData).forEach(([sectionSlug, entries]) => {
|
||||||
|
// Filter out empty entries (entries with no filled fields)
|
||||||
|
const nonEmptyEntries = entries.filter(entry =>
|
||||||
|
Object.values(entry).some(v => v !== undefined && v !== null && v !== '')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nonEmptyEntries.length > 0) {
|
||||||
|
fieldValues.push({
|
||||||
|
fieldSlug: `${sectionSlug}_entries`,
|
||||||
|
value: nonEmptyEntries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Save field values to API
|
// Save field values to API
|
||||||
await agentsService.saveFieldValues(fieldValues);
|
await agentsService.saveFieldValues(fieldValues);
|
||||||
|
|
||||||
@@ -198,6 +277,18 @@ export default function EditProfilePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRepeatableChange = (sectionSlug: string, entries: RepeatableEntryData[]) => {
|
||||||
|
setRepeatableData(prev => ({ ...prev, [sectionSlug]: entries }));
|
||||||
|
// Clear errors for this section when modified
|
||||||
|
if (repeatableErrors[sectionSlug]) {
|
||||||
|
setRepeatableErrors(prev => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[sectionSlug];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[calc(100vh-180px)]">
|
<div className="flex items-center justify-center h-[calc(100vh-180px)]">
|
||||||
@@ -256,15 +347,25 @@ export default function EditProfilePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dynamic Sections */}
|
{/* Dynamic Sections */}
|
||||||
{sections.map((section) => (
|
{sections.map((section) =>
|
||||||
<DynamicSection
|
section.isRepeatable ? (
|
||||||
key={section.id}
|
<RepeatableSection
|
||||||
section={section}
|
key={section.id}
|
||||||
formData={formData}
|
section={section}
|
||||||
onChange={handleFieldChange}
|
entries={repeatableData[section.slug] || [{}]}
|
||||||
errors={formErrors}
|
onChange={handleRepeatableChange}
|
||||||
/>
|
errors={repeatableErrors[section.slug]}
|
||||||
))}
|
/>
|
||||||
|
) : (
|
||||||
|
<DynamicSection
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
formData={formData}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
errors={formErrors}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{sections.length === 0 && !loading && (
|
{sections.length === 0 && !loading && (
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ interface ApiResponse<T> {
|
|||||||
|
|
||||||
export interface FieldValueInput {
|
export interface FieldValueInput {
|
||||||
fieldSlug: string;
|
fieldSlug: string;
|
||||||
value: string | number | boolean | string[] | Record<string, unknown> | null;
|
value: string | number | boolean | string[] | Record<string, unknown> | Record<string, unknown>[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FieldValueResponse {
|
export interface FieldValueResponse {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface ProfileSection {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isGlobal: boolean;
|
isGlobal: boolean;
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
|
isRepeatable: boolean;
|
||||||
fields?: ProfileField[];
|
fields?: ProfileField[];
|
||||||
isRequired?: boolean;
|
isRequired?: boolean;
|
||||||
effectiveSortOrder?: number;
|
effectiveSortOrder?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user