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:
821
frontend/src/components/DataCompletenessConfig.tsx
Normal file
821
frontend/src/components/DataCompletenessConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user