- Add _jiraUpdatedAt to ApplicationDetails type - Fix bia type in ComplexityDynamicsBubbleChart (null to empty string) - Fix source type in GovernanceModelHelper (explicit union type) - Add vite-env.d.ts for import.meta.env types - Add node.d.ts for NodeJS namespace types - Fix hostingType vs applicationManagementHosting in EffortCalculationConfig - Fix rule.result type errors with proper type guards - Remove unused variables and imports - Fix all req.query and req.params type errors
822 lines
32 KiB
TypeScript
822 lines
32 KiB
TypeScript
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 [, 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>
|
|
);
|
|
}
|