602 lines
19 KiB
TypeScript
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; |