Seera-Unified-UI/asm_app/src/components/QuickCreateModal.tsx
2026-03-23 17:43:17 +05:30

602 lines
19 KiB
TypeScript

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<string, any>;
}
const SimpleLinkInput: React.FC<SimpleLinkInputProps> = ({
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<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div ref={containerRef} className="relative">
<div className="relative">
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={12} />
<input
type="text"
value={isOpen ? searchText : value}
placeholder={placeholder}
disabled={disabled}
className={`w-full pl-9 pr-8 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md
focus:outline-none focus:ring-2 focus:ring-blue-500
disabled:bg-gray-100 dark:disabled:bg-gray-700
bg-white dark:bg-gray-700 text-gray-900 dark:text-white`}
onFocus={() => !disabled && setIsOpen(true)}
onChange={(e) => {
setSearchText(e.target.value);
setIsOpen(true);
}}
/>
{value && !disabled && !isOpen && (
<button
type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<FaTimes size={12} />
</button>
)}
</div>
{isOpen && !disabled && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-40 overflow-auto">
{isLoading ? (
<div className="p-3 text-center text-gray-500 dark:text-gray-400 text-sm">
<FaSpinner className="animate-spin inline mr-2" size={12} />
Loading...
</div>
) : results.length > 0 ? (
<ul>
{results.map((item, idx) => (
<li
key={idx}
onClick={() => 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 && (
<span className="text-xs text-gray-500 ml-2">{item.description}</span>
)}
</li>
))}
</ul>
) : (
<div className="p-3 text-center text-gray-500 dark:text-gray-400 text-sm">
No results found
</div>
)}
</div>
)}
</div>
);
};
interface QuickCreateModalProps {
doctype: string;
isOpen: boolean;
onClose: () => void;
onSuccess: (newRecord: any) => void;
initialValues?: Record<string, any>;
parentFilters?: Record<string, any>; // Filters to pass down to link fields
customConfig?: QuickCreateDoctypeConfig; // Override default config
}
const QuickCreateModal: React.FC<QuickCreateModalProps> = ({
doctype,
isOpen,
onClose,
onSuccess,
initialValues = {},
parentFilters = {},
customConfig,
}) => {
const [formData, setFormData] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [config, setConfig] = useState<QuickCreateDoctypeConfig | null>(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<string, any> = {};
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<string, any> = {};
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<string, string> = {};
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: <FaExclamationTriangle />,
});
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<string, any> = {};
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<any>(
`/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: <FaCheckCircle />,
});
// 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: <FaTimesCircle />,
});
} else {
toast.error(`Failed to create: ${errorMessage}`, {
position: 'top-right',
autoClose: 5000,
icon: <FaTimesCircle />,
});
}
} 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 (
<input
type="text"
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
className={baseInputClass}
/>
);
case 'Text':
return (
<textarea
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
rows={3}
className={`${baseInputClass} resize-none`}
/>
);
case 'Select':
return (
<select
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
disabled={isDisabled}
className={baseInputClass}
>
<option value="">Select {field.label}</option>
{(field.options || []).map((option) => {
const optionValue = typeof option === 'string' ? option : option.value;
const optionLabel = typeof option === 'string' ? option : option.label;
return (
<option key={optionValue} value={optionValue}>
{optionLabel}
</option>
);
})}
</select>
);
case 'Link':
return (
<SimpleLinkInput
doctype={field.linkDoctype || ''}
value={value || ''}
onChange={(val) => handleFieldChange(field.fieldname, val)}
disabled={isDisabled}
placeholder={field.placeholder}
// filters={{ ...field.linkFilters, ...parentFilters }}
filters={field.linkFilters || {}}
/>
);
case 'Check':
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={value === 1 || value === true}
onChange={(e) => handleFieldChange(field.fieldname, e.target.checked ? 1 : 0)}
disabled={isDisabled}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500
dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2
dark:bg-gray-700 dark:border-gray-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
{field.description || field.label}
</span>
</label>
);
case 'Int':
return (
<input
type="number"
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, parseInt(e.target.value) || '')}
placeholder={field.placeholder}
disabled={isDisabled}
step="1"
className={baseInputClass}
/>
);
case 'Float':
return (
<input
type="number"
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, parseFloat(e.target.value) || '')}
placeholder={field.placeholder}
disabled={isDisabled}
step="0.01"
className={baseInputClass}
/>
);
case 'Date':
return (
<input
type="date"
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
disabled={isDisabled}
className={baseInputClass}
/>
);
case 'Datetime':
return (
<input
type="datetime-local"
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
disabled={isDisabled}
className={baseInputClass}
/>
);
default:
return (
<input
type="text"
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
className={baseInputClass}
/>
);
}
};
if (!isOpen || !config) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2">
<FaPlus className="text-blue-500" size={16} />
{config.title}
</h3>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded transition-colors disabled:opacity-50"
>
<FaTimes size={18} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{config.fields.map((field) => {
if (field.hidden) return null;
// Check depends_on
if (field.dependsOn) {
const dependsOnValue = formData[field.dependsOn];
if (!dependsOnValue) return null;
}
return (
<div key={field.fieldname}>
{field.fieldtype !== 'Check' && (
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
{renderField(field)}
{field.description && field.fieldtype !== 'Check' && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{field.description}
</p>
)}
{errors[field.fieldname] && (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<FaExclamationTriangle size={10} />
{errors[field.fieldname]}
</p>
)}
</div>
);
})}
</div>
</form>
{/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
onClick={handleSubmit}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
>
{isSubmitting ? (
<>
<FaSpinner className="animate-spin" size={14} />
Creating...
</>
) : (
<>
<FaPlus size={14} />
Create
</>
)}
</button>
</div>
</div>
</div>
);
};
export default QuickCreateModal;