-
-
AI Application Analysis
-
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css
index bce040a95a..2721db382e 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css
@@ -616,21 +616,31 @@ form label.error {
padding-bottom: 10px;
}
-.ai-analysis-section.header-only .ai-analysis-section-body {
- display: none;
-}
-
-.ai-analysis-detail-item {
- margin-bottom: 16px;
- padding-bottom: 16px;
- border-bottom: 1px solid #e9ecef;
-}
-
-.ai-analysis-detail-item:last-child {
- margin-bottom: 0;
- padding-bottom: 0;
- border-bottom: none;
-}
+.ai-analysis-section.header-only .ai-analysis-section-body {
+ display: none;
+}
+
+.ai-analysis-section.collapsed .ai-analysis-section-body {
+ display: none;
+}
+
+.ai-analysis-detail-item {
+ margin-bottom: 16px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #e9ecef;
+}
+
+.ai-analysis-detail-item:last-child {
+ margin-bottom: 0;
+ padding-bottom: 0;
+ border-bottom: none;
+}
+
+.ai-analysis-detail-item.last-visible {
+ margin-bottom: 0;
+ padding-bottom: 0;
+ border-bottom: none;
+}
.ai-analysis-detail-category {
font-weight: 700;
@@ -657,9 +667,9 @@ form label.error {
margin-bottom: 6px;
}
-.ai-analysis-detail-item.hidden-item {
- opacity: 0.6;
-}
+.ai-analysis-detail-item.dismissed-item {
+ opacity: 0.6;
+}
.ai-analysis-section-toggle {
display: inline-flex;
@@ -682,24 +692,62 @@ form label.error {
opacity: 1;
}
-.ai-analysis-section-toggle:hover {
- color: #1f2937;
- background-color: #f9fafb;
- border-color: #9ca3af;
-}
-
-.ai-analysis-status-chip {
- display: inline-flex;
- align-items: center;
- justify-content: center;
+.ai-analysis-section-toggle:hover {
+ color: #1f2937;
+ background-color: #f9fafb;
+ border-color: #9ca3af;
+}
+
+.ai-analysis-collapse-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+ color: #4b5563;
+ border-radius: 999px;
+ transition: color 0.2s, background-color 0.2s, border-color 0.2s, transform 0.2s;
+}
+
+.ai-analysis-collapse-toggle:hover {
+ color: #1f2937;
+ background-color: #eef2f6;
+}
+
+.ai-analysis-collapse-toggle:focus,
+.ai-analysis-collapse-toggle:focus-visible {
+ outline: none;
+ box-shadow: none;
+}
+
+.ai-analysis-status-chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
padding: 4px 10px;
font-size: 12px;
font-weight: 700;
color: #5c6b7a;
background-color: #eef2f6;
- border: 1px solid #dce3ec;
- border-radius: 999px;
-}
+ border: 1px solid #dce3ec;
+ border-radius: 999px;
+}
+
+.ai-analysis-status-chip.proceed {
+ color: #0f5132;
+ background-color: #d1e7dd;
+ border-color: #badbcc;
+}
+
+.ai-analysis-status-chip.hold {
+ color: #842029;
+ background-color: #f8d7da;
+ border-color: #f5c2c7;
+}
#ai-analysis-tab {
display: inline-flex;
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js
index 28a257b992..41ecd22540 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js
@@ -1030,20 +1030,6 @@ $(function () {
);
});
- PubSub.subscribe('update_ai_analysis_status', (msg, data) => {
- const $indicator = $('#ai_analysis_status');
- const status = data?.status;
-
- $indicator.removeClass('proceed hold');
-
- if (status === 'proceed' || status === 'hold') {
- $indicator.addClass(status).show();
- return;
- }
-
- $indicator.hide();
- });
-
PubSub.subscribe('update_application_emails_count', (msg, data) => {
if (data.itemCount || data.itemCount === 0) {
tabCounters.emails = data.itemCount;
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js
index dfd5d2abb1..254595cbdd 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js
@@ -3,13 +3,36 @@
* Renders a stable sectioned view for AI-generated analysis results.
*/
-const hiddenSectionVisibility = {
+const dismissedSectionVisibility = {
error: false,
warning: false,
summary: false,
- nextStep: false
+ recommendation: false
};
+function getAnalysisLabels() {
+ const labels = document.getElementById('aiAnalysisLabels')?.dataset ?? {};
+
+ return {
+ errors: labels.errors || 'Errors',
+ warnings: labels.warnings || 'Warnings',
+ summaries: labels.summaries || 'Summaries',
+ recommendation: labels.recommendation || labels.recommendations || 'Recommendation',
+ proceed: labels.proceed || 'Proceed',
+ hold: labels.hold || 'Hold',
+ noErrors: labels.noErrors || 'No errors',
+ noWarnings: labels.noWarnings || 'No warnings',
+ showDismissed: labels.showDismissed || 'Show dismissed items',
+ hideDismissed: labels.hideDismissed || 'Hide dismissed items',
+ dismiss: labels.dismiss || 'Dismiss',
+ restore: labels.restore || 'Restore',
+ dismissTitle: labels.dismissTitle || 'Dismiss this item',
+ restoreTitle: labels.restoreTitle || 'Restore this item',
+ collapseTitle: labels.collapseTitle || 'Collapse section',
+ expandTitle: labels.expandTitle || 'Expand section'
+ };
+}
+
function bindTemplateAction($element, actionData) {
$element.attr('data-id', actionData.id).attr('data-type', actionData.type);
}
@@ -20,16 +43,11 @@ function bindTemplateValue($item, key, value) {
return;
}
- if (key === 'hide-btn' || key === 'show-btn') {
+ if (key === 'dismiss-btn' || key === 'restore-btn') {
bindTemplateAction($element, value);
return;
}
- if (key === 'icon') {
- $element.addClass(value);
- return;
- }
-
$element.text(value);
}
@@ -59,15 +77,15 @@ function getFindingDetailText(item) {
}
}
-function updateAnalysisTabStatus(recommendation) {
- let status = '';
- if (recommendation) {
- status = recommendation.decision === 'PROCEED' ? 'proceed' : 'hold';
+function normalizeDecision(decision) {
+ if (typeof decision !== 'string') {
+ return '';
}
- PubSub.publish('update_ai_analysis_status', {
- status: status
- });
+ const normalized = decision.trim().toUpperCase();
+ return normalized === 'PROCEED' || normalized === 'HOLD'
+ ? normalized
+ : '';
}
function normalizeFindings(items, fallbackType) {
@@ -75,7 +93,7 @@ function normalizeFindings(items, fallbackType) {
error: 'Error',
warning: 'Warning',
summary: 'Summary',
- nextStep: 'Next step'
+ recommendation: 'Recommendation'
};
return (items || [])
@@ -83,246 +101,249 @@ function normalizeFindings(items, fallbackType) {
.map((item, index) => ({
...item,
id: item.id || `${fallbackType}-${index}`,
- hidden: item.hidden === true,
+ dismissed: item.dismissed === true,
title: item.title || item.category || fallbackTitles[fallbackType] || 'Item',
detail: item.detail || item.message || ''
}));
}
-function normalizeRecommendation(recommendation) {
- if (!recommendation || typeof recommendation !== 'object') {
- return null;
- }
-
- const decision = typeof recommendation.decision === 'string'
- ? recommendation.decision.trim().toUpperCase()
- : '';
- const rationale = typeof recommendation.rationale === 'string'
- ? recommendation.rationale.trim()
- : '';
-
- if (decision !== 'PROCEED' && decision !== 'HOLD') {
- return null;
- }
-
- return {
- decision: decision,
- rationale: rationale
- };
-}
-
function createFindingItem(item, type, hidden) {
- const templateName = hidden ? 'hidden-item' : 'active-item';
- const actionKey = hidden ? 'show-btn' : 'hide-btn';
- return createItemFromTemplate(templateName, {
+ const labels = getAnalysisLabels();
+ const templateName = hidden ? 'dismissed-item' : 'active-item';
+ const actionKey = hidden ? 'restore-btn' : 'dismiss-btn';
+ const $item = createItemFromTemplate(templateName, {
category: item.title,
message: getFindingDetailText(item),
[actionKey]: { id: item.id, type: type }
});
+
+ if (hidden) {
+ $item.find('[data-element="restore-text"]').text(labels.restore);
+ $item.find('[data-element="restore-btn"]').attr('title', labels.restoreTitle);
+ } else {
+ $item.find('[data-element="dismiss-text"]').text(labels.dismiss);
+ $item.find('[data-element="dismiss-btn"]').attr('title', labels.dismissTitle);
+ }
+
+ return $item;
}
-function renderSection(section) {
- const $section = createItemFromTemplate('section', {
- icon: section.icon,
- title: section.title
+function updateVisibleItemLayout($items) {
+ const $allItems = $items.children('.ai-analysis-detail-item');
+ const $visibleItems = $allItems.filter(function() {
+ return this.style.display !== 'none';
});
- $section.addClass(section.sectionClass);
- if (section.activeItems.length === 0) {
- $section.addClass('compact');
+ $allItems.removeClass('last-visible');
+ $visibleItems.last().addClass('last-visible');
+}
+
+function formatSectionTitle(title, count) {
+ return `${title} (${count})`;
+}
+
+function configureSectionStatus($status, text, statusClass) {
+ if (!text) {
+ return;
}
- const $items = $section.find('[data-element="items"]');
- const $status = $section.find('[data-element="status-chip"]');
- const $toggle = $section.find('[data-element="hidden-toggle"]');
- const hiddenCount = section.hiddenItems.length;
- const isHiddenVisible = hiddenSectionVisibility[section.itemType] === true;
+ $status
+ .removeClass('proceed hold')
+ .addClass('ai-analysis-status-chip');
- if (section.headerOnlyText) {
- $section.addClass('header-only');
- $status
- .addClass('ai-analysis-status-chip')
- .text(section.headerOnlyText)
- .show();
- $toggle.hide();
- return $section;
+ if (statusClass) {
+ $status.addClass(statusClass);
}
- if (section.activeItems.length > 0 || hiddenCount > 0) {
- section.allItems.forEach(item => {
- const isHidden = item.hidden === true;
- const $item = createFindingItem(item, section.itemType, isHidden);
- if (isHidden && !isHiddenVisible) {
- $item.hide();
- }
+ $status
+ .text(text)
+ .show();
+}
- $items.append($item);
+function configureCollapseToggle($section, $collapseToggle) {
+ const labels = getAnalysisLabels();
+ $collapseToggle
+ .off('click')
+ .on('click', function() {
+ const isCollapsed = $section.toggleClass('collapsed').hasClass('collapsed');
+ const $icon = $(this).find('i');
+
+ $(this)
+ .attr('aria-expanded', (!isCollapsed).toString())
+ .attr('title', isCollapsed ? labels.expandTitle : labels.collapseTitle);
+
+ $icon
+ .toggleClass('fa-chevron-down', !isCollapsed)
+ .toggleClass('fa-chevron-up', isCollapsed);
});
+}
+
+function createAnalysisSection(config) {
+ const groups = splitFindingsByVisibility(config.items);
+ const hasItems = config.items.length > 0;
+
+ return {
+ ...config,
+ activeItems: groups.activeItems,
+ allItems: config.items,
+ hiddenItems: groups.hiddenItems,
+ hasItems
+ };
+}
+
+function appendSectionItems($items, section, isDismissedVisible) {
+ if (section.activeItems.length === 0 && section.hiddenItems.length === 0) {
+ return;
}
- if (hiddenCount > 0) {
- $toggle
- .css('visibility', 'visible')
- .text(isHiddenVisible
- ? 'Hide hidden items'
- : 'Show hidden items')
- .prop('disabled', false)
- .show()
- .off('click')
- .on('click', function() {
- const shouldShow = hiddenSectionVisibility[section.itemType] !== true;
- hiddenSectionVisibility[section.itemType] = shouldShow;
- $items.find('.hidden-item').toggle(shouldShow);
- $toggle.text(
- shouldShow
- ? 'Hide hidden items'
- : 'Show hidden items'
- );
- });
- } else {
- hiddenSectionVisibility[section.itemType] = false;
+ section.allItems.forEach(item => {
+ const isHidden = item.dismissed === true;
+ const $item = createFindingItem(item, section.itemType, isHidden);
+
+ if (isHidden && !isDismissedVisible) {
+ $item.hide();
+ }
+
+ $items.append($item);
+ });
+
+ updateVisibleItemLayout($items);
+}
+
+function configureDismissedItemsToggle($items, $toggle, section, isDismissedVisible) {
+ const labels = getAnalysisLabels();
+ const hiddenCount = section.hiddenItems.length;
+
+ if (hiddenCount === 0) {
+ dismissedSectionVisibility[section.itemType] = false;
$toggle
- .text('Show hidden items')
+ .text(labels.showDismissed)
.css('visibility', 'hidden')
.prop('disabled', true)
.show();
+ return;
}
- return $section;
+ $toggle
+ .css('visibility', 'visible')
+ .text(isDismissedVisible ? labels.hideDismissed : labels.showDismissed)
+ .prop('disabled', false)
+ .show()
+ .off('click')
+ .on('click', function() {
+ const shouldShow = dismissedSectionVisibility[section.itemType] !== true;
+ dismissedSectionVisibility[section.itemType] = shouldShow;
+ $items.find('.dismissed-item').toggle(shouldShow);
+ updateVisibleItemLayout($items);
+ $toggle.text(shouldShow ? labels.hideDismissed : labels.showDismissed);
+ });
}
-function renderRecommendationSection(recommendation) {
- if (!recommendation) {
- return null;
- }
-
- const shouldProceed = recommendation.decision === 'PROCEED';
+function renderSection(section) {
const $section = createItemFromTemplate('section', {
- icon: 'fl-info-circle',
- title: 'Recommendation'
+ title: section.title
});
- $section.addClass('recommendation compact');
- $section.find('[data-element="status-chip"]')
- .addClass('ai-analysis-status-badge')
- .addClass(shouldProceed ? 'proceed' : 'hold')
- .text(shouldProceed ? 'Proceed' : 'Hold')
- .show();
- $section.find('[data-element="hidden-toggle"]').remove();
- $section.find('[data-element="items"]').append(
- $('
')
- .text(recommendation.rationale || 'No rationale provided.')
- );
+ $section
+ .addClass(section.sectionClass)
+ .toggleClass('compact', section.activeItems.length === 0)
+ .toggleClass('header-only', !section.hasItems);
+
+ const $items = $section.find('[data-element="items"]');
+ const $status = $section.find('[data-element="status-chip"]');
+ const $toggle = $section.find('[data-element="hidden-toggle"]');
+ const $collapseToggle = $section.find('[data-element="collapse-toggle"]');
+ const isDismissedVisible = dismissedSectionVisibility[section.itemType] === true;
+
+ configureSectionStatus($status, section.statusText, section.statusClass);
+ configureCollapseToggle($section, $collapseToggle);
+ $collapseToggle.toggle(section.hasItems);
+
+ appendSectionItems($items, section, isDismissedVisible);
+ configureDismissedItemsToggle($items, $toggle, section, isDismissedVisible);
return $section;
}
function splitFindingsByVisibility(items) {
return {
- activeItems: items.filter(item => item.hidden !== true),
- hiddenItems: items.filter(item => item.hidden === true)
+ activeItems: items.filter(item => item.dismissed !== true),
+ hiddenItems: items.filter(item => item.dismissed === true)
};
}
function buildAnalysisSections(analysisData) {
- const recommendation = normalizeRecommendation(analysisData.recommendation);
+ const labels = getAnalysisLabels();
+ const decision = normalizeDecision(analysisData.decision);
const errors = normalizeFindings(analysisData.errors, 'error');
const warnings = normalizeFindings(analysisData.warnings, 'warning');
- const summaries = normalizeFindings(analysisData.summaries || analysisData.recommendations, 'summary');
- const nextSteps = normalizeFindings(analysisData.nextSteps, 'nextStep');
- const errorGroups = splitFindingsByVisibility(errors);
- const warningGroups = splitFindingsByVisibility(warnings);
- const summaryGroups = splitFindingsByVisibility(summaries);
- const nextStepGroups = splitFindingsByVisibility(nextSteps);
+ const summaries = normalizeFindings(analysisData.summaries, 'summary');
+ const recommendations = normalizeFindings(analysisData.recommendations, 'recommendation');
+ let recommendationStatusText = '';
+ if (decision === 'PROCEED') {
+ recommendationStatusText = labels.proceed;
+ } else if (decision === 'HOLD') {
+ recommendationStatusText = labels.hold;
+ }
return {
- recommendation,
sections: [
- {
- title: 'Errors',
- icon: 'fl-times-circle',
+ createAnalysisSection({
+ title: formatSectionTitle(labels.errors, errors.length),
sectionClass: 'error',
itemType: 'error',
- headerOnlyText: errorGroups.activeItems.length === 0 && errorGroups.hiddenItems.length === 0 ? 'No errors' : null,
- activeItems: errorGroups.activeItems,
- allItems: errors,
- hiddenItems: errorGroups.hiddenItems
- },
- {
- title: 'Warnings',
- icon: 'fl-exclamation-triangle',
+ items: errors
+ }),
+ createAnalysisSection({
+ title: formatSectionTitle(labels.warnings, warnings.length),
sectionClass: 'warning',
itemType: 'warning',
- headerOnlyText: warningGroups.activeItems.length === 0 && warningGroups.hiddenItems.length === 0 ? 'No warnings' : null,
- activeItems: warningGroups.activeItems,
- allItems: warnings,
- hiddenItems: warningGroups.hiddenItems
- },
- {
- title: 'Summary',
- icon: 'fl-info-circle',
+ items: warnings
+ }),
+ createAnalysisSection({
+ title: formatSectionTitle(labels.summaries, summaries.length),
sectionClass: 'summary',
itemType: 'summary',
- headerOnlyText: summaryGroups.activeItems.length === 0 && summaryGroups.hiddenItems.length === 0 ? 'No summary' : null,
- activeItems: summaryGroups.activeItems,
- allItems: summaries,
- hiddenItems: summaryGroups.hiddenItems
- },
- {
- title: 'Next Steps',
- icon: 'fl-check-square',
- sectionClass: 'next-steps',
- itemType: 'nextStep',
- headerOnlyText: nextStepGroups.activeItems.length === 0 && nextStepGroups.hiddenItems.length === 0 ? 'No next steps' : null,
- activeItems: nextStepGroups.activeItems,
- allItems: nextSteps,
- hiddenItems: nextStepGroups.hiddenItems
- }
+ items: summaries
+ }),
+ createAnalysisSection({
+ title: labels.recommendation,
+ sectionClass: 'recommendation',
+ itemType: 'recommendation',
+ statusText: recommendationStatusText,
+ statusClass: decision ? decision.toLowerCase() : '',
+ items: recommendations
+ })
]
};
}
-function hasAnyAnalysisContent(recommendation, sections) {
- if (recommendation) {
- return true;
- }
-
- return sections.some(section => section.allItems.length > 0);
-}
-
function bindAnalysisItemActions($sections) {
$sections.off('click');
- $sections.on('click', '[data-element="hide-btn"]', function(e) {
+ $sections.on('click', '[data-element="dismiss-btn"]', function(e) {
e.preventDefault();
const itemId = $(this).data('id');
- hideAnalysisItem(itemId);
+ dismissAnalysisItem(itemId);
});
- $sections.on('click', '[data-element="show-btn"]', function(e) {
+ $sections.on('click', '[data-element="restore-btn"]', function(e) {
e.preventDefault();
const itemId = $(this).data('id');
- showAnalysisItem(itemId);
+ restoreAnalysisItem(itemId);
});
}
function renderRealAIAnalysis(analysisData) {
- const { recommendation, sections } = buildAnalysisSections(analysisData);
+ const { sections } = buildAnalysisSections(analysisData);
const $sections = $('#aiAnalysisSections');
$sections.empty();
- const $recommendationSection = renderRecommendationSection(recommendation);
- const hasRecommendation = $recommendationSection !== null;
- if ($recommendationSection) {
- $sections.append($recommendationSection);
- }
sections.forEach(section => {
$sections.append(renderSection(section));
});
- updateAnalysisTabStatus(recommendation);
-
const $noDataMessage = $('#aiAnalysisNoData');
- if (!hasRecommendation && !hasAnyAnalysisContent(recommendation, sections)) {
+ if ($sections.children().length === 0) {
$noDataMessage.show();
$sections.hide();
} else {
@@ -333,36 +354,35 @@ function renderRealAIAnalysis(analysisData) {
bindAnalysisItemActions($sections);
}
-globalThis.hideAnalysisItem = function(itemId) {
+globalThis.dismissAnalysisItem = function(itemId) {
const applicationId = $('#DetailsViewApplicationId').val();
unity.grantManager.grantApplications.grantApplication
- .hideAIAnalysisItem(applicationId, itemId)
+ .dismissAIAnalysisItem(applicationId, itemId)
.then(function() {
loadAIAnalysis();
})
.catch(function() {
- abp.message.error('Failed to hide the item. Please try again.');
+ abp.message.error('Failed to dismiss the item. Please try again.');
});
}
-globalThis.showAnalysisItem = function(itemId) {
+globalThis.restoreAnalysisItem = function(itemId) {
const applicationId = $('#DetailsViewApplicationId').val();
unity.grantManager.grantApplications.grantApplication
- .showAIAnalysisItem(applicationId, itemId)
+ .restoreAIAnalysisItem(applicationId, itemId)
.then(function() {
loadAIAnalysis();
})
.catch(function() {
- abp.message.error('Failed to show the item. Please try again.');
+ abp.message.error('Failed to restore the item. Please try again.');
});
}
function resetAnalysisView() {
$('#aiAnalysisSections').empty().hide();
$('#aiAnalysisNoData').show();
- updateAnalysisTabStatus(null);
}
function tryParseRawAnalysis(analysisJson) {
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.css
index a1595e980c..6a9a913c3d 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.css
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.css
@@ -8,7 +8,9 @@
display: flex;
flex-direction: row;
justify-content: space-between;
- align-items: flex-end;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 8px 8px;
margin-bottom: 8px;
}
@@ -18,11 +20,39 @@
}
.submission-title {
- display: inline-block;
+ display: flex;
+ align-items: center;
+ height: 36px;
+ padding-top: 0;
}
.submission-button-section {
display: flex;
+ flex: 1 1 0;
+ margin-left: auto;
+ flex-wrap: wrap;
+ align-items: center;
+ align-content: center;
+ justify-content: flex-end;
+ gap: 6px;
+ min-width: 0;
+}
+
+.submission-button-section .btn {
+ flex: 0 0 auto;
+ height: 36px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.submission-button-section .button-content {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ white-space: nowrap;
}
#ChefsAttachmentsTable_paginate {
@@ -33,58 +63,26 @@
/* AI Summary Row Styles */
.ai-summary-row {
border-left: 4px solid var(--bc-colors-blue-primary);
- margin: -8px -10px 5px;
background: #faf9f8;
padding: 10px 15px;
- opacity: 0;
- transition: opacity 0.5s ease-in-out;
-}
-
-.ai-summary-row.fade-in {
- opacity: 1;
- animation: fadeIn 0.5s ease-in forwards;
}
-.ai-summary-row.fade-out {
- opacity: 0;
- animation: fadeOut 0.5s ease-out forwards;
+#ChefsAttachmentsTable td.ai-summary-cell {
+ padding: 0 !important;
}
-@keyframes fadeIn {
- from {
- opacity: 0;
- transform: translateY(-10px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-@keyframes fadeOut {
- from {
- opacity: 1;
- transform: translateY(0);
- }
- to {
- opacity: 0;
- transform: translateY(-10px);
- }
-}
-
-.ai-summary-content {
- font-size: 14px;
- line-height: 1.6;
+#ChefsAttachmentsTable .ai-summary-content {
color: #292929;
+ padding: 8px 12px;
}
-.ai-summary-content strong {
+#ChefsAttachmentsTable .ai-summary-content strong {
color: var(--bc-colors-blue-primary);
font-weight: 600;
font-size: 13px;
}
-.ai-summary-content p {
+#ChefsAttachmentsTable .ai-summary-content p {
margin-bottom: 0;
color: #495057;
white-space: pre-wrap;
@@ -94,39 +92,3 @@
#ChefsAttachmentsTable tr.shown {
background-color: rgba(13, 110, 253, 0.05);
}
-
-/* Disabled state for AI summary toggle button */
-#toggleAllAISummaries:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- pointer-events: none;
-}
-
-#toggleAllAISummaries:disabled i {
- opacity: 0.5;
-}
-
-/* Fix double scrollbar issue when AI summaries are expanded */
-#ChefsAttachmentsTable_wrapper .dt-scroll-body {
- max-height: none !important;
- overflow-y: visible !important;
-}
-
-/* Ensure table wrapper uses full width */
-#ChefsAttachmentsTable_wrapper {
- width: 100%;
-}
-
-/* Ensure scroll container properly sizes */
-#ChefsAttachmentsTable_wrapper .dt-scroll {
- overflow-x: auto;
-}
-
-#ChefsAttachmentsTable_wrapper .dt-scroll-head .dt-scroll-headInner,
-#ChefsAttachmentsTable_wrapper .dt-scroll-head .dt-scroll-headInner table {
- width: 100% !important;
-}
-
-#ChefsAttachmentsTable_wrapper .dt-scroll-body table {
- width: 100% !important;
-}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml
index 5b5a3f3698..39651c6cb0 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml
@@ -1,43 +1,37 @@
-@using Microsoft.Extensions.Localization
+@using Microsoft.Extensions.Localization
@using Unity.GrantManager.Localization
@inject IStringLocalizer
L
diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AiContractBoundaryTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AiContractBoundaryTests.cs
new file mode 100644
index 0000000000..4ab85251d5
--- /dev/null
+++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AiContractBoundaryTests.cs
@@ -0,0 +1,46 @@
+using Shouldly;
+using Unity.GrantManager.Attachments;
+using Unity.GrantManager.GrantApplications;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Unity.GrantManager.GrantApplications.Automation;
+
+public class AiContractBoundaryTests(ITestOutputHelper outputHelper) : GrantManagerApplicationTestBase(outputHelper)
+{
+ [Fact]
+ public void IApplicationScoringAppService_Should_Be_Resolvable_From_Remote_Contract_Boundary()
+ {
+ var service = GetRequiredService
();
+
+ service.ShouldNotBeNull();
+ service.GetType().Name.ShouldContain("Proxy");
+ }
+
+ [Fact]
+ public void IApplicationAnalysisAppService_Should_Be_Resolvable_From_Remote_Contract_Boundary()
+ {
+ var service = GetRequiredService();
+
+ service.ShouldNotBeNull();
+ service.GetType().Name.ShouldContain("Proxy");
+ }
+
+ [Fact]
+ public void IApplicationContentAppService_Should_Be_Resolvable_From_Remote_Contract_Boundary()
+ {
+ var service = GetRequiredService();
+
+ service.ShouldNotBeNull();
+ service.GetType().Name.ShouldContain("Proxy");
+ }
+
+ [Fact]
+ public void IAttachmentSummaryAppService_Should_Be_Resolvable_From_Remote_Contract_Boundary()
+ {
+ var service = GetRequiredService();
+
+ service.ShouldNotBeNull();
+ service.GetType().Name.ShouldContain("Proxy");
+ }
+}
diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationAnalysisAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationAnalysisAppServiceTests.cs
new file mode 100644
index 0000000000..46c07f08ef
--- /dev/null
+++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationAnalysisAppServiceTests.cs
@@ -0,0 +1,31 @@
+using NSubstitute;
+using Shouldly;
+using System;
+using System.Threading.Tasks;
+using Unity.AI.Operations;
+using Unity.GrantManager.GrantApplications;
+using Volo.Abp.Features;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Unity.GrantManager.GrantApplications.Automation;
+
+public class ApplicationAnalysisAppServiceTests(ITestOutputHelper outputHelper) : GrantManagerApplicationTestBase(outputHelper)
+{
+ [Fact]
+ public async Task GenerateApplicationAnalysisAsync_Should_Return_Completed_Result()
+ {
+ var featureChecker = Substitute.For();
+ featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis").Returns(true);
+
+ var analysisService = Substitute.For();
+ analysisService.RegenerateAndSaveAsync(Arg.Any(), Arg.Any()).Returns("analysis");
+
+ var service = new ApplicationAnalysisAppService(analysisService, featureChecker);
+
+ var result = await service.GenerateApplicationAnalysisAsync(Guid.NewGuid());
+
+ result.ShouldNotBeNull();
+ result.Completed.ShouldBeTrue();
+ }
+}
diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationContentAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationContentAppServiceTests.cs
new file mode 100644
index 0000000000..f8e76b15f7
--- /dev/null
+++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/ApplicationContentAppServiceTests.cs
@@ -0,0 +1,38 @@
+using NSubstitute;
+using Shouldly;
+using System;
+using System.Threading.Tasks;
+using Unity.GrantManager.GrantApplications;
+using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
+using Unity.AI.Automation;
+using Volo.Abp.Features;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Unity.GrantManager.GrantApplications.Automation;
+
+public class ApplicationContentAppServiceTests(ITestOutputHelper outputHelper) : GrantManagerApplicationTestBase(outputHelper)
+{
+ [Fact]
+ public async Task GenerateContentAsync_Should_Return_Completed_Result()
+ {
+ var featureChecker = Substitute.For();
+ featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries").Returns(true);
+ featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis").Returns(true);
+ featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(true);
+
+ var queue = Substitute.For();
+ queue.QueueApplicationPipelineAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.CompletedTask);
+ var currentTenant = Substitute.For();
+ currentTenant.Id.Returns(Guid.NewGuid());
+
+ var service = new ApplicationContentAppService(queue, featureChecker, currentTenant);
+
+ var result = await service.GenerateContentAsync(Guid.NewGuid());
+
+ result.ShouldNotBeNull();
+ result.Completed.ShouldBeTrue();
+ await queue.Received(1).QueueApplicationPipelineAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+}
diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AttachmentSummaryAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AttachmentSummaryAppServiceTests.cs
new file mode 100644
index 0000000000..759a31cd54
--- /dev/null
+++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AttachmentSummaryAppServiceTests.cs
@@ -0,0 +1,50 @@
+using NSubstitute;
+using Shouldly;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Unity.GrantManager.Attachments;
+using Unity.AI.Operations;
+using Volo.Abp.Features;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Unity.GrantManager.GrantApplications.Automation;
+
+public class AttachmentSummaryAppServiceTests(ITestOutputHelper outputHelper) : GrantManagerApplicationTestBase(outputHelper)
+{
+ [Fact]
+ public async Task GenerateAttachmentSummaryAsync_Should_Return_Completed_Result()
+ {
+ var featureChecker = Substitute.For();
+ featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries").Returns(true);
+
+ var summaryService = Substitute.For();
+ summaryService.GenerateAndSaveAsync(Arg.Any(), Arg.Any()).Returns("summary");
+
+ var service = new AttachmentSummaryAppService(summaryService, featureChecker);
+
+ var result = await service.GenerateAttachmentSummaryAsync(Guid.NewGuid());
+
+ result.ShouldNotBeNull();
+ result.Completed.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task GenerateAttachmentSummariesAsync_Should_Return_Completed_Results()
+ {
+ var featureChecker = Substitute.For();
+ featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries").Returns(true);
+
+ var summaryService = Substitute.For();
+ summaryService.GenerateAndSaveAsync(Arg.Any>(), Arg.Any())
+ .Returns(Task.FromResult(new List { "summary" }));
+
+ var service = new AttachmentSummaryAppService(summaryService, featureChecker);
+
+ var result = await service.GenerateAttachmentSummariesAsync(new List { Guid.NewGuid() });
+
+ result.ShouldHaveSingleItem();
+ result[0].Completed.ShouldBeTrue();
+ }
+}
diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs
index ed2eb392ce..047d2a807d 100644
--- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs
+++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs
@@ -1,149 +1,109 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using System;
-using System.Runtime.CompilerServices;
+using System.Collections.Generic;
using System.Threading.Tasks;
-using Unity.AI;
-using Unity.AI.Operations;
-using Unity.AI.Settings;
using Unity.GrantManager.Applications;
+using Unity.GrantManager.Attachments;
+using Unity.GrantManager.GrantApplications;
using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
+using Unity.GrantManager.GrantApplications.Automation.Events;
using Volo.Abp.EventBus.Local;
using Volo.Abp.Features;
using Volo.Abp.MultiTenancy;
-using Volo.Abp.Settings;
using Xunit;
using Xunit.Abstractions;
+
namespace Unity.GrantManager.GrantApplications.Automation;
+
public class RunApplicationAIPipelineJobTests(ITestOutputHelper outputHelper) : GrantManagerApplicationTestBase(outputHelper)
{
private static RunApplicationAIPipelineJob BuildJob(
IFeatureChecker featureChecker,
- IApplicationScoringService? scoringService = null,
- IAIService? aiService = null,
- ISettingProvider? settingProvider = null,
- bool formAutomaticAIEnabled = true)
+ IApplicationScoringAppService? scoringService = null)
{
- var ai = aiService ?? Substitute.For();
- ai.IsAvailableAsync().Returns(true);
-
- var settings = settingProvider ?? Substitute.For();
- if (settingProvider == null)
- {
- settings.GetOrNullAsync(AISettings.AutomaticGenerationEnabled).Returns("true");
- }
-
- var aiServices = new AIOperationServices(
- Substitute.For(),
- Substitute.For(),
- scoringService ?? Substitute.For(),
- ai);
-
- var formId = Guid.NewGuid();
-
- var application = (Application)RuntimeHelpers.GetUninitializedObject(typeof(Application));
- application.ApplicationFormId = formId;
-
- var applicationForm = (ApplicationForm)RuntimeHelpers.GetUninitializedObject(typeof(ApplicationForm));
- applicationForm.AutomaticallyGenerateAIAnalysis = formAutomaticAIEnabled;
-
- var applicationRepository = Substitute.For();
- applicationRepository.GetAsync(Arg.Any(), Arg.Any()).Returns(application);
+ var attachmentService = Substitute.For();
+ attachmentService.GenerateAttachmentSummariesAsync(Arg.Any>(), Arg.Any())
+ .Returns(Task.FromResult(new List()));
- var applicationFormRepository = Substitute.For();
- applicationFormRepository.GetAsync(Arg.Any(), Arg.Any()).Returns(applicationForm);
-
- var applicationFormServices = new ApplicationFormServices(applicationRepository, applicationFormRepository);
+ var analysisService = Substitute.For();
+ analysisService.GenerateApplicationAnalysisAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(new ApplicationAnalysisResultDto { Completed = true }));
return new RunApplicationAIPipelineJob(
- aiServices,
- applicationFormServices,
+ Substitute.For(),
+ attachmentService,
+ analysisService,
+ scoringService ?? Substitute.For(),
featureChecker,
Substitute.For(),
Substitute.For(),
- settings,
NullLogger.Instance);
}
+
[Fact]
public async Task ExecuteAsync_Should_Skip_Scoring_When_Feature_Disabled()
{
- // Arrange - scoring feature OFF
var featureChecker = Substitute.For();
featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(false);
featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries").Returns(false);
featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis").Returns(false);
- var scoringService = Substitute.For();
- var job = BuildJob(featureChecker, scoringService);
- // Act
- await job.ExecuteAsync(new RunApplicationAIPipelineJobArgs { ApplicationId = Guid.NewGuid() });
- // Assert - scoring service never called
- await scoringService.DidNotReceive().RegenerateAndSaveAsync(Arg.Any(), Arg.Any());
- }
- [Fact]
- public async Task Should_Skip_Pipeline_When_AutomaticGenerationEnabled_Is_False()
- {
- // Arrange - automatic generation OFF at tenant level
- var settings = Substitute.For();
- settings.GetOrNullAsync(AISettings.AutomaticGenerationEnabled).Returns("false");
+ var scoringService = Substitute.For();
+ scoringService.GenerateApplicationScoringAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(new ApplicationScoringResultDto { Completed = true }));
- var featureChecker = Substitute.For();
- featureChecker.IsEnabledAsync(Arg.Any()).Returns(true);
-
- var scoringService = Substitute.For();
- var job = BuildJob(featureChecker, scoringService, settingProvider: settings);
+ var job = BuildJob(featureChecker, scoringService);
- // Act
await job.ExecuteAsync(new RunApplicationAIPipelineJobArgs { ApplicationId = Guid.NewGuid() });
- // Assert - pipeline never reaches any AI service
- await scoringService.DidNotReceive().RegenerateAndSaveAsync(Arg.Any(), Arg.Any());
- await featureChecker.DidNotReceive().IsEnabledAsync(Arg.Any());
+ await scoringService.DidNotReceive().GenerateApplicationScoringAsync(Arg.Any(), Arg.Any());
}
[Fact]
- public async Task Should_Run_Pipeline_When_AutomaticGenerationEnabled_Is_True()
+ public async Task ExecuteAsync_Should_Run_Scoring_When_Feature_Enabled()
{
- // Arrange - automatic generation ON, all features enabled
- var settings = Substitute.For