import { useState, useEffect } from 'react'; import { getDataCompletenessConfig, updateDataCompletenessConfig, getSchema, type DataCompletenessConfig, type CompletenessFieldConfig, type CompletenessCategoryConfig, type SchemaResponse, } from '../services/api'; // Mapping from schema fieldName to ApplicationDetails field path // Some fields have different names in ApplicationDetails vs schema const FIELD_NAME_TO_PATH_MAP: Record = { // Direct mappings 'organisation': 'organisation', 'status': 'status', 'businessImpactAnalyse': 'businessImpactAnalyse', 'supplierProduct': 'supplierProduct', 'businessOwner': 'businessOwner', 'systemOwner': 'systemOwner', 'functionalApplicationManagement': 'functionalApplicationManagement', 'technicalApplicationManagement': 'technicalApplicationManagement', 'technicalApplicationManagementPrimary': 'technicalApplicationManagementPrimary', 'technicalApplicationManagementSecondary': 'technicalApplicationManagementSecondary', 'description': 'description', 'searchReference': 'searchReference', 'businessImportance': 'businessImportance', 'applicationManagementHosting': 'applicationManagementHosting', 'applicationManagementTAM': 'applicationManagementTAM', 'platform': 'platform', // Different names (schema fieldName -> ApplicationDetails property) 'applicationFunction': 'applicationFunctions', // Note: plural in ApplicationDetails 'applicationComponentHostingType': 'hostingType', 'ictGovernanceModel': 'governanceModel', 'applicationManagementApplicationType': 'applicationType', 'applicationManagementDynamicsFactor': 'dynamicsFactor', 'applicationManagementComplexityFactor': 'complexityFactor', 'applicationManagementNumberOfUsers': 'numberOfUsers', 'applicationManagementSubteam': 'applicationSubteam', 'applicationManagementOverrideFTE': 'overrideFTE', 'technischeArchitectuurTA': 'technischeArchitectuur', // Note: applicationTeam is not directly on ApplicationComponent, it's looked up via subteam }; function generateId(): string { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } export default function DataCompletenessConfig() { const [config, setConfig] = useState(null); const [, setSchema] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [editingCategory, setEditingCategory] = useState(null); const [editingCategoryName, setEditingCategoryName] = useState(''); const [editingCategoryDescription, setEditingCategoryDescription] = useState(''); const [addingFieldToCategory, setAddingFieldToCategory] = useState(null); const [newFieldName, setNewFieldName] = useState(''); const [newFieldPath, setNewFieldPath] = useState(''); const [newCategoryName, setNewCategoryName] = useState(''); const [newCategoryDescription, setNewCategoryDescription] = useState(''); const [availableFields, setAvailableFields] = useState>([]); const [draggedField, setDraggedField] = useState<{ categoryId: string; fieldIndex: number; field: CompletenessFieldConfig } | null>(null); const [draggedCategory, setDraggedCategory] = useState<{ categoryId: string; categoryIndex: number } | null>(null); useEffect(() => { loadConfig(); loadSchema(); }, []); const loadSchema = async () => { try { const schemaData = await getSchema(); setSchema(schemaData); // Extract fields from ApplicationComponent object type const applicationComponentType = schemaData.objectTypes['ApplicationComponent']; if (applicationComponentType) { const fields = applicationComponentType.attributes .filter(attr => { // Include editable attributes or non-system attributes return attr.isEditable || !attr.isSystem; }) .map(attr => { // Map schema fieldName to ApplicationDetails field path const fieldPath = FIELD_NAME_TO_PATH_MAP[attr.fieldName] || attr.fieldName; return { name: attr.name, fieldPath: fieldPath, type: attr.type, description: attr.description, }; }) // Remove duplicates and sort by name .filter((field, index, self) => index === self.findIndex(f => f.fieldPath === field.fieldPath) ) .sort((a, b) => a.name.localeCompare(b.name)); setAvailableFields(fields.map(f => ({ name: f.name, fieldPath: f.fieldPath }))); } } catch (err) { console.error('Failed to load schema:', err); // Continue without schema - user can still enter custom fields } }; const loadConfig = async () => { try { setLoading(true); setError(null); const data = await getDataCompletenessConfig(); setConfig(data); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load configuration'); } finally { setLoading(false); } }; const handleSave = async () => { if (!config) return; try { setSaving(true); setError(null); setSuccess(null); await updateDataCompletenessConfig(config); setSuccess('Configuration saved successfully!'); setTimeout(() => setSuccess(null), 3000); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save configuration'); } finally { setSaving(false); } }; const addCategory = () => { if (!config || !newCategoryName.trim()) return; const newCategory: CompletenessCategoryConfig = { id: generateId(), name: newCategoryName.trim(), description: newCategoryDescription.trim(), fields: [], }; setConfig({ ...config, categories: [...config.categories, newCategory], }); setNewCategoryName(''); setNewCategoryDescription(''); }; const deleteCategory = (categoryId: string) => { if (!config) return; if (!confirm('Are you sure you want to delete this category? This cannot be undone.')) return; setConfig({ ...config, categories: config.categories.filter(c => c.id !== categoryId), }); }; const startEditingCategory = (categoryId: string) => { if (!config) return; const category = config.categories.find(c => c.id === categoryId); if (!category) return; setEditingCategory(categoryId); setEditingCategoryName(category.name); setEditingCategoryDescription(category.description || ''); }; const cancelEditingCategory = () => { setEditingCategory(null); setEditingCategoryName(''); setEditingCategoryDescription(''); }; const saveEditingCategory = (categoryId: string) => { if (!config) return; setConfig({ ...config, categories: config.categories.map(cat => cat.id === categoryId ? { ...cat, name: editingCategoryName.trim(), description: editingCategoryDescription.trim() } : cat ), }); cancelEditingCategory(); }; const addFieldToCategory = (categoryId: string) => { if (!config || !newFieldName.trim() || !newFieldPath.trim()) return; const category = config.categories.find(c => c.id === categoryId); if (!category) return; // Check if field already exists in this category if (category.fields.some(f => f.fieldPath === newFieldPath.trim())) { setError(`Field "${newFieldName.trim()}" is already in this category`); setTimeout(() => setError(null), 3000); return; } const newField: CompletenessFieldConfig = { id: generateId(), name: newFieldName.trim(), fieldPath: newFieldPath.trim(), enabled: true, }; setConfig({ ...config, categories: config.categories.map(cat => cat.id === categoryId ? { ...cat, fields: [...cat.fields, newField] } : cat ), }); setNewFieldName(''); setNewFieldPath(''); setAddingFieldToCategory(null); }; const removeFieldFromCategory = (categoryId: string, fieldId: string) => { if (!config) return; setConfig({ ...config, categories: config.categories.map(cat => cat.id === categoryId ? { ...cat, fields: cat.fields.filter(f => f.id !== fieldId) } : cat ), }); }; const toggleField = (categoryId: string, fieldId: string) => { if (!config) return; setConfig({ ...config, categories: config.categories.map(cat => cat.id === categoryId ? { ...cat, fields: cat.fields.map(field => field.id === fieldId ? { ...field, enabled: !field.enabled } : field ), } : cat ), }); }; const handleFieldDragStart = (categoryId: string, fieldIndex: number) => { if (!config) return; const category = config.categories.find(c => c.id === categoryId); if (!category) return; const field = category.fields[fieldIndex]; if (!field) return; setDraggedField({ categoryId, fieldIndex, field }); }; const handleFieldDragOver = (e: React.DragEvent, categoryId: string, fieldIndex: number) => { e.preventDefault(); if (!draggedField || !config) return; // Same category - reorder within category if (draggedField.categoryId === categoryId) { if (draggedField.fieldIndex === fieldIndex) return; const category = config.categories.find(c => c.id === categoryId); if (!category) return; const newFields = [...category.fields]; const draggedItem = newFields[draggedField.fieldIndex]; newFields.splice(draggedField.fieldIndex, 1); newFields.splice(fieldIndex, 0, draggedItem); setConfig({ ...config, categories: config.categories.map(cat => cat.id === categoryId ? { ...cat, fields: newFields } : cat ), }); setDraggedField({ ...draggedField, fieldIndex }); } else { // Different category - move to new category const targetCategory = config.categories.find(c => c.id === categoryId); if (!targetCategory) return; // Check if field already exists in target category if (targetCategory.fields.some(f => f.id === draggedField.field.id)) return; // Remove from source category const sourceCategory = config.categories.find(c => c.id === draggedField.categoryId); if (!sourceCategory) return; const sourceFields = sourceCategory.fields.filter(f => f.id !== draggedField.field.id); // Add to target category at the specified index const targetFields = [...targetCategory.fields]; targetFields.splice(fieldIndex, 0, draggedField.field); setConfig({ ...config, categories: config.categories.map(cat => { if (cat.id === draggedField.categoryId) { return { ...cat, fields: sourceFields }; } if (cat.id === categoryId) { return { ...cat, fields: targetFields }; } return cat; }), }); setDraggedField({ categoryId, fieldIndex, field: draggedField.field }); } }; const handleCategoryFieldDragOver = (e: React.DragEvent, categoryId: string) => { e.preventDefault(); if (!draggedField || !config) return; // Only allow dropping if dragging from a different category if (draggedField.categoryId === categoryId) return; const targetCategory = config.categories.find(c => c.id === categoryId); if (!targetCategory) return; // Check if field already exists in target category if (targetCategory.fields.some(f => f.id === draggedField.field.id)) return; }; const handleFieldDrop = (e: React.DragEvent) => { e.preventDefault(); setDraggedField(null); }; const handleCategoryFieldDrop = (e: React.DragEvent, categoryId: string) => { e.preventDefault(); if (!draggedField || !config) return; // Only allow dropping if dragging from a different category if (draggedField.categoryId === categoryId) { setDraggedField(null); return; } const targetCategory = config.categories.find(c => c.id === categoryId); if (!targetCategory) { setDraggedField(null); return; } // Check if field already exists in target category if (targetCategory.fields.some(f => f.id === draggedField.field.id)) { setDraggedField(null); return; } // Remove from source category const sourceCategory = config.categories.find(c => c.id === draggedField.categoryId); if (!sourceCategory) { setDraggedField(null); return; } const sourceFields = sourceCategory.fields.filter(f => f.id !== draggedField.field.id); // Add to target category at the end const targetFields = [...targetCategory.fields, draggedField.field]; setConfig({ ...config, categories: config.categories.map(cat => { if (cat.id === draggedField.categoryId) { return { ...cat, fields: sourceFields }; } if (cat.id === categoryId) { return { ...cat, fields: targetFields }; } return cat; }), }); setDraggedField(null); }; const handleCategoryDragStart = (categoryId: string, categoryIndex: number) => { setDraggedCategory({ categoryId, categoryIndex }); }; const handleCategoryBlockDragOver = (e: React.DragEvent, categoryId: string, categoryIndex: number) => { e.preventDefault(); e.stopPropagation(); if (!draggedCategory || !config) return; if (draggedCategory.categoryId === categoryId) return; if (draggedCategory.categoryIndex === categoryIndex) return; const newCategories = [...config.categories]; const draggedItem = newCategories[draggedCategory.categoryIndex]; newCategories.splice(draggedCategory.categoryIndex, 1); newCategories.splice(categoryIndex, 0, draggedItem); setConfig({ ...config, categories: newCategories, }); setDraggedCategory({ categoryId, categoryIndex }); }; const handleCategoryDragEnd = () => { setDraggedCategory(null); }; if (loading) { return (
); } if (error && !config) { return (
{error}
); } if (!config) { return (
No configuration available
); } return (
{/* Header */}

Data Completeness Configuration

Create and configure categories and fields for the Data Completeness Score calculation. Fields are dynamically loaded from the "Application Component" object type in the datamodel.

{/* Success/Error Messages */} {success && (
{success}
)} {error && (
{error}
)} {/* Categories */} {config.categories.map((category, categoryIndex) => { const enabledCount = category.fields.filter(f => f.enabled).length; const isEditing = editingCategory === category.id; const isAddingField = addingFieldToCategory === category.id; const isDraggedCategory = draggedCategory?.categoryId === category.id; return (
{ // Only allow dragging when not editing and not adding a field if (!isEditing && !isAddingField) { handleCategoryDragStart(category.id, categoryIndex); } else { e.preventDefault(); } }} onDragOver={(e) => { // Only allow reordering when not editing and not adding a field if (!isEditing && !isAddingField) { handleCategoryBlockDragOver(e, category.id, categoryIndex); } }} onDragEnd={handleCategoryDragEnd} className={`bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6 ${ isDraggedCategory ? 'opacity-50' : '' } ${!isEditing && !isAddingField ? 'cursor-move' : ''}`} > {/* Category Header */}
{!isEditing && !isAddingField && (
)}
{isEditing ? (
setEditingCategoryName(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500" placeholder="Category Name" /> setEditingCategoryDescription(e.target.value)} placeholder="Description" className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500" />
) : (

{category.name}

{category.description || 'No description'}

)}
{enabledCount} / {category.fields.length} enabled {!isEditing && ( <> )}
{/* Fields List */}
handleCategoryFieldDragOver(e, category.id)} onDrop={(e) => handleCategoryFieldDrop(e, category.id)} > {category.fields.length === 0 ? (
No fields in this category. {draggedField && draggedField.categoryId !== category.id ? 'Drop a field here' : 'Click "Add Field" to add one.'}
) : ( category.fields.map((field, index) => (
handleFieldDragStart(category.id, index)} onDragOver={(e) => handleFieldDragOver(e, category.id, index)} onDrop={handleFieldDrop} className={`flex items-center justify-between py-1.5 px-3 border border-gray-200 rounded hover:bg-gray-50 cursor-move ${ draggedField?.categoryId === category.id && draggedField?.fieldIndex === index ? 'opacity-50' : '' } ${ draggedField && draggedField.categoryId !== category.id && draggedField.fieldIndex === index ? 'border-blue-400 bg-blue-50' : '' }`} >
{/* Drag handle icon */}
toggleField(category.id, field.id)} onClick={(e) => e.stopPropagation()} className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 flex-shrink-0" />
{field.enabled ? 'Enabled' : 'Disabled'}
)) )}
{/* Add Field Form - Below the list */} {isAddingField && (

Add Field to {category.name}

Only fields from the "Application Component" object type are available

{/* Display selected field info (read-only) */} {newFieldPath && newFieldName && (
Selected Field:{' '} {newFieldName}
Field Path: {newFieldPath}
)}
)} {/* Add Field Button - Below the list */} {!isEditing && !isAddingField && (
)}
); })} {/* Add New Category */}

Add New Category

setNewCategoryName(e.target.value)} placeholder="e.g., Security, Compliance" className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" />
setNewCategoryDescription(e.target.value)} placeholder="Brief description" className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" />
{/* Save Button */}
{/* Info Box */}

About Data Completeness Configuration

  • Fields are dynamically loaded from the "Application Component" object type in your Jira Assets schema
  • Create custom categories to organize fields for completeness checking
  • Add fields to categories by selecting from available schema fields or entering custom field paths
  • Only enabled fields are included in the completeness score calculation
  • Changes take effect immediately after saving
  • The field path determines which property in the ApplicationDetails object is checked
); }