import React, { useState, useEffect, useCallback, useRef } from 'react'; import { FaTimes, FaPlus, FaSpinner, FaCheckCircle, FaTimesCircle, FaExclamationTriangle, FaSearch } from 'react-icons/fa'; import { toast } from 'react-toastify'; import { type QuickCreateDoctypeConfig, type QuickCreateFieldConfig, getQuickCreateConfig } from './QuickCreateConfig'; import apiService from '../services/apiService'; // Simple Link Input component for use inside modal (avoids circular dependency) interface SimpleLinkInputProps { doctype: string; value: string; onChange: (value: string) => void; disabled?: boolean; placeholder?: string; filters?: Record; } const SimpleLinkInput: React.FC = ({ doctype, value, onChange, disabled = false, placeholder = 'Search...', filters = {}, }) => { const [searchText, setSearchText] = useState(''); const [results, setResults] = useState<{ value: string; description?: string }[]>([]); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const containerRef = useRef(null); const debounceRef = useRef | null>(null); // Search function const searchLink = useCallback(async (text: string = '') => { if (!doctype) return; setIsLoading(true); try { const params = new URLSearchParams({ doctype, txt: text, page_length: '20', }); if (filters && Object.keys(filters).length > 0) { params.append('filters', JSON.stringify(filters)); } const response = await apiService.apiCall<{ value: string; description?: string }[]>( `/api/method/frappe.desk.search.search_link?${params.toString()}` ); setResults(response || []); } catch (error) { console.error(`Error fetching ${doctype} links:`, error); setResults([]); } finally { setIsLoading(false); } }, [doctype, filters]); // Debounced search useEffect(() => { if (!isOpen) return; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => searchLink(searchText), 300); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [searchText, isOpen, searchLink]); // Load on open useEffect(() => { if (isOpen) searchLink(searchText); }, [isOpen]); // Close on outside click useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsOpen(false); setSearchText(''); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const handleSelect = (val: string) => { onChange(val); setSearchText(''); setIsOpen(false); }; const handleClear = () => { onChange(''); setSearchText(''); }; return (
!disabled && setIsOpen(true)} onChange={(e) => { setSearchText(e.target.value); setIsOpen(true); }} /> {value && !disabled && !isOpen && ( )}
{isOpen && !disabled && (
{isLoading ? (
Loading...
) : results.length > 0 ? (
    {results.map((item, idx) => (
  • handleSelect(item.value)} className={`px-3 py-2 cursor-pointer text-sm hover:bg-blue-500 hover:text-white ${value === item.value ? 'bg-blue-50 dark:bg-blue-900/30' : ''}`} > {item.value} {item.description && ( {item.description} )}
  • ))}
) : (
No results found
)}
)}
); }; interface QuickCreateModalProps { doctype: string; isOpen: boolean; onClose: () => void; onSuccess: (newRecord: any) => void; initialValues?: Record; parentFilters?: Record; // Filters to pass down to link fields customConfig?: QuickCreateDoctypeConfig; // Override default config } const QuickCreateModal: React.FC = ({ doctype, isOpen, onClose, onSuccess, initialValues = {}, parentFilters = {}, customConfig, }) => { const [formData, setFormData] = useState>({}); const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [config, setConfig] = useState(null); // Get configuration for the doctype useEffect(() => { const doctypeConfig = customConfig || getQuickCreateConfig(doctype); setConfig(doctypeConfig); if (doctypeConfig) { // Initialize form data with default values const defaultData: Record = {}; doctypeConfig.fields.forEach((field) => { if (field.defaultValue !== undefined) { defaultData[field.fieldname] = field.defaultValue; } else if (field.fieldtype === 'Check') { defaultData[field.fieldname] = 0; } else { defaultData[field.fieldname] = ''; } }); // Merge with initial values setFormData({ ...defaultData, ...initialValues }); } }, [doctype, customConfig, initialValues]); // Reset form when modal opens useEffect(() => { if (isOpen && config) { const defaultData: Record = {}; config.fields.forEach((field) => { if (field.defaultValue !== undefined) { defaultData[field.fieldname] = field.defaultValue; } else if (field.fieldtype === 'Check') { defaultData[field.fieldname] = 0; } else { defaultData[field.fieldname] = ''; } }); setFormData({ ...defaultData, ...initialValues }); setErrors({}); } }, [isOpen, config, initialValues]); // Handle field change const handleFieldChange = useCallback((fieldname: string, value: any) => { setFormData((prev) => ({ ...prev, [fieldname]: value })); // Clear error for this field setErrors((prev) => { const newErrors = { ...prev }; delete newErrors[fieldname]; return newErrors; }); }, []); // Validate form const validateForm = useCallback((): boolean => { if (!config) return false; const newErrors: Record = {}; config.fields.forEach((field) => { if (field.required && !field.hidden) { const value = formData[field.fieldname]; if (value === undefined || value === null || value === '') { newErrors[field.fieldname] = `${field.label} is required`; } } }); // Run custom validation if provided if (config.validateBeforeCreate) { const customError = config.validateBeforeCreate(formData); if (customError) { toast.error(customError, { position: 'top-right', autoClose: 4000, icon: , }); return false; } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }, [config, formData]); // Handle form submission const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!validateForm() || !config) { return; } setIsSubmitting(true); try { // Prepare data for API - only include non-empty fields const dataToSubmit: Record = {}; Object.entries(formData).forEach(([key, value]) => { if (value !== '' && value !== null && value !== undefined) { dataToSubmit[key] = value; } }); // Make API call to create the record const response = await apiService.apiCall( `/api/resource/${config.doctype}`, { method: 'POST', body: JSON.stringify(dataToSubmit), } ); if (response?.data) { const newRecord = response.data; toast.success(`${config.title.replace('Create New ', '')} created successfully!`, { position: 'top-right', autoClose: 3000, icon: , }); // Call afterCreate callback if provided if (config.afterCreate) { config.afterCreate(newRecord); } // Call onSuccess callback with the new record onSuccess(newRecord); onClose(); } else { throw new Error('Failed to create record'); } } catch (err) { console.error('Error creating record:', err); const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; // Check for duplicate entry error if (errorMessage.includes('Duplicate') || errorMessage.includes('already exists')) { toast.error(`A record with this name already exists. Please use a different name.`, { position: 'top-right', autoClose: 5000, icon: , }); } else { toast.error(`Failed to create: ${errorMessage}`, { position: 'top-right', autoClose: 5000, icon: , }); } } finally { setIsSubmitting(false); } }; // Render a single field based on its type const renderField = (field: QuickCreateFieldConfig) => { if (field.hidden) return null; const value = formData[field.fieldname]; const error = errors[field.fieldname]; const isDisabled = field.readOnly || isSubmitting; // Check if field should be shown based on depends_on if (field.dependsOn) { const dependsOnValue = formData[field.dependsOn]; if (!dependsOnValue) return null; } const baseInputClass = `w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'} ${isDisabled ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : 'bg-white dark:bg-gray-700'} text-gray-900 dark:text-white`; switch (field.fieldtype) { case 'Data': return ( handleFieldChange(field.fieldname, e.target.value)} placeholder={field.placeholder} disabled={isDisabled} className={baseInputClass} /> ); case 'Text': return (