diff --git a/src/app/(agent)/agent/edit/components/DynamicField.tsx b/src/app/(agent)/agent/edit/components/DynamicField.tsx index 96e39fe..464a3e5 100644 --- a/src/app/(agent)/agent/edit/components/DynamicField.tsx +++ b/src/app/(agent)/agent/edit/components/DynamicField.tsx @@ -4,6 +4,7 @@ import React, { useRef, useState, useCallback } from 'react'; import Image from 'next/image'; import Select, { MultiValue, StylesConfig } from 'react-select'; import { ProfileField } from '@/services/profile-sections.service'; +import { agentsService } from '@/services/agents.service'; import { FormInput } from './FormInput'; import { FormCheckbox } from './FormCheckbox'; import { FormTextarea } from './FormTextarea'; @@ -157,6 +158,7 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF return ( @@ -449,11 +451,12 @@ interface UploadingFile { // File Upload Component interface FileUploadProps { field: ProfileField; + fieldSlug: string; value: UploadedFile[]; onChange: (value: UploadedFile[]) => void; } -function FileUpload({ field, value, onChange }: FileUploadProps) { +function FileUpload({ field, fieldSlug, value, onChange }: FileUploadProps) { const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [uploadingFiles, setUploadingFiles] = useState([]); @@ -501,7 +504,20 @@ function FileUpload({ field, value, onChange }: FileUploadProps) { // Remove from uploading and add to uploaded 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[], + }]); + } 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) { console.error('Upload failed:', error); setUploadingFiles(prev => @@ -582,7 +598,18 @@ function FileUpload({ field, value, onChange }: FileUploadProps) { // 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[], + }]); + } catch (saveError) { + console.error('Failed to save file removal to database:', saveError); + } }; const handleRemoveUploadingFile = (tempId: string) => { diff --git a/src/app/(agent)/agent/edit/components/RepeatableSection.tsx b/src/app/(agent)/agent/edit/components/RepeatableSection.tsx new file mode 100644 index 0000000..783847f --- /dev/null +++ b/src/app/(agent)/agent/edit/components/RepeatableSection.tsx @@ -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 = { + '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 = { + '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; + +interface RepeatableSectionProps { + section: ProfileSection; + entries: RepeatableEntryData[]; + onChange: (sectionSlug: string, entries: RepeatableEntryData[]) => void; + errors?: Record>; // errors[entryIndex][fieldSlug] +} + +export default function RepeatableSection({ + section, + entries, + onChange, + errors, +}: RepeatableSectionProps) { + const [expandedEntries, setExpandedEntries] = useState>(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(); + 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 ( +
+ {/* Section Header */} +
+
+
+ {isEmoji ? ( + {section.icon} + ) : ( + {section.name} + )} +
+
+

+ {section.name} + {section.isRequired && *} +

+ {section.description && ( +

{section.description}

+ )} +
+
+ + {displayEntries.length} {displayEntries.length === 1 ? 'entry' : 'entries'} + +
+ + {/* Entries */} +
+ {displayEntries.map((entry, entryIndex) => { + const isExpanded = expandedEntries.has(entryIndex); + const entryTitle = getEntryTitle(entry, entryIndex); + const entryErrors = errors?.[entryIndex] || {}; + + return ( +
+ {/* Entry Header - Collapsible */} +
toggleExpand(entryIndex)} + > +
+ + + {entryTitle} + +
+ {displayEntries.length > 1 && ( + + )} +
+ + {/* Entry Fields - Collapsible */} + {isExpanded && ( +
+ {activeFields.map((field) => ( + handleFieldChange(entryIndex, fieldSlug, value)} + error={entryErrors[field.slug]} + /> + ))} +
+ )} +
+ ); + })} +
+ + {/* Add Entry Button */} + +
+ ); +} diff --git a/src/app/(agent)/agent/edit/components/index.ts b/src/app/(agent)/agent/edit/components/index.ts index 724453d..7ce8202 100644 --- a/src/app/(agent)/agent/edit/components/index.ts +++ b/src/app/(agent)/agent/edit/components/index.ts @@ -8,3 +8,4 @@ export { FileUpload, AttachedFile } from './FileUpload'; export { TagInput } from './TagInput'; export { default as DynamicField } from './DynamicField'; export { default as DynamicSection } from './DynamicSection'; +export { default as RepeatableSection } from './RepeatableSection'; diff --git a/src/app/(agent)/agent/edit/page.tsx b/src/app/(agent)/agent/edit/page.tsx index 5e3f327..07d27c2 100644 --- a/src/app/(agent)/agent/edit/page.tsx +++ b/src/app/(agent)/agent/edit/page.tsx @@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { QuickLinks } from './components'; import DynamicSection from './components/DynamicSection'; +import RepeatableSection, { RepeatableEntryData } from './components/RepeatableSection'; import { profileSectionsService, ProfileSection, AgentTypeSectionsResponse } from '@/services/profile-sections.service'; import { agentsService, AgentProfile, FieldValueInput } from '@/services/agents.service'; @@ -19,7 +20,10 @@ export default function EditProfilePage() { // Form data keyed by field slug const [formData, setFormData] = useState>({}); + // Repeatable sections data keyed by section slug + const [repeatableData, setRepeatableData] = useState>({}); const [formErrors, setFormErrors] = useState>({}); + const [repeatableErrors, setRepeatableErrors] = useState>>>({}); const [isSaving, setIsSaving] = useState(false); // Fetch agent profile and sections on mount @@ -96,6 +100,7 @@ export default function EditProfilePage() { // Fetch existing field values from the API try { const fieldValuesResponse = await agentsService.getFieldValues(); + console.log('Field values from API:', fieldValuesResponse.fieldValues); if (fieldValuesResponse.fieldValues) { fieldValuesResponse.fieldValues.forEach(fv => { // 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('initialData after field values:', initialData); + setFormData(initialData); + // Initialize repeatable sections data + const initialRepeatableData: Record = {}; + 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) { console.error('Failed to fetch data:', err); @@ -139,32 +166,69 @@ export default function EditProfilePage() { try { setIsSaving(true); setFormErrors({}); + setRepeatableErrors({}); - // Validate required fields + // Validate required fields for regular sections const errors: Record = {}; - sections.forEach(section => { - 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); + const repErrors: Record>> = {}; - if (isEmpty) { - errors[field.slug] = `${field.name} is required`; + sections.forEach(section => { + 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); + setRepeatableErrors(repErrors); // Scroll to first error - const firstErrorField = Object.keys(errors)[0]; - const errorElement = document.querySelector(`[data-field-slug="${firstErrorField}"]`); - errorElement?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if (hasErrors) { + const firstErrorField = Object.keys(errors)[0]; + 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; } @@ -174,6 +238,21 @@ export default function EditProfilePage() { value: value as string | number | boolean | string[] | Record | 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 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) { return (
@@ -256,15 +347,25 @@ export default function EditProfilePage() { )} {/* Dynamic Sections */} - {sections.map((section) => ( - - ))} + {sections.map((section) => + section.isRepeatable ? ( + + ) : ( + + ) + )} {/* Empty State */} {sections.length === 0 && !loading && ( diff --git a/src/services/agents.service.ts b/src/services/agents.service.ts index dd337a1..0e4ea74 100644 --- a/src/services/agents.service.ts +++ b/src/services/agents.service.ts @@ -45,7 +45,7 @@ interface ApiResponse { export interface FieldValueInput { fieldSlug: string; - value: string | number | boolean | string[] | Record | null; + value: string | number | boolean | string[] | Record | Record[] | null; } export interface FieldValueResponse { diff --git a/src/services/profile-sections.service.ts b/src/services/profile-sections.service.ts index 0c4f38c..9657e33 100644 --- a/src/services/profile-sections.service.ts +++ b/src/services/profile-sections.service.ts @@ -74,6 +74,7 @@ export interface ProfileSection { isActive: boolean; isGlobal: boolean; isSystem: boolean; + isRepeatable: boolean; fields?: ProfileField[]; isRequired?: boolean; effectiveSortOrder?: number;