Add database adapter system, production deployment configs, and new dashboard components

- Add PostgreSQL and SQLite database adapters with factory pattern
- Add migration script for SQLite to PostgreSQL
- Add production Dockerfiles and docker-compose configs
- Add deployment documentation and scripts
- Add BIA sync dashboard and matching service
- Add data completeness configuration and components
- Add new dashboard components (BusinessImportanceComparison, ComplexityDynamics, etc.)
- Update various services and routes
- Remove deprecated management-parameters.json and taxonomy files
This commit is contained in:
2026-01-14 00:38:40 +01:00
parent ca21b9538d
commit a7f8301196
73 changed files with 12878 additions and 2003 deletions

View File

@@ -0,0 +1,821 @@
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<string, string> = {
// 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<DataCompletenessConfig | null>(null);
const [schema, setSchema] = useState<SchemaResponse | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [editingCategory, setEditingCategory] = useState<string | null>(null);
const [editingCategoryName, setEditingCategoryName] = useState('');
const [editingCategoryDescription, setEditingCategoryDescription] = useState('');
const [addingFieldToCategory, setAddingFieldToCategory] = useState<string | null>(null);
const [newFieldName, setNewFieldName] = useState('');
const [newFieldPath, setNewFieldPath] = useState('');
const [newCategoryName, setNewCategoryName] = useState('');
const [newCategoryDescription, setNewCategoryDescription] = useState('');
const [availableFields, setAvailableFields] = useState<Array<{ name: string; fieldPath: string }>>([]);
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 (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
if (error && !config) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
);
}
if (!config) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-gray-700">
No configuration available
</div>
);
}
return (
<div>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Data Completeness Configuration</h1>
<p className="mt-1 text-sm text-gray-500">
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.
</p>
</div>
{/* Success/Error Messages */}
{success && (
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4 text-green-700">
{success}
</div>
)}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
)}
{/* 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 (
<div
key={category.id}
draggable
onDragStart={(e) => {
// 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 */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 flex-1">
{!isEditing && !isAddingField && (
<div className="flex-shrink-0 text-gray-400 cursor-move" title="Drag to reorder category">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
</svg>
</div>
)}
<div className="flex-1">
{isEditing ? (
<div className="space-y-2">
<input
type="text"
value={editingCategoryName}
onChange={(e) => 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"
/>
<input
type="text"
value={editingCategoryDescription}
onChange={(e) => 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"
/>
<div className="flex gap-2">
<button
onClick={() => saveEditingCategory(category.id)}
disabled={!editingCategoryName.trim()}
className="px-3 py-1.5 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Save
</button>
<button
onClick={cancelEditingCategory}
className="px-3 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
) : (
<div>
<h2 className="text-lg font-semibold text-gray-900">{category.name}</h2>
<p className="text-sm text-gray-500 mt-1">{category.description || 'No description'}</p>
</div>
)}
</div>
</div>
<div className="flex items-center gap-4 ml-4">
<span className="text-sm text-gray-600">
{enabledCount} / {category.fields.length} enabled
</span>
{!isEditing && (
<>
<button
onClick={() => startEditingCategory(category.id)}
className="px-3 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50"
>
Edit
</button>
<button
onClick={() => deleteCategory(category.id)}
className="px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800 border border-red-300 rounded-md hover:bg-red-50"
>
Delete
</button>
</>
)}
</div>
</div>
{/* Fields List */}
<div
className="space-y-1 mb-4"
onDragOver={(e) => handleCategoryFieldDragOver(e, category.id)}
onDrop={(e) => handleCategoryFieldDrop(e, category.id)}
>
{category.fields.length === 0 ? (
<div className={`text-center text-gray-500 py-3 border border-dashed rounded-lg text-sm ${
draggedField && draggedField.categoryId !== category.id
? 'border-blue-400 bg-blue-50'
: 'border-gray-300'
}`}>
No fields in this category. {draggedField && draggedField.categoryId !== category.id ? 'Drop a field here' : 'Click "Add Field" to add one.'}
</div>
) : (
category.fields.map((field, index) => (
<div
key={field.id}
draggable
onDragStart={() => 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'
: ''
}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* Drag handle icon */}
<div className="flex-shrink-0 text-gray-400 cursor-move">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
</svg>
</div>
<input
type="checkbox"
checked={field.enabled}
onChange={() => 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"
/>
<label
className="text-sm text-gray-900 cursor-pointer flex items-center gap-2 min-w-0 flex-1"
onClick={() => toggleField(category.id, field.id)}
>
<span className="font-medium">{field.name}</span>
<span className="text-xs text-gray-500">
<code className="bg-gray-100 px-1.5 py-0.5 rounded">{field.fieldPath}</code>
</span>
</label>
</div>
<div className="flex items-center gap-2 ml-3 flex-shrink-0">
<div className={`px-2 py-0.5 text-xs font-medium rounded-full ${
field.enabled
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{field.enabled ? 'Enabled' : 'Disabled'}
</div>
<button
onClick={(e) => {
e.stopPropagation();
removeFieldFromCategory(category.id, field.id);
}}
className="text-red-600 hover:text-red-800 text-xs font-medium"
>
Remove
</button>
</div>
</div>
))
)}
</div>
{/* Add Field Form - Below the list */}
{isAddingField && (
<div className="mb-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
<h3 className="text-sm font-medium text-gray-900 mb-3">Add Field to {category.name}</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Select Field from Application Component *
</label>
<select
value={newFieldPath}
onChange={(e) => {
const selectedField = availableFields.find(f => f.fieldPath === e.target.value);
if (selectedField) {
setNewFieldPath(selectedField.fieldPath);
setNewFieldName(selectedField.name);
} else {
setNewFieldPath(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 focus:border-blue-500 bg-white"
>
<option value="">-- Select a field --</option>
{availableFields.map((field) => (
<option key={field.fieldPath} value={field.fieldPath}>
{field.name} ({field.fieldPath})
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">
Only fields from the "Application Component" object type are available
</p>
</div>
{/* Display selected field info (read-only) */}
{newFieldPath && newFieldName && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md">
<div className="text-sm">
<span className="font-medium text-gray-700">Selected Field:</span>{' '}
<span className="text-gray-900">{newFieldName}</span>
</div>
<div className="text-xs text-gray-600 mt-1">
Field Path: <code className="bg-blue-100 px-1 py-0.5 rounded">{newFieldPath}</code>
</div>
</div>
)}
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => {
setAddingFieldToCategory(null);
setNewFieldName('');
setNewFieldPath('');
}}
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={() => addFieldToCategory(category.id)}
disabled={!newFieldName.trim() || !newFieldPath.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add Field
</button>
</div>
</div>
)}
{/* Add Field Button - Below the list */}
{!isEditing && !isAddingField && (
<div className="mb-4">
<button
onClick={() => setAddingFieldToCategory(category.id)}
className="px-3 py-1.5 text-sm font-medium text-green-600 hover:text-green-800 border border-green-300 rounded-md hover:bg-green-50"
>
Add Field
</button>
</div>
)}
</div>
);
})}
{/* Add New Category */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Add New Category</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category Name *
</label>
<input
type="text"
value={newCategoryName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<input
type="text"
value={newCategoryDescription}
onChange={(e) => 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"
/>
</div>
<div className="flex items-end">
<button
onClick={addCategory}
disabled={!newCategoryName.trim()}
className="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add Category
</button>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end gap-4">
<button
onClick={loadConfig}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Reset
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Configuration'}
</button>
</div>
{/* Info Box */}
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-900 mb-2">About Data Completeness Configuration</h3>
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
<li>Fields are dynamically loaded from the "Application Component" object type in your Jira Assets schema</li>
<li>Create custom categories to organize fields for completeness checking</li>
<li>Add fields to categories by selecting from available schema fields or entering custom field paths</li>
<li>Only enabled fields are included in the completeness score calculation</li>
<li>Changes take effect immediately after saving</li>
<li>The field path determines which property in the ApplicationDetails object is checked</li>
</ul>
</div>
</div>
);
}