diff --git a/applications/Unity.GrantManager/.github/agents/pre-readiness-deep.agent.md b/applications/Unity.GrantManager/.github/agents/pre-readiness-deep.agent.md new file mode 100644 index 0000000000..bd78117a88 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/pre-readiness-deep.agent.md @@ -0,0 +1,262 @@ +--- +name: pr-readiness-deep +description: Deep PR quality gate that actively scans and fixes SonarQube issues, CodeQL vulnerabilities, and ABP architecture violations. +--- + +# PR Readiness Agent (Deep Scan) + +Final quality gate for Unity Grant Manager PRs with active issue detection and remediation. + +## Inputs +- Branch diff, build/test status, target branch + +## Core Capabilities +- **Active SonarQube scanning**: Use `sonarqube_analyze_file` to scan modified files +- **Security issue detection**: Use `sonarqube_list_potential_security_issues` for security hotspots +- **Automatic fixes**: Apply fixes for detected SonarQube and CodeQL issues +- **ABP validation**: Verify architecture compliance +- **Cypress E2E testing**: Run frontend integration tests from Unity.AutoUI project + +## Quality Checks Workflow + +### Step 1: Analyze Modified Files +For each changed file in the PR: +1. **Run SonarQube analysis**: `sonarqube_analyze_file` on the file +2. **Parse results**: Identify critical/blocker/major issues +3. **Fix issues automatically**: Apply fixes for common patterns +4. **Re-scan**: Verify fixes resolved the issues + +### Step 2: Security Scan +1. **List security issues**: `sonarqube_list_potential_security_issues` +2. **Check CodeQL alerts**: Review GitHub Security tab findings +3. **Prioritize**: Critical > High > Medium +4. **Fix**: Apply security patches + +### Step 3: ABP Architecture +- Layer boundaries (Domain → Application → Web) +- Repository/DTO/AutoMapper conventions +- Permissions and localization keys +- EF migrations (if schema changes) + +### Step 4: Build & Tests +```bash +# Backend build and unit tests +dotnet build Unity.GrantManager.sln --no-restore +dotnet test Unity.GrantManager.sln --no-build + +# Frontend Cypress E2E tests +cd ../Unity.AutoUI +npm install +npx cypress run +``` + +### Step 5: Cypress E2E Testing +1. **Navigate to Cypress project**: `cd applications/Unity.AutoUI` +2. **Run tests**: Use `npx cypress run` for headless, `npx cypress open` for interactive +3. **Parse results**: Check for failed tests, screenshots, videos +4. **Report**: Include test pass/fail status in output + +## SonarQube Issues to Auto-Fix + +**Critical/Blocker (Must Fix)**: +- SQL injection → Convert to EF LINQ +- Missing `[Authorize]` → Add authorization attributes +- Exposing domain entities → Convert to DTO pattern +- Hardcoded credentials → Move to configuration +- Resource leaks → Add proper disposal + +**Major (Should Fix)**: +- High complexity → Extract methods +- Code duplication → Create shared utilities +- Empty catch blocks → Add proper logging + +**Process**: +1. Use `sonarqube_analyze_file` on each modified file +2. Parse issue severity, rule, and location +3. Apply appropriate fix pattern (see Common Fixes below) +4. Re-analyze to confirm resolution + +### CodeQL Security (Check GitHub Security Tab) +**Must Fix (Critical/High)**: +- SQL injection +- Path traversal → Validate file paths +- Missing authorization +- Logging sensitive data +- Hardcoded secrets + +**After fixes**: Verify alerts cleared in GitHub Security tab + +## Action Mode + +When invoked: +1. **Scan all changed files** using `sonarqube_analyze_file` +2. **List security issues** using `sonarqube_list_potential_security_issues` +3. **Apply fixes** for detected issues using patterns below +4. **Run backend tests**: `dotnet test Unity.GrantManager.sln --no-build` +5. **Run Cypress E2E tests**: Navigate to Unity.AutoUI and run `npx cypress run` +6. **Validate**: Re-run analysis to confirm resolution +7. **Report**: Summary of issues found/fixed and test results + +## Output + +**After Scanning & Fixing**: +1. **Summary Report**: + - Files analyzed: X + - Issues found: Y (Critical: Z, High: W) + - Issues fixed: N + - Remaining issues: M + - Backend tests: X passed, Y failed + - Cypress E2E tests: X passed, Y failed (with links to screenshots/videos if failures) +2. **Go/No-Go Decision**: + - ✅ GO: No critical/blocker issues remain, all tests pass + - ❌ NO-GO: Critical issues, test failures, or security vulnerabilities require intervention + - ⚠️ CONDITIONAL: Minor issues present but can merge with follow-up tasks +3. **Detailed Findings**: + - Fixed automatically: List with file:line + - Manual review needed: List with reasoning +4. **Quality Metrics**: + - SonarQube gate: Pass/Fail + - CodeQL alerts: Count by severity + - Code coverage: % + - Build/test status: Pass/Fail + - **Cypress E2E tests**: Pass/Fail (X passed, Y failed) + - **Cypress artifacts**: Screenshots/videos if failures + +## Requirements + +- ✅ All SonarQube critical/blocker issues auto-fixed +- ✅ Security rating A/B (after fixes) +- ✅ No CodeQL critical/high vulnerabilities +- ✅ Code coverage ≥80% +- ✅ Build/tests pass (backend unit tests) +- ✅ **Cypress E2E tests pass** (frontend integration tests) +- ✅ ABP conventions followed +- ✅ AutoMapper/localization/permissions configured + +## Tool Usage + +### Analyzing Files +``` +Use sonarqube_analyze_file for each modified C# file to detect: +- Code quality issues +- Security vulnerabilities +- Maintainability problems +- Bug risks +``` + +### Listing Security Issues +``` +Use sonarqube_list_potential_security_issues to get: +- All security hotspots +- Vulnerabilities by severity +- Recommended fixes +``` + +### After Fixes +Re-run `sonarqube_analyze_file` on modified files to verify issues resolved. + +## Cypress E2E Testing + +### Location +- **Project**: `applications/Unity.AutoUI` +- **Config**: `cypress.config.ts` +- **Tests**: `cypress/` folder +- **Launcher**: `CypressTestLauncher.bat` (Windows) + +### Running Tests + +**Headless (CI/CD)**: +```bash +cd applications/Unity.AutoUI +npx cypress run +``` + +**Interactive Mode**: +```bash +cd applications/Unity.AutoUI +npx cypress open +``` + +**Using Launcher** (Windows): +```bash +cd applications/Unity.AutoUI +./CypressTestLauncher.bat +``` + +### What to Check +- ✅ All test specs pass +- ✅ No failed assertions +- ✅ Screenshots captured for failures (in `cypress/screenshots/`) +- ✅ Videos recorded (in `cypress/videos/`) +- ✅ No console errors or warnings +- ✅ UI rendering correctly + +### Failure Handling +If Cypress tests fail: +1. Review failure screenshots/videos +2. Check if UI changes broke existing tests +3. Update test selectors if component structure changed +4. Fix actual bugs if tests caught regressions +5. Re-run tests to verify fixes + +### Test Coverage Areas +Based on Unity.AutoUI project, tests likely cover: +- Grant application submission workflows +- Form validation +- User authentication/authorization +- Data table interactions +- File upload/download +- Multi-step wizards + +## Common Fixes + +```csharp +// ❌ SQL Injection +var sql = $"SELECT * FROM Users WHERE Email = '{email}'"; + +// ✅ Use EF LINQ +var users = await _dbContext.Users.Where(u => u.Email == email).ToListAsync(); + +// ❌ Missing authorization +public async Task DeleteAsync(Guid id) + +// ✅ Add attribute +[Authorize(GrantManagerPermissions.Applications.Delete)] +public async Task DeleteAsync(Guid id) + +// ❌ Return entity +public async Task GetAsync(Guid id) + +// ✅ Return DTO +public async Task GetAsync(Guid id) +{ + var entity = await _repository.GetAsync(id); + return ObjectMapper.Map(entity); +} + +// ❌ Path traversal +public async Task GetDocumentAsync(string fileName) +{ + var path = Path.Combine(root, "Documents", fileName); + return await File.ReadAllBytesAsync(path); +} + +// ✅ Validate path +public async Task GetDocumentAsync(Guid documentId) +{ + var doc = await _repository.GetAsync(documentId); + var safeFileName = Path.GetFileName(doc.FileName); + var fullPath = Path.GetFullPath(Path.Combine(root, "Documents", safeFileName)); + var allowedPath = Path.GetFullPath(Path.Combine(root, "Documents")); + + if (!fullPath.StartsWith(allowedPath)) + throw new BusinessException("Invalid path"); + + return await File.ReadAllBytesAsync(fullPath); +} +``` + +## References +- `.github/copilot-instructions.md` +- `.github/skills/unity-module-structure/SKILL.md` +- `.github/agents/unity-abp-instructions.md` diff --git a/applications/Unity.GrantManager/.github/agents/unity-abp-instructions.md b/applications/Unity.GrantManager/.github/agents/unity-abp-instructions.md new file mode 100644 index 0000000000..573577e9a5 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/unity-abp-instructions.md @@ -0,0 +1,662 @@ +# ABP Framework Instructions for Unity Grant Manager + +## Project Overview +Unity Grant Manager is an ASP.NET Core MVC application built using ABP Framework 9.1.3, following Domain-Driven Design (DDD) principles. + +## Architecture & Technology Stack + +### Backend +- **Framework**: ABP Framework 9.1.3 (ASP.NET Core MVC) +- **Architecture**: Domain-Driven Design (DDD) +- **Pattern**: Multi-layered application (Domain, Application, Web) +- **UI Framework**: ABP MVC UI with Bootstrap 4 +- **ORM**: Entity Framework Core (inferred from ABP standard) + +### Frontend +- **UI Theme**: ABP Basic Theme (`@abp/aspnetcore.mvc.ui.theme.basic`) +- **JavaScript**: jQuery, DataTables.net +- **Form Builder**: FormIO (formiojs 4.17.4) +- **Charts**: ECharts 6.0 +- **Rich Text**: TinyMCE 8.3.2 +- **CSS**: Bootstrap 4.6.2 + +## Project Structure + +``` +Unity.GrantManager/ +├── src/ +│ ├── Unity.GrantManager.Domain/ # Domain layer (entities, aggregates, repositories) +│ ├── Unity.GrantManager.Domain.Shared/ # Shared domain concepts +│ ├── Unity.GrantManager.Application/ # Application services +│ ├── Unity.GrantManager.Application.Contracts/ # DTOs, interfaces +│ ├── Unity.GrantManager.Web/ # MVC UI layer +│ │ ├── Views/ # Razor views +│ │ │ └── Shared/Components/ # View components +│ │ ├── Controllers/ # MVC controllers +│ │ ├── wwwroot/ # Static files +│ │ └── Pages/ # Razor pages +│ └── Unity.GrantManager.HttpApi/ # Web API controllers +├── test/ # Test projects +└── modules/ # ABP modules +``` + +## ABP Framework Conventions + +### 1. Application Services +- Located in `*.Application` project +- Inherit from `ApplicationService` base class +- Use `AppService` suffix (e.g., `GrantApplicationAppService`) +- Return DTOs, not domain entities +- Handle authorization with `[Authorize]` attributes +- Use ABP's `IObjectMapper` for entity-to-DTO mapping + +```csharp +public class GrantApplicationAppService : ApplicationService, IGrantApplicationAppService +{ + private readonly IRepository _repository; + + public GrantApplicationAppService(IRepository repository) + { + _repository = repository; + } + + [Authorize(GrantManagerPermissions.GrantApplications.View)] + public async Task GetAsync(Guid id) + { + var entity = await _repository.GetAsync(id); + return ObjectMapper.Map(entity); + } +} +``` + +### 2. Domain Entities +- Located in `*.Domain` project +- Inherit from `Entity`, `AggregateRoot`, or `FullAuditedAggregateRoot` +- Use `FullAuditedAggregateRoot` for entities requiring audit trails +- Place business logic in entity methods, not in services +- Use domain events for cross-aggregate communication + +```csharp +public class GrantApplication : FullAuditedAggregateRoot +{ + public string ReferenceNo { get; private set; } + public decimal RequestedAmount { get; private set; } + public ApplicationStatus Status { get; private set; } + + public void Approve(decimal approvedAmount) + { + // Business logic here + Status = ApplicationStatus.Approved; + AddDistributedEvent(new ApplicationApprovedEvent(Id)); + } +} +``` + +### 3. Repositories +- Use `IRepository` for basic CRUD +- Create custom repositories in `*.Domain` for complex queries +- Repository interfaces in Domain, implementations in Infrastructure/EntityFrameworkCore + +### 4. DTOs (Data Transfer Objects) +- Located in `*.Application.Contracts` project +- Separate DTOs for create, update, and read operations +- Use `EntityDto` as base when including Id +- Example: `CreateGrantApplicationDto`, `UpdateGrantApplicationDto`, `GrantApplicationDto` + +### 5. MVC Controllers +- Located in `*.Web` project's `Controllers` folder +- Inherit from `AbpController` +- Use dependency injection for application services +- Return `IActionResult` or derived types +- Use ABP's localization: `L["KeyName"]` + +```csharp +public class GrantApplicationsController : AbpController +{ + private readonly IGrantApplicationAppService _applicationService; + + public GrantApplicationsController(IGrantApplicationAppService applicationService) + { + _applicationService = applicationService; + } + + public async Task Details(Guid applicationId) + { + var dto = await _applicationService.GetAsync(applicationId); + return View(dto); + } +} +``` + +### 6. View Components +- Located in `Views/Shared/Components/{ComponentName}/` +- Default view: `Default.cshtml` +- JavaScript file: `Default.js` (if needed) +- Invoke in views: `@await Component.InvokeAsync("ComponentName")` + +### 7. Localization +- Use `L` function in C#: `L["KeyName"]` +- Use `l` function in JavaScript: `l('KeyName')` +- Use `@L["KeyName"]` in Razor views +- Localization files in JSON format in `Localization` folder + +### 8. Permissions +- Define in `*Permissions.cs` files +- Use constants for permission names +- Check with `[Authorize(PermissionName)]` attribute or `await AuthorizationService.CheckAsync()` + +## Unity Grant Manager Specific Patterns + +### DataTables Integration +- Use `initializeDataTable()` helper function from `table-utils.js` +- Column definitions follow a consistent pattern with getter functions +- Enable server-side processing for large datasets +- Use `createNumberFormatter()` for currency formatting + +```javascript +const dataTable = initializeDataTable({ + dt: $('#TableId'), + defaultVisibleColumns: ['select', 'referenceNo', 'status'], + listColumns: getColumns(formatter, l), + maxRowsPerPage: 10, + defaultSortColumn: { name: 'submissionDate', dir: 'desc' }, + dataEndpoint: service.getList, + responseCallback: responseCallback, + actionButtons: actionButtons, + serverSideEnabled: true +}); +``` + +### Column Getter Pattern +- Create separate functions for each column definition +- Include `columnIndex` parameter for ordering +- Return object with: `title`, `data`, `name`, `className`, `render`, `index` + +```javascript +function getReferenceNoColumn(columnIndex) { + return { + title: 'Submission #', + data: 'referenceNo', + name: 'referenceNo', + className: 'data-table-header text-nowrap', + render: function (data, type, row) { + return `${data || ''}`; + }, + index: columnIndex + }; +} +``` + +### Form Handling +- Use ABP's form validation helpers +- Leverage FormIO for dynamic forms +- Handle form submission via AJAX with proper error handling + +### Date Handling +- Use `luxon` library for date manipulation +- Use ABP's `DateUtils.formatUtcDateToLocal()` helper +- Store dates in UTC, display in local timezone +- Format: `luxon.DateTime.fromISO(data).toUTC().toLocaleString()` + +### Currency Formatting +```javascript +const formatter = createNumberFormatter(); // From table-utils.js +formatter.format(amount); // Returns formatted currency string +``` + +## Best Practices + +### 1. Keep Business Logic in Domain +- Don't put business rules in controllers or views +- Use domain services for logic crossing multiple aggregates +- Application services orchestrate, domain entities execute + +### 2. Use ABP Conventions +- Follow ABP naming conventions (`AppService`, `Dto`, etc.) +- Use ABP's built-in features (authorization, localization, audit logging) +- Leverage ABP's dependency injection + +### 3. Maintain Layer Separation +- Domain layer has no dependencies on other layers +- Application layer depends only on Domain and Domain.Shared +- Web layer depends on Application.Contracts, not Domain directly + +### 4. Error Handling +- Use ABP's `UserFriendlyException` for user-facing errors +- Use ABP's `BusinessException` for business rule violations +- Let ABP's exception handling middleware manage responses + +### 5. JavaScript Organization +- Keep component-specific JS in component folders +- Extract reusable utilities to shared files (e.g., `table-utils.js`) +- Use function declarations for hoisted helpers +- Avoid duplicate function definitions + +### 6. Testing +- Write unit tests for domain logic +- Integration tests for application services +- Use ABP's test infrastructure + +## Common Operations + +### Adding a New Entity +1. Create entity in `*.Domain` project +2. Add to `DbContext` in `*.EntityFrameworkCore` +3. Create migration +4. Create DTOs in `*.Application.Contracts` +5. Create application service in `*.Application` +6. Add AutoMapper mappings +7. Define permissions +8. Create MVC controller and views + +### Database Migrations +```powershell +# From Unity.GrantManager.EntityFrameworkCore project directory +dotnet ef migrations add MigrationName +dotnet ef database update +``` + +### Adding Localization Keys +1. Add to `en.json` in `Localization/GrantManager` folder +2. Add translations for other supported languages +3. Use `L["KeyName"]` in code + +## Important Files & Utilities + +### JavaScript Utilities +- `table-utils.js`: DataTables initialization and helpers +- `DateUtils`: Date formatting utilities +- `createNumberFormatter()`: Currency formatting + +### Common JavaScript Patterns +```javascript +// Localization +const l = abp.localization.getResource('GrantManager'); + +// AJAX calls +abp.ajax({ + url: '/api/app/grant-application/...', + type: 'POST', + data: JSON.stringify(data) +}); + +// Notifications +abp.notify.success(l('SavedSuccessfully')); +abp.notify.error(l('ErrorOccurred')); +``` + +## ABP 9.1.3 Features for Unity Grant Manager + +### 1. Background Jobs for Long-Running Operations +**Use Cases**: Application processing, bulk operations, report generation, email notifications + +```csharp +// Define a background job +public class ProcessApplicationJob : AsyncBackgroundJob +{ + private readonly IGrantApplicationRepository _repository; + + public ProcessApplicationJob(IGrantApplicationRepository repository) + { + _repository = repository; + } + + public override async Task ExecuteAsync(ProcessApplicationArgs args) + { + var application = await _repository.GetAsync(args.ApplicationId); + // Process application logic + } +} + +// Enqueue a job +await _backgroundJobManager.EnqueueAsync(new ProcessApplicationArgs { ApplicationId = id }); +``` + +**Configuration** (in module class): +```csharp +Configure(options => +{ + options.IsJobExecutionEnabled = true; // Enable background job execution +}); +``` + +### 2. Blob Storage for Document Management +**Use Cases**: Storing application documents, attachments, generated reports + +```csharp +// Inject IBlobContainer +private readonly IBlobContainer _blobContainer; + +// Save a file +await _blobContainer.SaveAsync("document-name.pdf", stream, overrideExisting: true); + +// Retrieve a file +var stream = await _blobContainer.GetAsync("document-name.pdf"); + +// Delete a file +await _blobContainer.DeleteAsync("document-name.pdf"); +``` + +**Configuration** (module class): +```csharp +// Configure Blob Storage for different containers +Configure(options => +{ + options.Containers.Configure(container => + { + container.UseFileSystem(fileSystem => + { + fileSystem.BasePath = Path.Combine(hostingEnvironment.ContentRootPath, "Documents"); + }); + }); +}); +``` + +**Database Provider Alternative**: +```csharp +container.UseDatabase(); // Stores blobs in database +``` + +### 3. Global Feature System for Feature Toggles +**Use Cases**: Enable/disable features like assessment scoring, due diligence checks, payment processing + +```csharp +// Define features in Domain.Shared +public static class GrantManagerFeatures +{ + public const string AdvancedScoring = "GrantManager.AdvancedScoring"; + public const string AutomatedDueDiligence = "GrantManager.AutomatedDueDiligence"; + public const string PaymentIntegration = "GrantManager.PaymentIntegration"; +} + +// Configure in module +GlobalFeatureManager.Instance.Modules.GrantManager() + .EnableAll(); // Or .Enable(GrantManagerFeatures.AdvancedScoring) + +// Check feature in code +if (await FeatureChecker.IsEnabledAsync(GrantManagerFeatures.AdvancedScoring)) +{ + // Execute advanced scoring logic +} + +// In Razor views +@if (await FeatureChecker.IsEnabledAsync(GrantManagerFeatures.PaymentIntegration)) +{ + +} +``` + +### 4. Distributed Events for Workflow Management +**Use Cases**: Application state changes, notifications, audit trail, integration with external systems + +ABP 9.1.3 improves distributed event handling with better inbox/outbox pattern support. + +```csharp +// Define event (in Domain.Shared) +[Serializable] +public class ApplicationApprovedEto : EtoBase +{ + public Guid ApplicationId { get; set; } + public decimal ApprovedAmount { get; set; } +} + +// Publish event (in Application Service or Domain Entity) +await _distributedEventBus.PublishAsync(new ApplicationApprovedEto +{ + ApplicationId = id, + ApprovedAmount = amount +}); + +// Handle event (in Application layer) +public class ApplicationApprovedEventHandler : + IDistributedEventHandler, + ITransientDependency +{ + private readonly IEmailSender _emailSender; + + public ApplicationApprovedEventHandler(IEmailSender emailSender) + { + _emailSender = emailSender; + } + + public async Task HandleEventAsync(ApplicationApprovedEto eventData) + { + // Send approval email + // Create payment record + // Update external systems + } +} +``` + +**Configure Outbox for Reliability**: +```csharp +Configure(options => +{ + options.Outboxes.Configure(config => + { + config.UseDbContext(); + }); +}); +``` + +### 5. Enhanced Audit Logging +**Use Cases**: Track all changes to grant applications, compliance reporting, user activity monitoring + +ABP 9.1.3 provides better audit log filtering and querying. + +```csharp +// Disable auditing for specific method +[DisableAuditing] +public async Task GetLargeReportAsync() +{ + // Method not audited +} + +// Custom audit log properties +public class GrantApplicationAppService : ApplicationService +{ + public async Task ApproveAsync(Guid id, decimal amount) + { + // Add custom audit data + AuditingManager.Current.Log.EntityChanges.Add(new EntityChangeInfo + { + ChangeType = EntityChangeType.Updated, + EntityId = id.ToString(), + PropertyChanges = new List + { + new EntityPropertyChangeInfo + { + PropertyName = "ApprovalAmount", + NewValue = amount.ToString(), + OriginalValue = "0" + } + } + }); + } +} + +// Query audit logs (in a service) +var auditLogs = await _auditLogRepository.GetListAsync( + includeDetails: true, + httpMethod: "POST", + url: "/api/app/grant-application", + userName: "admin", + startTime: DateTime.UtcNow.AddDays(-7), + endTime: DateTime.UtcNow +); +``` + +### 6. Setting Management for Configurable Parameters +**Use Cases**: Approval thresholds, deadline configurations, scoring weights, notification preferences + +```csharp +// Define settings (in Domain.Shared) +public static class GrantManagerSettings +{ + public const string ApprovalThreshold = "GrantManager.ApprovalThreshold"; + public const string MaxApplicationsPerUser = "GrantManager.MaxApplicationsPerUser"; + public const string AutoCloseDeadlineDays = "GrantManager.AutoCloseDeadlineDays"; +} + +// Define setting definition provider +public class GrantManagerSettingDefinitionProvider : SettingDefinitionProvider +{ + public override void Define(ISettingDefinitionContext context) + { + context.Add( + new SettingDefinition( + GrantManagerSettings.ApprovalThreshold, + "100000", + isVisibleToClients: true, + isEncrypted: false + ), + new SettingDefinition( + GrantManagerSettings.MaxApplicationsPerUser, + "5", + isVisibleToClients: true + ) + ); + } +} + +// Use settings in code +var threshold = await SettingProvider.GetAsync(GrantManagerSettings.ApprovalThreshold); + +if (amount > threshold) +{ + // Require additional approval +} + +// Get setting in JavaScript +var maxApps = await abp.setting.get('GrantManager.MaxApplicationsPerUser'); +``` + +### 7. Dynamic Claims for Custom Authorization +**Use Cases**: Department-based access, region-based filtering, role-based data visibility + +```csharp +// Define custom claim type +public static class GrantManagerClaims +{ + public const string Department = "GrantManager_Department"; + public const string Region = "GrantManager_Region"; + public const string MaxApprovalAmount = "GrantManager_MaxApprovalAmount"; +} + +// Add dynamic claims (in Identity module) +public class GrantManagerClaimsPrincipalContributor : IAbpClaimsPrincipalContributor, ITransientDependency +{ + public async Task ContributeAsync(AbpClaimsPrincipalContributorContext context) + { + var identity = context.ClaimsPrincipal.Identities.FirstOrDefault(); + var userId = identity?.FindUserId(); + + if (userId.HasValue) + { + // Add custom claims from user profile or database + var userDepartment = await GetUserDepartmentAsync(userId.Value); + identity?.AddClaim(new Claim(GrantManagerClaims.Department, userDepartment)); + } + } +} + +// Use in authorization +[Authorize] +public async Task> GetMyDepartmentApplicationsAsync() +{ + var department = CurrentUser.FindClaimValue(GrantManagerClaims.Department); + return await _repository.GetListAsync(x => x.Department == department); +} +``` + +### 8. EF Core 8 Features (if using .NET 8+) +**New Capabilities**: JSON columns, raw SQL queries, complex type mapping + +```csharp +// JSON column mapping (for flexible metadata) +public class GrantApplication : FullAuditedAggregateRoot +{ + public string ReferenceNo { get; set; } + public ApplicationMetadata Metadata { get; set; } // Stored as JSON +} + +// In DbContext configuration +protected override void OnModelCreating(ModelBuilder builder) +{ + builder.Entity(b => + { + b.OwnsOne(e => e.Metadata, b => b.ToJson()); + }); +} + +// Raw SQL queries with better performance +var applications = await _dbContext.Database + .SqlQuery($"EXEC GetTopApplications @Year = {year}") + .ToListAsync(); +``` + +### 9. Object Extension System for Extensibility +**Use Cases**: Add custom fields without modifying core entities + +```csharp +// Configure in EntityFrameworkCore module +ObjectExtensionManager.Instance + .AddOrUpdateProperty( + "CustomField1", + options => { options.MapEfCore(b => b.HasMaxLength(128)); } + ); + +// Use in application service +application.SetProperty("CustomField1", "CustomValue"); +var value = application.GetProperty("CustomField1"); +``` + +### 10. Text Template Management +**Use Cases**: Email templates, document generation, notification templates + +```csharp +// Define template +public class ApprovalEmailTemplate : TemplateDefinitionProvider +{ + public override void Define(ITemplateDefinitionContext context) + { + context.Add( + new TemplateDefinition("ApprovalEmail") + .WithVirtualFilePath("/Templates/ApprovalEmail.tpl", isInlineLocalized: true) + ); + } +} + +// Use template +var emailBody = await _templateRenderer.RenderAsync( + "ApprovalEmail", + new { ApplicantName = "John Doe", Amount = 50000 } +); +``` + +## Module Structure +Unity Grant Manager includes: +- **Unity.Shared**: Shared components across Unity applications +- **MessageBrokers**: RabbitMQ integration (consider using ABP distributed events) +- **modules/**: Various ABP modules + +## Additional Resources +- ABP Framework Documentation: https://docs.abp.io +- ABP 9.1 Release Notes: https://docs.abp.io/en/abp/9.1/Release-Info +- Project README: `/Unity/applications/Unity.GrantManager/README.md` +- Architecture documentation: `/Unity/documentation/` + +## Recommended Next Steps for ABP 9.1.3 Integration + +1. **Implement Blob Storage** for document management (replace file system storage) +2. **Add Distributed Events** for application workflow state changes +3. **Configure Background Jobs** for report generation and notifications +4. **Use Setting Management** for configurable business rules (thresholds, deadlines) +5. **Leverage Global Features** for feature flags in production +6. **Enhance Audit Logging** for compliance requirements +7. **Implement Dynamic Claims** for department/region-based access control +8. **Use Text Templates** for standardized email and document generation + +--- + +**Remember**: This is an ABP Framework MVC application, NOT Angular. Use Razor views, jQuery, and traditional server-side rendering patterns. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptTypes.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptTypes.cs new file mode 100644 index 0000000000..41ce17e33e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptTypes.cs @@ -0,0 +1,8 @@ +namespace Unity.GrantManager.AI; + +public static class AIPromptTypes +{ + public const string AttachmentSummary = "AttachmentSummary"; + public const string ApplicationAnalysis = "ApplicationAnalysis"; + public const string ScoresheetSection = "ScoresheetSection"; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index bdc4d248e5..a1cb4ba783 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -20,9 +20,9 @@ public class OpenAIService : IAIService, ITransientDependency private readonly ILogger _logger; private readonly ITextExtractionService _textExtractionService; private readonly IAIPromptCaptureStore _promptIoCaptureStore; - private const string ApplicationAnalysisPromptType = "ApplicationAnalysis"; - private const string AttachmentSummaryPromptType = "AttachmentSummary"; - private const string ScoresheetSectionPromptType = "ScoresheetSection"; + private const string ApplicationAnalysisPromptType = AIPromptTypes.ApplicationAnalysis; + private const string AttachmentSummaryPromptType = AIPromptTypes.AttachmentSummary; + private const string ScoresheetSectionPromptType = AIPromptTypes.ScoresheetSection; private const string PromptVersionV0 = "v0"; private const string PromptVersionV1 = "v1"; private static readonly string PromptTemplatesFolder = Path.Combine("AI", "Prompts", "Versions"); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs index 8bea35cab0..ec60077961 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs @@ -147,13 +147,8 @@ private static Dictionary BuildPromptDataValues(JsonElement if (allowedSchemaKeys.Count > 0) { - foreach (var key in values.Keys.ToList()) - { - if (!allowedSchemaKeys.Contains(key)) - { - values.Remove(key); - } - } + values = values.Where(kvp => allowedSchemaKeys.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase); } return values; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt index b13f42bddb..ee9c1cade7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt @@ -1,26 +1,25 @@ - Use only provided input sections as evidence. - Do not invent fields, documents, requirements, or facts. -- Treat missing or empty values as findings only when they weaken rubric evidence. -- Prefer material issues; avoid nitpicking. -- Ignore evidence that is not relevant to a reviewer-facing conclusion. - Prefer, in order: direct evidence from DATA, specific supporting evidence from ATTACHMENTS, then broader context only when necessary. +- Treat missing or empty values as findings only when they weaken rubric evidence. +- Prefer material findings; avoid nitpicking. - Do not restate basic application facts as findings unless they support a specific reviewer conclusion about readiness, feasibility, budget credibility, eligibility, or confidence in proceeding. -- Only include warnings when the evidence shows a specific, concrete risk, inconsistency, or meaningful uncertainty; a stated risk label alone is not enough. -- Use 3-6 words for title. -- Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. -- Each detail must be 1-2 complete sentences. +- Prefer direct evidence from DATA over derivative statements in ATTACHMENTS when both address the same point. +- If ATTACHMENTS evidence is used, cite the attachment by name in detail. - Each detail must cite concrete evidence from DATA or ATTACHMENTS. -- When citing a positive conclusion, explain why that evidence matters for readiness, feasibility, or funding confidence. +- Only include warnings when the evidence shows a specific, concrete risk, inconsistency, or meaningful uncertainty; a stated risk label alone is not enough. +- Do not state that one amount exceeds, matches, or conflicts with another unless the comparison is directly supported by the provided values. +- Do not treat ordinary lack of detailed supporting explanation as a material gap unless the provided evidence creates real uncertainty about feasibility, eligibility, or budget credibility. - Prefer neutral evidence descriptions over evaluative adjectives unless the evidence directly supports a strong conclusion. - Do not describe capacity, feasibility, or justification as strong, detailed, or well-supported unless the evidence shows more than the existence of basic organizational, budget, or timeline information. - Do not infer community support, established partnerships, or delivery capacity from a single partner reference, staff count, or basic organizational status alone. - Do not describe a timeline as realistic or feasible based only on start and end dates unless additional evidence supports deliverability. -- Prefer direct evidence from DATA over derivative statements in ATTACHMENTS when both address the same point. -- If ATTACHMENTS evidence is used, cite the attachment by name in detail. +- Use 3-6 words for title. +- Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. +- Each detail must be 1-2 complete sentences. - Summaries and nextSteps must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. -- Avoid generic praise, generic checklist language, and repeated conclusions across lists. +- Avoid generic praise, checklist language, and repeated conclusions across lists. - Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. -- Do not treat ordinary lack of detailed supporting explanation as a material gap unless the provided evidence creates real uncertainty about feasibility, eligibility, or budget credibility. - If no findings exist, return empty arrays. - Rating must be HIGH, MEDIUM, or LOW. - Use summaries for overall application quality/readiness synthesis. @@ -30,3 +29,4 @@ - Use HOLD only when provided evidence shows a material eligibility, feasibility, budget, or readiness concern that would reasonably block scoring or decision-making. - recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. - recommendation.rationale should name the 1-3 strongest evidence-based reasons for the recommendation. + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt index a35dd58acf..cfd2fe2d47 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt @@ -1,10 +1,9 @@ ROLE -You are a careful grant analyst assistant for human reviewers. You do not fill gaps or turn weak signals into strong reviewer conclusions. +You are a careful grant review assistant for human reviewers. Do not fill gaps, assume compliance, or treat relevance as proof. TASK Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES: -1. Identify the strongest reviewer-relevant evidence in the application and attachments. +1. Review the application and attachments for the strongest reviewer-relevant evidence. 2. Determine which conclusions are directly supported by that evidence. 3. Exclude weak, repetitive, or loosely supported conclusions. -4. Before finalizing each conclusion, ask whether the evidence directly supports it and whether a more neutral description would be more accurate. -5. Return only the strongest evidence-backed reviewer conclusions. +4. Return only the strongest evidence-backed reviewer conclusions. \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt index 8008ef059a..0cebe3aa94 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt @@ -1,10 +1,12 @@ - Use only ATTACHMENT as evidence. - Summarize actual content when ATTACHMENT.text is present; otherwise provide a conservative file-level summary. -- Ignore attachment details that are not relevant to describing what the file contains or contributes. -- Describe the attachment itself, including its apparent function or content type when supported by the evidence, rather than summarizing the overall project. +- Describe the attachment itself rather than summarizing the overall project. +- Ensure the summary describes the attachment itself, not the overall project. - If ATTACHMENT.text is primarily structured application, contact, organization, budget, or date fields, summarize it as a metadata-style attachment rather than rewriting it as a generic project summary. +- Begin with what the attachment contains or provides, not the file name or file type, unless that metadata is necessary to describe the evidence. - Do not invent missing details. - Do not calculate or restate totals, sums, or aggregates unless they are explicitly present in ATTACHMENT.text. - Write 1-2 complete sentences. - Summary must be grounded in concrete ATTACHMENT evidence. - Return exactly one object with only the key: summary. + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt index 525825366e..50f0d6a6f3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt @@ -1,10 +1,8 @@ ROLE -You are a careful grant analyst assistant for human reviewers. You do not fill gaps or summarize the overall project when the attachment itself is the evidence. +You are a careful grant review assistant for human reviewers. Do not fill gaps, assume compliance, or treat relevance as proof. TASK Using ATTACHMENT, OUTPUT, and RULES: -1. Identify what the attachment contains. -2. Determine what type of attachment it appears to be, when the evidence supports that. -3. Summarize only the attachment-specific content or evidence it provides. -4. Before finalizing the summary, check that it describes the attachment itself and not the overall project. -5. Return a concise reviewer-facing summary. +1. Review the attachment to identify what it contains. +2. Summarize the attachment itself, not the overall project. +3. Return a concise reviewer-facing summary. \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt index bbdaaf55c2..7c25c7f6bd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt @@ -1,5 +1,15 @@ - Use only DATA and ATTACHMENTS as evidence. - Do not invent missing application details. +- Ignore fields or details that are not relevant to the specific question being answered. +- Prefer, in order: direct evidence of the exact condition asked, closely related supporting evidence, then general context only when necessary. +- If evidence is insufficient, partial, indirect, missing, or non-specific, choose the most conservative valid answer and explain the uncertainty. +- Do not convert general project descriptions into evidence for a specific scored condition unless that condition is directly supported. +- Treat prefilled labels, ratings, rankings, or statuses as background context only unless the question explicitly asks for that same item. +- Do not treat related concepts as equivalent; answer the specific question asked, not a nearby concept. +- Do not infer unsupported claims about requirements, conditions, relationships, compliance elements, mitigations, supports, or outcomes. +- Answer a specific condition positively only when that exact condition is directly evidenced in DATA or ATTACHMENTS. +- For eligibility, completeness, ownership, location, or compliance questions, do not answer positively unless the exact condition is directly confirmed in the provided evidence. +- If the evidence shows only involvement, presence, relevance, or association, do not treat that alone as proof that a requirement or condition is satisfied. - Return exactly one answer object per question ID in SECTION.questions. - Do not omit any question IDs from SECTION.questions. - Do not add keys that are not question IDs from SECTION.questions. @@ -11,14 +21,6 @@ - The "rationale" field must be 1-2 complete sentences grounded in concrete DATA/ATTACHMENTS evidence. - In rationale, cite concrete source evidence from the provided input content rather than prompt section headers. - For every question, rationale must justify both the selected answer and the selected confidence level based on evidence strength. -- If evidence is insufficient, partial, indirect, missing, or non-specific, choose the most conservative valid answer and explain the uncertainty. -- Ignore fields or details that are not relevant to the specific question being answered. -- Prefer, in order: direct evidence of the exact condition asked, closely related supporting evidence, then general context only when necessary. -- Do not convert general project descriptions into evidence for a specific scored condition unless that condition is directly supported. -- Treat prefilled labels, ratings, rankings, or statuses in DATA as background context only; do not use them as evidence unless the question explicitly asks you to report that same item. -- Do not use one field's prior classification, rating, or judgment as evidence for a different question unless the question explicitly asks for that same classification, rating, or judgment. -- Do not treat related concepts as equivalent; answer the specific question asked, not a nearby concept. -- Do not infer unsupported claims about requirements, conditions, relationships, compliance elements, mitigations, supports, or outcomes. - The "confidence" field must be an integer from 0 to 100 in increments of 5 and represents confidence in the selected answer. - Set confidence by certainty of the selected answer based on available evidence, regardless of which option is selected. - Do not use maximum or near-maximum confidence when the answer depends on inference rather than an explicit statement of the exact condition. @@ -35,18 +37,12 @@ - If evidence supports the existence of a topic but not the required strength, completeness, or specificity, choose the lowest option consistent with that evidence. - If evidence is insufficient for a select list question, choose the lowest allowed answer value from question.allowed_answers and explain the uncertainty. - Do not treat broad project descriptions, general goals, high-level timelines, budget presence, or a single indirect reference as sufficient evidence for a higher-scored select-list answer. -- Answer a specific condition positively only when that exact condition is directly evidenced in DATA or ATTACHMENTS. -- If the evidence shows only involvement, presence, relevance, or association, do not treat that alone as proof that a requirement or condition is satisfied. -- If a question asks whether something is eligible, complete, appropriate, or satisfied, require direct evidence of that exact condition rather than general relevance or presence. - For text and text area questions, answer must be concise, evidence-based, non-empty, and avoid boilerplate placeholders. - For text and text area questions, answer is the reviewer comment, and rationale must explain the evidence basis and certainty for that comment. - If no concerns are identified for a text or text area question, return a short non-empty evidence-based comment rather than leaving answer blank. - For comment fields, summarize only the evidence-based conclusions supported by the scored answers, including uncertainty where applicable, and do not introduce stronger claims. -- For narrative comment fields, keep wording aligned with the scored answers and evidence; do not use stronger certainty or impact language than the evidence supports. - For comment fields, describe the evidence and resulting answer without elevating it into an overall assessment unless the question explicitly asks for one. -- Do not treat the presence of a named person, partner, organization, location, document, or field as proof of a separate requirement, condition, or relationship unless that exact point is explicitly evidenced. - Do not add recommendations or stronger conclusions unless the question explicitly asks for them. -- For comment fields, always provide a concise evidence-based summary even when no concerns are identified. - For comment fields, do not leave answer empty even when all other answers are positive. - Do not leave rationale empty when answer is populated. - Final self-check before responding: every question ID in RESPONSE must have a non-empty "answer", non-empty "rationale", and "confidence". diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt index 855286f824..297a9f351f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt @@ -1,5 +1,5 @@ ROLE -You are a careful grant review assistant for human reviewers. You do not fill gaps, assume compliance, or treat relevance as proof. +You are a careful grant review assistant for human reviewers. Do not fill gaps, assume compliance, or treat relevance as proof. TASK Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES: @@ -8,5 +8,4 @@ Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES: 3. Consider only the most relevant evidence in DATA and ATTACHMENTS for that condition. 4. Choose the most conservative valid answer supported by that evidence. 5. If evidence is incomplete or indirect, explain the uncertainty in the rationale. -6. Before finalizing each answer, ask: "What exact evidence supports this condition?" If no direct evidence exists, choose the most conservative valid answer. -7. Repeat for every question in SECTION. +6. Repeat for every question in SECTION. \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs index 54df521637..c45eeb9d36 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs @@ -2,12 +2,14 @@ using NPOI.SS.UserModel; using NPOI.XWPF.UserModel; using System; -using System.Collections.Generic; using System.IO; +using System.IO.Compression; +using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Xml.Linq; using UglyToad.PdfPig; using Volo.Abp.DependencyInjection; @@ -22,6 +24,7 @@ public partial class TextExtractionService : ITextExtractionService, ITransientD private const int MaxDocxParagraphs = 2000; private const int MaxDocxTableRows = 2000; private const int MaxDocxTableCellsPerRow = 50; + private const int MaxPowerPointSlides = 200; private readonly ILogger _logger; private readonly Dictionary> _extractorsByExtension; @@ -37,7 +40,8 @@ public TextExtractionService(ILogger logger) [".pdf"] = ExtractTextFromPdfFile, [".docx"] = ExtractTextFromWordDocx, [".xls"] = ExtractTextFromExcelFile, - [".xlsx"] = ExtractTextFromExcelFile + [".xlsx"] = ExtractTextFromExcelFile, + [".pptx"] = ExtractTextFromPowerPointFile }; } @@ -92,6 +96,13 @@ public Task ExtractTextAsync(string fileName, byte[] fileContent, string return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); } + if (normalizedContentType.Contains("presentation") || + normalizedContentType.Contains("powerpoint")) + { + var rawText = ExtractTextFromPowerPointFile(fileName, fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); + } + _logger.LogDebug("No text extraction available for content type {ContentType} with extension {Extension}", contentType, extension); return Task.FromResult(string.Empty); @@ -120,6 +131,7 @@ private string ExtractTextFromTextFile(byte[] fileContent) _logger.LogDebug("Truncated text content to {MaxLength} characters", MaxExtractedTextLength); } + _logger.LogDebug("Extracted {CharacterCount} characters from text-based content.", text.Length); return text; } catch (Exception ex) @@ -136,12 +148,26 @@ private string ExtractTextFromPdfFile(string fileName, byte[] fileContent) using var stream = new MemoryStream(fileContent, writable: false); using var document = PdfDocument.Open(stream); var builder = new StringBuilder(); + var processedPageCount = 0; var pageTexts = document.GetPages() .Select(page => page.Text) .Where(pageText => !string.IsNullOrWhiteSpace(pageText)); - AppendUntilLimit(builder, pageTexts); + foreach (var pageText in pageTexts) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } + + processedPageCount++; + if (TryAppendWithTrailingNewline(builder, pageText)) + { + break; + } + } + _logger.LogDebug("Extracted PDF text from {ProcessedPageCount} pages for {FileName}", processedPageCount, fileName); return builder.ToString(); } catch (Exception ex) @@ -158,15 +184,14 @@ private string ExtractTextFromWordDocx(string fileName, byte[] fileContent) using var stream = new MemoryStream(fileContent, writable: false); using var document = new XWPFDocument(stream); var builder = new StringBuilder(); - var paragraphTexts = document.Paragraphs - .Take(MaxDocxParagraphs) - .Select(paragraph => paragraph.ParagraphText) - .Where(paragraphText => !string.IsNullOrWhiteSpace(paragraphText)); - - AppendUntilLimit(builder, paragraphTexts); - - TryAppendDocxTableText(document, builder); - + var processedParagraphCount = AppendDocxParagraphText(document, builder); + var processedTableRowCount = AppendDocxTableText(document, builder); + + _logger.LogDebug( + "Extracted Word text from {ProcessedParagraphCount} paragraphs and {ProcessedTableRowCount} table rows for {FileName}", + processedParagraphCount, + processedTableRowCount, + fileName); return builder.ToString(); } catch (Exception ex) @@ -176,28 +201,71 @@ private string ExtractTextFromWordDocx(string fileName, byte[] fileContent) } } - private static void TryAppendDocxTableText(XWPFDocument document, StringBuilder builder) + private static int AppendDocxParagraphText(XWPFDocument document, StringBuilder builder) + { + var processedParagraphCount = 0; + var paragraphTexts = document.Paragraphs + .Take(MaxDocxParagraphs) + .Select(paragraph => paragraph.ParagraphText) + .Where(paragraphText => !string.IsNullOrWhiteSpace(paragraphText)); + + foreach (var paragraphText in paragraphTexts) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } + + processedParagraphCount++; + if (TryAppendWithTrailingNewline(builder, paragraphText)) + { + break; + } + } + + return processedParagraphCount; + } + + private static int AppendDocxTableText(XWPFDocument document, StringBuilder builder) { if (builder.Length >= MaxExtractedTextLength) { - return; + return 0; } + var processedTableRowCount = 0; foreach (var table in document.Tables) { foreach (var row in table.Rows.Take(MaxDocxTableRows)) { + if (builder.Length >= MaxExtractedTextLength) + { + return processedTableRowCount; + } + var cellTexts = row.GetTableCells() .Take(MaxDocxTableCellsPerRow) .Select(cell => cell.GetText()) .Where(cellText => !string.IsNullOrWhiteSpace(cellText)); - if (AppendUntilLimit(builder, cellTexts)) + var rowHadValue = false; + foreach (var cellText in cellTexts) { - return; + rowHadValue = true; + if (TryAppendWithTrailingNewline(builder, cellText)) + { + return processedTableRowCount + 1; + } + } + + if (rowHadValue) + { + processedTableRowCount++; } } } + + return processedTableRowCount; } private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) @@ -208,6 +276,8 @@ private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) using var workbook = WorkbookFactory.Create(stream); var builder = new StringBuilder(); var sheetCount = Math.Min(workbook.NumberOfSheets, MaxExcelSheets); + var processedSheetCount = 0; + var processedRowCount = 0; for (var sheetIndex = 0; sheetIndex < sheetCount; sheetIndex++) { @@ -217,13 +287,24 @@ private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) } var sheet = workbook.GetSheetAt(sheetIndex); - var limitReached = TryAppendExcelSheet(sheet, builder); + var (rowsProcessed, limitReached) = TryAppendExcelSheet(sheet, builder); + if (rowsProcessed > 0) + { + processedSheetCount++; + processedRowCount += rowsProcessed; + } + if (limitReached) { break; } } + _logger.LogDebug( + "Extracted Excel text from {ProcessedSheetCount} sheets and {ProcessedRowCount} rows for {FileName}", + processedSheetCount, + processedRowCount, + fileName); return builder.ToString(); } catch (Exception ex) @@ -233,11 +314,94 @@ private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) } } - private static bool TryAppendExcelSheet(ISheet? sheet, StringBuilder builder) + private string ExtractTextFromPowerPointFile(string fileName, byte[] fileContent) + { + try + { + using var stream = new MemoryStream(fileContent, writable: false); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false); + var builder = new StringBuilder(); + var slideEntries = GetOrderedPowerPointSlideEntries(archive) + .Take(MaxPowerPointSlides); + var processedSlideCount = 0; + + foreach (var slideEntry in slideEntries) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } + + using var slideStream = slideEntry.Open(); + var slideText = ExtractPowerPointSlideText(slideStream); + if (string.IsNullOrWhiteSpace(slideText)) + { + continue; + } + + processedSlideCount++; + if (TryAppendWithTrailingNewline(builder, slideText)) + { + break; + } + } + + _logger.LogDebug("Extracted PowerPoint text from {ProcessedSlideCount} slides for {FileName}", processedSlideCount, fileName); + return builder.ToString(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "PowerPoint (.pptx) text extraction failed for {FileName}", fileName); + return string.Empty; + } + } + + private IEnumerable GetOrderedPowerPointSlideEntries(ZipArchive archive) + { + var slideEntriesByName = archive.Entries + .Where(entry => entry.FullName.StartsWith("ppt/slides/slide", StringComparison.OrdinalIgnoreCase) && + entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(entry => entry.FullName, StringComparer.OrdinalIgnoreCase); + + if (slideEntriesByName.Count == 0) + { + _logger.LogDebug("No slide entries found in PowerPoint archive."); + return Enumerable.Empty(); + } + + var orderedSlideNames = TryGetPowerPointSlideOrder(archive); + if (orderedSlideNames.Count == 0) + { + _logger.LogDebug("Using PowerPoint part-name order fallback for {SlideCount} slides.", slideEntriesByName.Count); + return slideEntriesByName.Values + .OrderBy(entry => GetPowerPointSlideNumber(entry.FullName)) + .ToList(); + } + + var orderedEntries = new List(slideEntriesByName.Count); + foreach (var slideName in orderedSlideNames) + { + if (slideEntriesByName.TryGetValue(slideName, out var slideEntry)) + { + orderedEntries.Add(slideEntry); + slideEntriesByName.Remove(slideName); + } + } + + if (slideEntriesByName.Count > 0) + { + orderedEntries.AddRange(slideEntriesByName.Values.OrderBy(entry => GetPowerPointSlideNumber(entry.FullName))); + } + + _logger.LogDebug("Resolved PowerPoint presentation order for {SlideCount} slides.", orderedEntries.Count); + return orderedEntries; + } + + private static (int RowsProcessed, bool LimitReached) TryAppendExcelSheet(ISheet? sheet, StringBuilder builder) { if (sheet == null) { - return false; + return (0, false); } var processedRows = 0; @@ -248,18 +412,22 @@ private static bool TryAppendExcelSheet(ISheet? sheet, StringBuilder builder) break; } - var limitReached = TryAppendExcelRow(row, builder); - processedRows++; + var (rowHadValue, limitReached) = TryAppendExcelRow(row, builder); + if (rowHadValue) + { + processedRows++; + } + if (limitReached) { - return true; + return (processedRows, true); } } - return builder.Length >= MaxExtractedTextLength; + return (processedRows, builder.Length >= MaxExtractedTextLength); } - private static bool TryAppendExcelRow(IRow row, StringBuilder builder) + private static (bool RowHadValue, bool LimitReached) TryAppendExcelRow(IRow row, StringBuilder builder) { var rowHasValue = false; foreach (var cell in row.Cells.Take(MaxExcelCellsPerRow)) @@ -280,7 +448,7 @@ private static bool TryAppendExcelRow(IRow row, StringBuilder builder) rowHasValue = true; if (limitReached) { - return true; + return (true, true); } } @@ -290,7 +458,7 @@ private static bool TryAppendExcelRow(IRow row, StringBuilder builder) builder.Append(Environment.NewLine); } - return builder.Length >= MaxExtractedTextLength; + return (rowHasValue, builder.Length >= MaxExtractedTextLength); } private static bool TryAppendWithTrailingNewline(StringBuilder builder, string? value) @@ -309,10 +477,108 @@ private static bool TryAppendWithTrailingNewline(StringBuilder builder, string? return builder.Length >= MaxExtractedTextLength; } - private static bool AppendUntilLimit(StringBuilder builder, IEnumerable texts) + private static string ExtractPowerPointSlideText(Stream slideStream) { - var limitReached = texts.Any(text => TryAppendWithTrailingNewline(builder, text)); - return limitReached || builder.Length >= MaxExtractedTextLength; + var document = XDocument.Load(slideStream); + XNamespace drawingNamespace = "http://schemas.openxmlformats.org/drawingml/2006/main"; + var textRuns = document + .Descendants(drawingNamespace + "t") + .Select(node => node.Value?.Trim()) + .Where(value => !string.IsNullOrWhiteSpace(value)); + + return string.Join(Environment.NewLine, textRuns); + } + + private static int GetPowerPointSlideNumber(string entryName) + { + var fileName = Path.GetFileNameWithoutExtension(entryName); + if (string.IsNullOrWhiteSpace(fileName)) + { + return int.MaxValue; + } + + var slideNumberText = fileName.Substring("slide".Length); + return int.TryParse(slideNumberText, out var slideNumber) + ? slideNumber + : int.MaxValue; + } + + private List TryGetPowerPointSlideOrder(ZipArchive archive) + { + try + { + var presentationEntry = archive.GetEntry("ppt/presentation.xml"); + var relationshipsEntry = archive.GetEntry("ppt/_rels/presentation.xml.rels"); + if (presentationEntry == null || relationshipsEntry == null) + { + return new List(); + } + + using var presentationStream = presentationEntry.Open(); + using var relationshipsStream = relationshipsEntry.Open(); + var presentationDocument = XDocument.Load(presentationStream); + var relationshipsDocument = XDocument.Load(relationshipsStream); + + XNamespace presentationNamespace = "http://schemas.openxmlformats.org/presentationml/2006/main"; + XNamespace officeDocumentRelationshipsNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + XNamespace packageRelationshipsNamespace = "http://schemas.openxmlformats.org/package/2006/relationships"; + + var slideTargetsByRelationshipId = (relationshipsDocument + .Root? + .Elements(packageRelationshipsNamespace + "Relationship") + .Where(element => string.Equals( + element.Attribute("Type")?.Value, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide", + StringComparison.OrdinalIgnoreCase)) + .Select(element => new + { + Id = element.Attribute("Id")?.Value, + Target = NormalizePowerPointSlideTarget(element.Attribute("Target")?.Value) + }) + .Where(item => !string.IsNullOrWhiteSpace(item.Id) && !string.IsNullOrWhiteSpace(item.Target)) + .ToDictionary(item => item.Id!, item => item.Target!, StringComparer.OrdinalIgnoreCase)) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + + return presentationDocument + .Descendants(presentationNamespace + "sldId") + .Select(element => element.Attribute(officeDocumentRelationshipsNamespace + "id")?.Value) + .Where(relationshipId => !string.IsNullOrWhiteSpace(relationshipId)) + .Select(relationshipId => slideTargetsByRelationshipId.GetValueOrDefault(relationshipId!)) + .Where(target => !string.IsNullOrWhiteSpace(target)) + .Cast() + .ToList(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Falling back to part-name slide order for PowerPoint extraction."); + return new List(); + } + } + + private static string? NormalizePowerPointSlideTarget(string? target) + { + if (string.IsNullOrWhiteSpace(target)) + { + return null; + } + + var normalizedTarget = target.Replace('\\', '/').TrimStart('/'); + if (normalizedTarget.StartsWith("ppt/", StringComparison.OrdinalIgnoreCase)) + { + return normalizedTarget; + } + + if (normalizedTarget.StartsWith("slides/", StringComparison.OrdinalIgnoreCase)) + { + return $"ppt/{normalizedTarget}"; + } + + if (normalizedTarget.StartsWith("../", StringComparison.OrdinalIgnoreCase)) + { + normalizedTarget = normalizedTarget.Substring(3); + } + + return $"ppt/{normalizedTarget}"; } private static void AppendTrailingNewlineIfRoom(StringBuilder builder) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs index bd12f4410f..a9398d1547 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs @@ -5,14 +5,17 @@ using System.Linq.Expressions; using System.Linq; using System.Threading.Tasks; +using Unity.AI.Permissions; using Unity.GrantManager.AI; using Unity.GrantManager.Applications; using Unity.GrantManager.Identity; using Unity.GrantManager.Intakes; +using Volo.Abp; using Volo.Abp.Application.Services; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; +using Volo.Abp.Features; namespace Unity.GrantManager.Attachments; @@ -26,7 +29,8 @@ public class AttachmentAppService( IIntakeFormSubmissionManager intakeFormSubmissionManager, IPersonRepository personUserRepository, IAIService aiService, - ISubmissionAppService submissionAppService) : ApplicationService, IAttachmentAppService + ISubmissionAppService submissionAppService, + IFeatureChecker featureChecker) : ApplicationService, IAttachmentAppService { private const string DefaultContentType = "application/octet-stream"; private const string SummaryGenerationFailedMessage = "AI summary generation failed."; @@ -189,8 +193,14 @@ protected internal static async Task UpdateMetadataIntern return attachment.CreatorId; } + [Authorize(AIPermissions.AttachmentSummary.AttachmentSummaryDefault)] public async Task GenerateAISummaryAttachmentAsync(Guid attachmentId, string? promptVersion = null, bool capturePromptIo = false) { + if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries")) + { + throw new UserFriendlyException("AI attachment summaries are not enabled."); + } + if (!await aiService.IsAvailableAsync()) { Logger.LogWarning("AI service is not available for attachment summary generation. AttachmentId: {AttachmentId}", attachmentId); @@ -217,8 +227,14 @@ public async Task GenerateAISummaryAttachmentAsync(Guid attachmentId, st return summaryResponse.Summary; } + [Authorize(AIPermissions.AttachmentSummary.AttachmentSummaryDefault)] public async Task> GenerateAISummariesAttachmentsAsync(List attachmentIds, string? promptVersion = null, bool capturePromptIo = false) { + if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries")) + { + throw new UserFriendlyException("AI attachment summaries are not enabled."); + } + if (!await aiService.IsAvailableAsync()) { Logger.LogWarning("AI service is not available for bulk attachment summary generation."); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs index 9858838ff8..2aa5d540d6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs @@ -1,19 +1,29 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; +using Unity.AI.Permissions; using Unity.GrantManager.AI; using Volo.Abp; +using Volo.Abp.Features; namespace Unity.GrantManager.GrantApplications; +[Authorize(AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault)] public class ApplicationAIAnalysisAppService( - IApplicationAnalysisService applicationAnalysisService) + IApplicationAnalysisService applicationAnalysisService, + IFeatureChecker featureChecker) : GrantManagerAppService, IApplicationAIAnalysisAppService { public async Task GenerateAIAnalysisAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false) { try { + if (!await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis")) + { + throw new UserFriendlyException("AI application analysis is not enabled."); + } + return await applicationAnalysisService.RegenerateAndSaveAsync(applicationId, promptVersion, capturePromptIo); } catch (Exception ex) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs index 8780c833f3..14ededab94 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs @@ -2,17 +2,23 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Unity.AI.Permissions; using Unity.GrantManager.AI; using Volo.Abp; +using Volo.Abp.Authorization; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.Features; namespace Unity.GrantManager.GrantApplications { public class ApplicationAIPromptCaptureAppService( IAIPromptCaptureStore promptIoCaptureStore, - IWebHostEnvironment webHostEnvironment) + IWebHostEnvironment webHostEnvironment, + IFeatureChecker featureChecker, + IPermissionChecker permissionChecker) : GrantManagerAppService, IApplicationAIPromptCaptureAppService { - public Task> GetRecentAsync(Guid applicationId, string promptType, string? promptVersion = null) + public async Task> GetRecentAsync(Guid applicationId, string promptType, string? promptVersion = null) { if (!string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase)) { @@ -21,11 +27,55 @@ public Task> GetRecentAsync(Guid applicationId, st if (string.IsNullOrWhiteSpace(promptType)) { - return Task.FromResult(new List()); + return new List(); } + await EnsurePromptCapturePermissionAsync(promptType); + await EnsurePromptCaptureFeatureEnabledAsync(promptType); var captures = promptIoCaptureStore.GetRecent(applicationId.ToString(), promptType, promptVersion); - return Task.FromResult(new List(captures)); + return new List(captures); + } + + private async Task EnsurePromptCapturePermissionAsync(string promptType) + { + var permissionName = promptType switch + { + AIPromptTypes.AttachmentSummary => AIPermissions.AttachmentSummary.AttachmentSummaryDefault, + AIPromptTypes.ApplicationAnalysis => AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault, + AIPromptTypes.ScoresheetSection => AIPermissions.ScoringAssistant.ScoringAssistantDefault, + _ => null + }; + + if (string.IsNullOrWhiteSpace(permissionName)) + { + throw new UserFriendlyException("Unknown prompt type."); + } + + if (!await permissionChecker.IsGrantedAsync(permissionName)) + { + throw new AbpAuthorizationException("The user doesn't have permission to view prompt capture for this prompt type."); + } + } + + private async Task EnsurePromptCaptureFeatureEnabledAsync(string promptType) + { + var featureName = promptType switch + { + AIPromptTypes.AttachmentSummary => "Unity.AI.AttachmentSummaries", + AIPromptTypes.ApplicationAnalysis => "Unity.AI.ApplicationAnalysis", + AIPromptTypes.ScoresheetSection => "Unity.AI.Scoring", + _ => null + }; + + if (string.IsNullOrWhiteSpace(featureName)) + { + throw new UserFriendlyException("Unknown prompt type."); + } + + if (!await featureChecker.IsEnabledAsync(featureName)) + { + throw new UserFriendlyException("Prompt capture is not enabled for this prompt type."); + } } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs index 577dc6c6f7..a0b00cfd29 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs @@ -1,23 +1,46 @@ -using Microsoft.Extensions.Logging; using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; using Unity.AI.Permissions; using Unity.GrantManager.AI; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Intakes.Events; using Volo.Abp; +using Volo.Abp.EventBus.Local; +using Volo.Abp.Features; namespace Unity.GrantManager.GrantApplications; [Authorize(AIPermissions.ScoringAssistant.ScoringAssistantDefault)] public class ApplicationAIScoringAppService( - IApplicationScoresheetAnalysisService applicationScoresheetAnalysisService) + IApplicationScoresheetAnalysisService applicationScoresheetAnalysisService, + IApplicationRepository applicationRepository, + ILocalEventBus localEventBus, + IFeatureChecker featureChecker) : GrantManagerAppService, IApplicationAIScoringAppService { public async Task GenerateAIScoresheetAnswersAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false) { try { - return await applicationScoresheetAnalysisService.RegenerateAndSaveAsync(applicationId, promptVersion, capturePromptIo); + if (!await featureChecker.IsEnabledAsync("Unity.AI.Scoring")) + { + throw new UserFriendlyException("AI scoring is not enabled."); + } + + var result = await applicationScoresheetAnalysisService.RegenerateAndSaveAsync(applicationId, promptVersion, capturePromptIo); + if (string.Equals(result, "{}", StringComparison.Ordinal)) + { + return result; + } + + var application = await applicationRepository.GetAsync(applicationId); + await localEventBus.PublishAsync(new AiScoresheetAnswersGeneratedEvent + { + Application = application + }); + return result; } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs index b06e30aae2..21cc065690 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs @@ -23,6 +23,7 @@ public class GenerateAIContentHandler : ILocalEventHandler CreateNewApplicationAsync(IntakeMapping intakeMa CommunityPopulation = MappingUtil.ConvertToIntFromString(intakeMap.CommunityPopulation), RequestedAmount = MappingUtil.ConvertToDecimalFromStringDefaultZero(intakeMap.RequestedAmount), SubmissionDate = MappingUtil.ConvertDateTimeFromStringDefaultNow(intakeMap.SubmissionDate), - ProjectStartDate = MappingUtil.ConvertDateTimeNullableFromString(intakeMap.ProjectStartDate), - ProjectEndDate = MappingUtil.ConvertDateTimeNullableFromString(intakeMap.ProjectEndDate), + ProjectStartDate = MappingUtil.ConvertDateFromChefsFormat(intakeMap.ProjectStartDate), + ProjectEndDate = MappingUtil.ConvertDateFromChefsFormat(intakeMap.ProjectEndDate), TotalProjectBudget = MappingUtil.ConvertToDecimalFromStringDefaultZero(intakeMap.TotalProjectBudget), Community = intakeMap.Community, ElectoralDistrict = intakeMap.ElectoralDistrict, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Intakes/Mapping/MappingUtil.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Intakes/Mapping/MappingUtil.cs index 21dc117b60..66a5b20d2b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Intakes/Mapping/MappingUtil.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Intakes/Mapping/MappingUtil.cs @@ -84,6 +84,60 @@ public static DateTime ConvertDateTimeFromStringDefaultNow(string? dateTime) return null; } + /// + /// Converts a date string from various CHEFS form component formats to a nullable DateTime. + /// Handles formats from simpledatetime, simpleday, and simpledatetimeadvanced components. + /// Examples: + /// - "2025-06-06T00:00:00-07:00" (simpledatetime/simpledatetimeadvanced - ISO 8601 with timezone) + /// - "06/06/2025" (simpleday - MM/DD/YYYY) + /// - "2025-06-06" (ISO 8601 date only) + /// + /// The date string from a CHEFS form component. + /// A nullable DateTime with the date portion, or null if conversion fails. + public static DateTime? ConvertDateFromChefsFormat(string? dateString) + { + if (string.IsNullOrWhiteSpace(dateString)) + { + return null; + } + + // Try ISO 8601 formats with timezone (simpledatetime, simpledatetimeadvanced) + // Example: "2025-06-06T00:00:00-07:00" + if (DateTime.TryParse(dateString, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTime parsedWithTimezone)) + { + return parsedWithTimezone.Date; + } + + // Try MM/DD/YYYY format (simpleday) + // Example: "06/06/2025" + string[] formats = new[] + { + "MM/dd/yyyy", + "M/d/yyyy", + "MM-dd-yyyy", + "M-d-yyyy" + }; + + if (DateTime.TryParseExact(dateString, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsedExact)) + { + return parsedExact.Date; + } + + // Try standard ISO date format (yyyy-MM-dd) + if (DateTime.TryParseExact(dateString, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsedIso)) + { + return parsedIso.Date; + } + + // Fallback to general parsing with InvariantCulture + if (DateTime.TryParse(dateString, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsedGeneral)) + { + return parsedGeneral.Date; + } + + return null; + } + public static bool IsJObject(dynamic? applicantAgent) { if (applicantAgent == null) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js index 68a1855b1f..6a1ce0db94 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js @@ -3,12 +3,12 @@ $(document).ready(function () { initializeApplicantDetailsPage(); scheduleInitialLayoutPasses(); - window.addEventListener('applicant-submissions-layout-changed', function () { + globalThis.addEventListener('applicant-submissions-layout-changed', function () { applyTabHeightOffset(); debouncedResizeAwareDataTables(); scheduleDeferredLayoutPass(); }); - window.addEventListener('applicant-addresses-layout-changed', function () { + globalThis.addEventListener('applicant-addresses-layout-changed', function () { applyTabHeightOffset(); debouncedResizeAwareDataTables(); scheduleDeferredLayoutPass(); @@ -16,7 +16,7 @@ $(document).ready(function () { // Handle breadcrumb back button $('#goBackToApplicants').on('click', function () { - window.location.href = '/GrantApplicants'; + globalThis.location.href = '/GrantApplicants'; }); // Handle tab switching animations @@ -60,7 +60,7 @@ function initializeApplicantDetailsPage() { }, 500); // Initialize tooltips if any - let tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); + let tooltipTriggerList = Array.prototype.slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); tooltipTriggerList.map(function (tooltipTriggerEl) { return new bootstrap.Tooltip(tooltipTriggerEl); }); @@ -70,9 +70,8 @@ function initializeApplicantDetailsPage() { function debounce(func, wait) { let timeout; return function (...args) { - const context = this; clearTimeout(timeout); - timeout = setTimeout(() => func.apply(context, args), wait); + timeout = setTimeout(() => func.apply(this, args), wait); }; } @@ -295,7 +294,7 @@ function getAvailableViewportHeight(element, minHeight) { const bottomSpacing = 12; return Math.max( minHeight, - Math.floor(window.innerHeight - element.getBoundingClientRect().top - bottomSpacing) + Math.floor(globalThis.innerHeight - element.getBoundingClientRect().top - bottomSpacing) ); } @@ -346,8 +345,8 @@ function initializeResizableDivider() { }; const restoreDividerPosition = () => { - const savedPercentage = parseFloat(localStorage.getItem(storageKey)); - if (isNaN(savedPercentage)) { + const savedPercentage = Number.parseFloat(localStorage.getItem(storageKey)); + if (Number.isNaN(savedPercentage)) { return; } @@ -367,8 +366,8 @@ function initializeResizableDivider() { document.body.style.cursor = 'col-resize'; }); - window.addEventListener('resize', restoreDividerPosition); - window.addEventListener('resize', applyTabHeightOffset); + globalThis.addEventListener('resize', restoreDividerPosition); + globalThis.addEventListener('resize', applyTabHeightOffset); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 860a412d4f..f52738140b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -26,14 +26,18 @@ @inject ICurrentTenant CurrentTenant @inject ISettingProvider SettingProvider -@{ - PageLayout.Content.Title = L["Grants"].Value; - var notificationsFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Notifications"); - var readEmailGranted = await PermissionChecker.IsGrantedAsync("Notifications.Email"); - var aiApplicationAnalysisEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis") - && await PermissionChecker.IsGrantedAsync(AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault); - var flexFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Flex"); -} +@{ + PageLayout.Content.Title = L["Grants"].Value; + var notificationsFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Notifications"); + var readEmailGranted = await PermissionChecker.IsGrantedAsync("Notifications.Email"); + var aiAttachmentSummariesEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries") + && await PermissionChecker.IsGrantedAsync(AIPermissions.AttachmentSummary.AttachmentSummaryDefault); + var aiApplicationAnalysisEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis") + && await PermissionChecker.IsGrantedAsync(AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault); + var aiScoringEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.Scoring") + && await PermissionChecker.IsGrantedAsync(AIPermissions.ScoringAssistant.ScoringAssistantDefault); + var flexFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Flex"); +} @section styles { @@ -67,6 +71,7 @@ + @functions { @@ -493,12 +498,15 @@
Attachment
- + @if (aiAttachmentSummariesEnabled) + { + + }
@@ -533,12 +541,15 @@
Scoring
- + @if (aiScoringEnabled) + { + + }
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 2e78d0df13..79ec9a5e85 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 @@ -16,35 +16,6 @@ $(function () { setPromptCaptureOutput(outputSelector, ''); }; - function formatAIPromptCaptureBlock(capture) { - const parts = []; - - parts.push(`PROMPT TYPE: ${capture.promptType || ''}`); - parts.push(`PROMPT VERSION: ${capture.promptVersion || ''}`); - - if (capture.captureLabel) { - parts.push(`LABEL: ${capture.captureLabel}`); - } - - if (capture.capturedAt) { - parts.push(`CAPTURED AT: ${capture.capturedAt}`); - } - - parts.push(''); - parts.push('SYSTEM PROMPT'); - parts.push(capture.systemPrompt || ''); - parts.push(''); - parts.push('USER PROMPT'); - parts.push(capture.userPrompt || ''); - parts.push(''); - parts.push('RAW OUTPUT'); - parts.push(capture.rawOutput || ''); - parts.push(''); - parts.push('FORMATTED OUTPUT'); - parts.push(capture.formattedOutput || ''); - - return parts.join('\n'); - } globalThis.renderAIPromptCapture = function(containerSelector, outputSelector, captures) { if (!Array.isArray(captures) || captures.length === 0) { @@ -913,6 +884,38 @@ $(function () { globalThis.addEventListener('resize', windowResize); }); +function formatAIPromptCaptureBlock(capture) { + const parts = [ + `PROMPT TYPE: ${capture.promptType || ''}`, + `PROMPT VERSION: ${capture.promptVersion || ''}` + ]; + + if (capture.captureLabel) { + parts.push(`LABEL: ${capture.captureLabel}`); + } + + if (capture.capturedAt) { + parts.push(`CAPTURED AT: ${capture.capturedAt}`); + } + + parts.push( + '', + 'SYSTEM PROMPT', + capture.systemPrompt || '', + '', + 'USER PROMPT', + capture.userPrompt || '', + '', + 'RAW OUTPUT', + capture.rawOutput || '', + '', + 'FORMATTED OUTPUT', + capture.formattedOutput || '' + ); + + return parts.join('\n'); +} + // Handle the card header click event function onCardHeaderClick(clickedHeader, cardHeaders) { 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 3c16d8b86a..26f9c97ac2 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 @@ -79,7 +79,7 @@ function normalizeFindings(items, fallbackType) { }; return (items || []) - .filter(item => item) + .filter(Boolean) .map((item, index) => ({ ...item, id: item.id || `${fallbackType}-${index}`, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.js index 8e0c4348c4..62815b2aae 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.js @@ -10,9 +10,6 @@ $(function () { let addressesTable = null; let zoneForm = null; - function notifyApplicantAddressesLayoutChange() { - window.dispatchEvent(new CustomEvent('applicant-addresses-layout-changed')); - } function renderTableLink(data, row) { if (!data || !row.applicationId) { @@ -199,14 +196,6 @@ $(function () { }); } - function safeParse(value) { - try { - return JSON.parse(value || '[]'); - } catch (error) { - console.warn('Unable to parse ApplicantAddresses data.', error); - return []; - } - } function buildSavePayload(zoneFormInstance, $form) { const modifiedFields = Array.from(zoneFormInstance.modifiedFields ?? []); @@ -252,58 +241,73 @@ $(function () { return payload; } - function isGuidEmpty(value) { - return !value || value === '00000000-0000-0000-0000-000000000000'; - } + +}); - function buildAddressPayload(addressId, prefix, $form) { - return { - id: addressId, - street: $form.find(`[name="${prefix}.Street"]`).val(), - street2: $form.find(`[name="${prefix}.Street2"]`).val(), - unit: $form.find(`[name="${prefix}.Unit"]`).val(), - city: $form.find(`[name="${prefix}.City"]`).val(), - province: $form.find(`[name="${prefix}.Province"]`).val(), - postalCode: $form.find(`[name="${prefix}.PostalCode"]`).val() - }; +function safeParse(value) { + try { + return JSON.parse(value || '[]'); + } catch (error) { + console.warn('Unable to parse ApplicantAddresses data.', error); + return []; + } +} + +function notifyApplicantAddressesLayoutChange() { + globalThis.dispatchEvent(new CustomEvent('applicant-addresses-layout-changed')); +} + +function isGuidEmpty(value) { + return !value || value === '00000000-0000-0000-0000-000000000000'; +} + +function buildAddressPayload(addressId, prefix, $form) { + return { + id: addressId, + street: $form.find(`[name="${prefix}.Street"]`).val(), + street2: $form.find(`[name="${prefix}.Street2"]`).val(), + unit: $form.find(`[name="${prefix}.Unit"]`).val(), + city: $form.find(`[name="${prefix}.City"]`).val(), + province: $form.find(`[name="${prefix}.Province"]`).val(), + postalCode: $form.find(`[name="${prefix}.PostalCode"]`).val() + }; +} + +function updateTablesAfterSave(payload, contactsDt, addressesDt) { + if (contactsDt && payload.primaryContact) { + contactsDt.rows().every(function () { + const rowData = this.data(); + if (rowData.id === payload.primaryContact.id) { + rowData.name = payload.primaryContact.fullName || ''; + rowData.email = payload.primaryContact.email || ''; + rowData.phone = payload.primaryContact.businessPhone || payload.primaryContact.cellPhone || ''; + rowData.title = payload.primaryContact.title || ''; + this.data(rowData); + } + }); + contactsDt.rows().invalidate().draw(false); } - function updateTablesAfterSave(payload, contactsDt, addressesDt) { - if (contactsDt && payload.primaryContact) { - contactsDt.rows().every(function () { + if (addressesDt) { + ['primaryPhysicalAddress', 'primaryMailingAddress'].forEach((key) => { + const addressPayload = payload[key]; + if (!addressPayload) { + return; + } + addressesDt.rows().every(function () { const rowData = this.data(); - if (rowData.id === payload.primaryContact.id) { - rowData.name = payload.primaryContact.fullName || ''; - rowData.email = payload.primaryContact.email || ''; - rowData.phone = payload.primaryContact.businessPhone || payload.primaryContact.cellPhone || ''; - rowData.title = payload.primaryContact.title || ''; + if (rowData.id === addressPayload.id) { + rowData.street = addressPayload.street || ''; + rowData.street2 = addressPayload.street2 || ''; + rowData.unit = addressPayload.unit || ''; + rowData.city = addressPayload.city || ''; + rowData.province = addressPayload.province || ''; + rowData.postal = addressPayload.postalCode || ''; this.data(rowData); } }); - contactsDt.rows().invalidate().draw(false); - } - - if (addressesDt) { - ['primaryPhysicalAddress', 'primaryMailingAddress'].forEach((key) => { - const addressPayload = payload[key]; - if (!addressPayload) { - return; - } - addressesDt.rows().every(function () { - const rowData = this.data(); - if (rowData.id === addressPayload.id) { - rowData.street = addressPayload.street || ''; - rowData.street2 = addressPayload.street2 || ''; - rowData.unit = addressPayload.unit || ''; - rowData.city = addressPayload.city || ''; - rowData.province = addressPayload.province || ''; - rowData.postal = addressPayload.postalCode || ''; - this.data(rowData); - } - }); - }); + }); - addressesDt.rows().invalidate().draw(false); - } + addressesDt.rows().invalidate().draw(false); } -}); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.js index d0ab490685..24ea37ab77 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.js @@ -1,6 +1,1149 @@ -$(function () { - const LAYOUT_NOTIFICATION_DELAYS = [0, 120, 280, 600]; +const LAYOUT_NOTIFICATION_DELAYS = [0, 120, 280, 600]; + +// Helper functions +function titleCase(str) { + if (!str) return ''; + str = str.toLowerCase().split(' '); + for (let i = 0; i < str.length; i++) { + str[i] = str[i].charAt(0).toUpperCase() + str[i].slice(1); + } + return str.join(' '); +} + +function convertToYesNo(str) { + switch (str) { + case true: + return "Yes"; + case false: + return "No"; + default: + return ''; + } +} + +function getFullType(code) { + const companyTypes = [ + { code: "BC", name: "BC Company" }, + { code: "CP", name: "Cooperative" }, + { code: "GP", name: "General Partnership" }, + { code: "S", name: "Society" }, + { code: "SP", name: "Sole Proprietorship" }, + { code: "A", name: "Extraprovincial Company" }, + { code: "B", name: "Extraprovincial" }, + { code: "BEN", name: "Benefit Company" }, + { code: "C", name: "Continuation In" }, + { code: "CC", name: "BC Community Contribution Company" }, + { code: "CS", name: "Continued In Society" }, + { code: "CUL", name: "Continuation In as a BC ULC" }, + { code: "EPR", name: "Extraprovincial Registration" }, + { code: "FI", name: "Financial Institution" }, + { code: "FOR", name: "Foreign Registration" }, + { code: "LIB", name: "Public Library Association" }, + { code: "LIC", name: "Licensed (Extra-Pro)" }, + { code: "LL", name: "Limited Liability Partnership" }, + { code: "LLC", name: "Limited Liability Company" }, + { code: "LP", name: "Limited Partnership" }, + { code: "MF", name: "Miscellaneous Firm" }, + { code: "PA", name: "Private Act" }, + { code: "PAR", name: "Parish" }, + { code: "QA", name: "CO 1860" }, + { code: "QB", name: "CO 1862" }, + { code: "QC", name: "CO 1878" }, + { code: "QD", name: "CO 1890" }, + { code: "QE", name: "CO 1897" }, + { code: "REG", name: "Registraton (Extra-pro)" }, + { code: "ULC", name: "BC Unlimited Liability Company" }, + { code: "XCP", name: "Extraprovincial Cooperative" }, + { code: "XL", name: "Extrapro Limited Liability Partnership" }, + { code: "XP", name: "Extraprovincial Limited Partnership" }, + { code: "XS", name: "Extraprovincial Society" } + ]; + const match = companyTypes.find(entry => entry.code === code); + return match ? match.name : "Unknown"; +} + +function payoutDefinition(approvedAmount, totalPaid) { + if ((approvedAmount > 0 && totalPaid > 0) && (approvedAmount === totalPaid)) { + return 'Fully Paid'; + } else if (totalPaid === 0) { + return ''; + } else { + return 'Partially Paid'; + } +} + +function getNames(data) { + let name = ''; + data.forEach((d, index) => { + name = name + (' ' + d.fullName + getDutyText(d)); + if (index != (data.length - 1)) { + name = name + ','; + } + }); + + return name; +} + +function getDutyText(data) { + return data.duty ? (" [" + data.duty + "]") : ''; +} + +function formatItems(items) { + const newData = items.map((item, index) => { + return { + ...item, + rowCount: index + }; + }); + return newData; +} + +function notifySubmissionsLayoutChange() { + globalThis.dispatchEvent(new CustomEvent('applicant-submissions-layout-changed')); +} + +function scheduleLayoutNotifications() { + LAYOUT_NOTIFICATION_DELAYS.forEach((delay) => { + setTimeout(notifySubmissionsLayoutChange, delay); + }); +} + +function bindLayoutNotificationEvents(dataTable) { + dataTable.on('draw', notifySubmissionsLayoutChange); +} + +function updateOpenButtonState(dataTable) { + const selectedRows = dataTable.rows({ selected: true }).data(); + const $openBtn = $('#openSubmissionBtn'); + + if (selectedRows.length === 1) { + $openBtn.prop('disabled', false).show(); + } else { + $openBtn.prop('disabled', true).hide(); + } +} + +// Column getter functions +function getSelectColumn(columnIndex) { + return { + title: '', + data: 'rowCount', + name: 'select', + orderable: false, + className: 'notexport dt-checkboxes-cell', + checkboxes: { + selectRow: true, + selectAllRender: '', + }, + render: function (data, type, row) { + return ''; + }, + index: columnIndex + }; +} + +function getReferenceNoColumn(columnIndex) { + return { + title: 'Submission #', + data: 'referenceNo', + name: 'referenceNo', + className: 'data-table-header text-nowrap', + render: function (data, type, row) { + return `${data || ''}`; + }, + index: columnIndex + }; +} + +function getApplicantNameColumn(columnIndex) { + return { + title: 'Applicant Name', + data: 'applicant.applicantName', + name: 'applicantName', + className: 'data-table-header', + index: columnIndex + }; +} + +function getCategoryColumn(columnIndex, l) { + return { + title: 'Category', + data: 'category', + name: 'category', + className: 'data-table-header', + index: columnIndex + }; +} + +function getSubmissionDateColumn(columnIndex, l) { + return { + title: l('SubmissionDate'), + data: 'submissionDate', + name: 'submissionDate', + className: 'data-table-header', + index: columnIndex, + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); + } + }; +} + +function getProjectNameColumn(columnIndex) { + return { + title: 'Project Name', + data: 'projectName', + name: 'projectName', + className: 'data-table-header', + index: columnIndex + }; +} + +function getSectorColumn(columnIndex) { + return { + title: 'Sector', + name: 'sector', + data: 'applicant.sector', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSubSectorColumn(columnIndex) { + return { + title: 'SubSector', + name: 'subsector', + data: 'applicant.subSector', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getTotalProjectBudgetColumn(columnIndex, formatter) { + return { + title: 'Total Project Budget', + name: 'totalProjectBudget', + data: 'totalProjectBudget', + className: 'data-table-header currency-display', + render: function (data) { + return formatter.format(data); + }, + index: columnIndex + }; +} + +function getAssigneesColumn(columnIndex, l) { + return { + title: l('Assignee'), + data: 'assignees', + name: 'assignees', + className: 'dt-editable', + render: function (data, type, row) { + let displayText = ' '; + + if (data?.length == 1) { + displayText = type === 'fullName' ? getNames(data) : (data[0].fullName + getDutyText(data[0])); + } else if (data.length > 1) { + displayText = getNames(data); + } + + return ` + + ' + displayText + '' + + ``; + }, + index: columnIndex + }; +} + +function getStatusColumn(columnIndex, l) { + return { + title: l('GrantApplicationStatus'), + data: 'status', + name: 'status', + className: 'data-table-header', + index: columnIndex + }; +} + +function getRequestedAmountColumn(columnIndex, l, formatter) { + return { + title: l('RequestedAmount'), + data: 'requestedAmount', + name: 'requestedAmount', + className: 'data-table-header currency-display', + render: function (data) { + return formatter.format(data); + }, + index: columnIndex + }; +} + +function getApprovedAmountColumn(columnIndex, formatter) { + return { + title: 'Approved Amount', + name: 'approvedAmount', + data: 'approvedAmount', + className: 'data-table-header currency-display', + render: function (data) { + return formatter.format(data); + }, + index: columnIndex + }; +} + +function getEconomicRegionColumn(columnIndex) { + return { + title: 'Economic Region', + name: 'economicRegion', + data: 'economicRegion', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getRegionalDistrictColumn(columnIndex) { + return { + title: 'Regional District', + name: 'regionalDistrict', + data: 'regionalDistrict', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getCommunityColumn(columnIndex) { + return { + title: 'Community', + name: 'community', + data: 'community', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getOrganizationNumberColumn(columnIndex, l) { + return { + title: l('ApplicantInfoView:ApplicantInfo.OrgNumber'), + name: 'orgNumber', + data: 'applicant.orgNumber', + className: 'data-table-header', + visible: false, + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getOrgBookStatusColumn(columnIndex) { + return { + title: 'Org Book Status', + name: 'orgBookStatus', + data: 'applicant.orgStatus', + className: 'data-table-header', + render: function (data) { + if (data === 'ACTIVE') { + return 'Active'; + } else if (data === 'HISTORICAL') { + return 'Historical'; + } else { + return data ?? ''; + } + }, + index: columnIndex + }; +} + +function getProjectStartDateColumn(columnIndex) { + return { + title: 'Project Start Date', + name: 'projectStartDate', + data: 'projectStartDate', + className: 'data-table-header', + render: function (data) { + return data ? luxon.DateTime.fromISO(data, { + locale: abp.localization.currentCulture.name, + }).toUTC().toLocaleString() : ''; + }, + index: columnIndex + }; +} + +function getProjectEndDateColumn(columnIndex) { + return { + title: 'Project End Date', + name: 'projectEndDate', + data: 'projectEndDate', + className: 'data-table-header', + render: function (data) { + return data ? luxon.DateTime.fromISO(data, { + locale: abp.localization.currentCulture.name, + }).toUTC().toLocaleString() : ''; + }, + index: columnIndex + }; +} + +function getProjectedFundingTotalColumn(columnIndex, formatter) { + return { + title: 'Projected Funding Total', + name: 'projectFundingTotal', + data: 'projectFundingTotal', + className: 'data-table-header currency-display', + render: function (data) { + return formatter.format(data) ?? ''; + }, + index: columnIndex + }; +} + +function getTotalProjectBudgetPercentageColumn(columnIndex) { + return { + title: '% of Total Project Budget', + name: 'percentageTotalProjectBudget', + data: 'percentageTotalProjectBudget', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getTotalPaidAmountColumn(columnIndex, formatter) { + return { + title: 'Total Paid Amount $', + name: 'totalPaidAmount', + data: 'paymentInfo', + className: 'data-table-header currency-display', + render: function (data) { + let totalPaid = data?.totalPaid ?? ''; + return formatter.format(totalPaid); + }, + index: columnIndex + }; +} + +function getElectoralDistrictColumn(columnIndex) { + return { + title: 'Project Electoral District', + name: 'electoralDistrict', + data: 'electoralDistrict', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getApplicantElectoralDistrictColumn(columnIndex) { + return { + title: 'Applicant Electoral District', + name: 'applicantElectoralDistrict', + data: 'applicantElectoralDistrict', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getForestryOrNonForestryColumn(columnIndex) { + return { + title: 'Forestry or Non-Forestry', + name: 'forestryOrNonForestry', + data: 'forestry', + className: 'data-table-header', + render: function (data) { + if (data) + return data == 'FORESTRY' ? 'Forestry' : 'Non Forestry'; + else + return ''; + }, + index: columnIndex + }; +} + +function getForestryFocusColumn(columnIndex) { + return { + title: 'Forestry Focus', + name: 'forestryFocus', + data: 'forestryFocus', + className: 'data-table-header', + render: function (data) { + if (!data) { + return ''; + } + if (data == 'PRIMARY') { + return 'Primary processing'; + } else if (data == 'SECONDARY') { + return 'Secondary/Value-Added/Not Mass Timber'; + } else if (data == 'MASS_TIMBER') { + return 'Mass Timber'; + } else { + return data; + } + }, + index: columnIndex + }; +} + +function getAcquisitionColumn(columnIndex) { + return { + title: 'Acquisition', + name: 'acquisition', + data: 'acquisition', + className: 'data-table-header', + render: function (data) { + if (data) { + return titleCase(data); + } + else { + return ''; + } + }, + index: columnIndex + }; +} + +function getCityColumn(columnIndex) { + return { + title: 'City', + name: 'city', + data: 'city', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getCommunityPopulationColumn(columnIndex) { + return { + title: 'Community Population', + name: 'communityPopulation', + data: 'communityPopulation', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getLikelihoodOfFundingColumn(columnIndex) { + return { + title: 'Likelihood of Funding', + name: 'likelihoodOfFunding', + data: 'likelihoodOfFunding', + className: 'data-table-header', + render: function (data) { + if (data) { + return titleCase(data); + } + else { + return ''; + } + }, + index: columnIndex + }; +} + +function getSubStatusColumn(columnIndex) { + return { + title: 'Sub-Status', + name: 'subStatusDisplayValue', + data: 'subStatusDisplayValue', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getTagsColumn(columnIndex) { + return { + title: 'Tags', + name: 'applicationTag', + data: 'applicationTag', + className: '', + render: function (data) { + if (data && Array.isArray(data)) { + let tagNames = data + .filter(x => x?.tag?.name) + .map(x => x.tag.name); + return tagNames.join(', ') ?? ''; + } + return ''; + }, + index: columnIndex + }; +} + +function getTotalScoreColumn(columnIndex) { + return { + title: 'Total Score', + name: 'totalScore', + data: 'totalScore', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getAssessmentResultColumn(columnIndex) { + return { + title: 'Assessment Result', + name: 'assessmentResult', + data: 'assessmentResultStatus', + className: 'data-table-header', + render: function (data) { + if (data) { + return titleCase(data); + } + else { + return ''; + } + }, + index: columnIndex + }; +} + +function getRecommendedAmountColumn(columnIndex, formatter) { + return { + title: 'Recommended Amount', + name: 'recommendedAmount', + data: 'recommendedAmount', + className: 'data-table-header currency-display', + render: function (data) { + return formatter.format(data) ?? ''; + }, + index: columnIndex + }; +} + +function getDueDateColumn(columnIndex) { + return { + title: 'Due Date', + name: 'dueDate', + data: 'dueDate', + className: 'data-table-header', + render: function (data) { + return data ? luxon.DateTime.fromISO(data, { + locale: abp.localization.currentCulture.name, + }).toUTC().toLocaleString() : ''; + }, + index: columnIndex + }; +} + +function getOwnerColumn(columnIndex) { + return { + title: 'Owner', + name: 'Owner', + data: 'owner', + className: 'data-table-header', + render: function (data) { + return data ? data.fullName : ''; + }, + index: columnIndex + }; +} + +function getDecisionDateColumn(columnIndex) { + return { + title: 'Decision Date', + name: 'finalDecisionDate', + data: 'finalDecisionDate', + className: 'data-table-header', + render: function (data) { + return data ? luxon.DateTime.fromISO(data, { + locale: abp.localization.currentCulture.name, + }).toUTC().toLocaleString() : ''; + }, + index: columnIndex + }; +} + +function getProjectSummaryColumn(columnIndex) { + return { + title: 'Project Summary', + name: 'projectSummary', + data: 'projectSummary', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getOrganizationTypeColumn(columnIndex) { + return { + title: 'Organization Type', + name: 'organizationType', + data: 'organizationType', + className: 'data-table-header', + render: function (data) { + return getFullType(data) ?? ''; + }, + index: columnIndex + }; +} + +function getOrganizationNameColumn(columnIndex, l) { + return { + title: l('Summary:Application.OrganizationName'), + name: 'organizationName', + data: 'organizationName', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getBusinessNumberColumn(columnIndex, l) { + return { + title: l('Summary:Application.BusinessNumber'), + name: 'businessNumber', + data: 'applicant.businessNumber', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getNonRegisteredOrganizationNameColumn(columnIndex, l) { + return { + title: l('Summary:Application.NonRegOrgName'), + name: 'nonRegOrgName', + data: 'nonRegOrgName', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getUnityApplicationIdColumn(columnIndex) { + return { + title: 'Unity Application ID', + name: 'unityApplicationId', + data: 'unityApplicationId', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getDueDiligenceStatusColumn(columnIndex) { + return { + title: 'Due Diligence Status', + name: 'dueDiligenceStatus', + data: 'dueDiligenceStatus', + className: 'data-table-header', + render: function (data) { + return titleCase(data ?? '') ?? ''; + }, + index: columnIndex + }; +} + +function getDeclineRationaleColumn(columnIndex) { + return { + title: 'Decline Rationale', + name: 'declineRationale', + data: 'declineRational', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getContactFullNameColumn(columnIndex) { + return { + title: 'Contact Full Name', + name: 'contactFullName', + data: 'contactFullName', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getContactTitleColumn(columnIndex) { + return { + title: 'Contact Title', + name: 'contactTitle', + data: 'contactTitle', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getContactEmailColumn(columnIndex) { + return { + title: 'Contact Email', + name: 'contactEmail', + data: 'contactEmail', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getContactBusinessPhoneColumn(columnIndex) { + return { + title: 'Contact Business Phone', + name: 'contactBusinessPhone', + data: 'contactBusinessPhone', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getContactCellPhoneColumn(columnIndex) { + return { + title: 'Contact Cell Phone', + name: 'contactCellPhone', + data: 'contactCellPhone', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSectorSubSectorIndustryDescColumn(columnIndex) { + return { + title: 'Other Sector/Sub/Industry Description', + name: 'sectorSubSectorIndustryDesc', + data: 'applicant.sectorSubSectorIndustryDesc', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSigningAuthorityFullNameColumn(columnIndex) { + return { + title: 'Signing Authority Full Name', + name: 'signingAuthorityFullName', + data: 'signingAuthorityFullName', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSigningAuthorityTitleColumn(columnIndex) { + return { + title: 'Signing Authority Title', + name: 'signingAuthorityTitle', + data: 'signingAuthorityTitle', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSigningAuthorityEmailColumn(columnIndex) { + return { + title: 'Signing Authority Email', + name: 'signingAuthorityEmail', + data: 'signingAuthorityEmail', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSigningAuthorityBusinessPhoneColumn(columnIndex) { + return { + title: 'Signing Authority Business Phone', + name: 'signingAuthorityBusinessPhone', + data: 'signingAuthorityBusinessPhone', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSigningAuthorityCellPhoneColumn(columnIndex) { + return { + title: 'Signing Authority Cell Phone', + name: 'signingAuthorityCellPhone', + data: 'signingAuthorityCellPhone', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getPlaceColumn(columnIndex) { + return { + title: 'Place', + name: 'place', + data: 'place', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getRiskRankingColumn(columnIndex) { + return { + title: 'Risk Ranking', + name: 'riskranking', + data: 'riskRanking', + className: 'data-table-header', + render: function (data) { + return titleCase(data ?? '') ?? ''; + }, + index: columnIndex + }; +} + +function getNotesColumn(columnIndex) { + return { + title: 'Notes', + name: 'notes', + data: 'notes', + className: 'data-table-header multi-line', + width: "20rem", + createdCell: function (td) { + $(td).css('min-width', '20rem'); + }, + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getRedStopColumn(columnIndex) { + return { + title: 'Red-Stop', + name: 'redstop', + data: 'applicant.redStop', + className: 'data-table-header', + render: function (data) { + return convertToYesNo(data); + }, + index: columnIndex + }; +} + +function getIndigenousColumn(columnIndex) { + return { + title: 'Indigenous', + name: 'indigenous', + data: 'applicant.indigenousOrgInd', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getFyeDayColumn(columnIndex) { + return { + title: 'FYE Day', + name: 'fyeDay', + data: 'applicant.fiscalDay', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getFyeMonthColumn(columnIndex) { + return { + title: 'FYE Month', + name: 'fyeMonth', + data: 'applicant.fiscalMonth', + className: 'data-table-header', + render: function (data) { + if (data) { + return titleCase(data); + } + else { + return ''; + } + }, + index: columnIndex + }; +} + +function getApplicantIdColumn(columnIndex) { + return { + title: 'Applicant Id', + name: 'applicantId', + data: 'applicant.unityApplicantId', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getPayoutColumn(columnIndex) { + return { + title: 'Payout', + name: 'paymentInfo', + data: 'paymentInfo', + className: 'data-table-header', + render: function (data) { + return payoutDefinition(data?.approvedAmount ?? 0, data?.totalPaid ?? 0); + }, + index: columnIndex + }; +} +function responseCallback(result) { + return { + recordsTotal: result.totalCount, + recordsFiltered: result.totalCount, + data: formatItems(result.items) + }; +} + +function getColumns(formatter, l) { + let columnIndex = 0; + const sortedColumns = [ + getSelectColumn(columnIndex++), + getReferenceNoColumn(columnIndex++), + getCategoryColumn(columnIndex++, l), + getSubmissionDateColumn(columnIndex++, l), + getStatusColumn(columnIndex++, l), + getRequestedAmountColumn(columnIndex++, l, formatter), + getApprovedAmountColumn(columnIndex++, formatter), + getApplicantNameColumn(columnIndex++), + getProjectNameColumn(columnIndex++), + getSectorColumn(columnIndex++), + getSubSectorColumn(columnIndex++), + getTotalProjectBudgetColumn(columnIndex++, formatter), + getAssigneesColumn(columnIndex++, l), + getEconomicRegionColumn(columnIndex++), + getRegionalDistrictColumn(columnIndex++), + getCommunityColumn(columnIndex++), + getOrganizationNumberColumn(columnIndex++, l), + getOrgBookStatusColumn(columnIndex++), + getProjectStartDateColumn(columnIndex++), + getProjectEndDateColumn(columnIndex++), + getProjectedFundingTotalColumn(columnIndex++, formatter), + getTotalProjectBudgetPercentageColumn(columnIndex++), + getTotalPaidAmountColumn(columnIndex++, formatter), + getElectoralDistrictColumn(columnIndex++), + getApplicantElectoralDistrictColumn(columnIndex++), + getForestryOrNonForestryColumn(columnIndex++), + getForestryFocusColumn(columnIndex++), + getAcquisitionColumn(columnIndex++), + getCityColumn(columnIndex++), + getCommunityPopulationColumn(columnIndex++), + getLikelihoodOfFundingColumn(columnIndex++), + getSubStatusColumn(columnIndex++), + getTagsColumn(columnIndex++), + getTotalScoreColumn(columnIndex++), + getAssessmentResultColumn(columnIndex++), + getRecommendedAmountColumn(columnIndex++, formatter), + getDueDateColumn(columnIndex++), + getOwnerColumn(columnIndex++), + getDecisionDateColumn(columnIndex++), + getProjectSummaryColumn(columnIndex++), + getOrganizationTypeColumn(columnIndex++), + getOrganizationNameColumn(columnIndex++, l), + getBusinessNumberColumn(columnIndex++, l), + getDueDiligenceStatusColumn(columnIndex++), + getDeclineRationaleColumn(columnIndex++), + getContactFullNameColumn(columnIndex++), + getContactTitleColumn(columnIndex++), + getContactEmailColumn(columnIndex++), + getContactBusinessPhoneColumn(columnIndex++), + getContactCellPhoneColumn(columnIndex++), + getSectorSubSectorIndustryDescColumn(columnIndex++), + getSigningAuthorityFullNameColumn(columnIndex++), + getSigningAuthorityTitleColumn(columnIndex++), + getSigningAuthorityEmailColumn(columnIndex++), + getSigningAuthorityBusinessPhoneColumn(columnIndex++), + getSigningAuthorityCellPhoneColumn(columnIndex++), + getPlaceColumn(columnIndex++), + getRiskRankingColumn(columnIndex++), + getNotesColumn(columnIndex++), + getRedStopColumn(columnIndex++), + getIndigenousColumn(columnIndex++), + getFyeDayColumn(columnIndex++), + getFyeMonthColumn(columnIndex++), + getApplicantIdColumn(columnIndex++), + getPayoutColumn(columnIndex++), + getNonRegisteredOrganizationNameColumn(columnIndex++, l), + getUnityApplicationIdColumn(columnIndex++) + ].map((column) => ({ ...column, targets: [column.index], orderData: [column.index, 0] })) + .sort((a, b) => a.index - b.index); + return sortedColumns; +} + +$(function () { // Check if createNumberFormatter exists if (typeof createNumberFormatter !== 'function') { console.error('createNumberFormatter is not defined. Ensure table-utils.js is loaded before this script'); @@ -45,26 +1188,7 @@ $(function () { const submissionsData = submissionsDataJson ? JSON.parse(submissionsDataJson) : []; // Get all columns - const listColumns = getColumns(); - - // Response callback - same pattern as Application List - const responseCallback = function (result) { - return { - recordsTotal: result.totalCount, - recordsFiltered: result.totalCount, - data: formatItems(result.items) - }; - }; - - const formatItems = function (items) { - const newData = items.map((item, index) => { - return { - ...item, - rowCount: index - }; - }); - return newData; - }; + const listColumns = getColumns(formatter, l); // Mock service that returns embedded data (simulating API endpoint) // Must return a jQuery Deferred object (not native Promise) for ABP compatibility @@ -99,1157 +1223,26 @@ $(function () { dynamicButtonContainerId: 'submissionsDynamicButtonContainerId' }); - function notifySubmissionsLayoutChange() { - window.dispatchEvent(new CustomEvent('applicant-submissions-layout-changed')); - } - scheduleLayoutNotifications(); - bindLayoutNotificationEvents(); + bindLayoutNotificationEvents(dataTable); // External search binding dataTable.externalSearch('#submissions-search', { delay: 300 }); // Open button handling - function updateOpenButtonState() { - const selectedRows = dataTable.rows({ selected: true }).data(); - const $openBtn = $('#openSubmissionBtn'); - - if (selectedRows.length === 1) { - $openBtn.prop('disabled', false).show(); - } else { - $openBtn.prop('disabled', true).hide(); - } - } - dataTable.on('select deselect', function () { - updateOpenButtonState(); + updateOpenButtonState(dataTable); }); $('#openSubmissionBtn').on('click', function () { const selectedRows = dataTable.rows({ selected: true }).data(); if (selectedRows.length === 1) { - window.location.href = `/GrantApplications/Details?ApplicationId=${selectedRows[0].id}`; + globalThis.location.href = `/GrantApplications/Details?ApplicationId=${selectedRows[0].id}`; } }); // Initialize button state - updateOpenButtonState(); - - function scheduleLayoutNotifications() { - LAYOUT_NOTIFICATION_DELAYS.forEach((delay) => { - setTimeout(notifySubmissionsLayoutChange, delay); - }); - } - - function bindLayoutNotificationEvents() { - dataTable.on('draw', notifySubmissionsLayoutChange); - } - - // Column getter functions (from Application List) - function getColumns() { - let columnIndex = 0; - const sortedColumns = [ - getSelectColumn(columnIndex++), - getReferenceNoColumn(columnIndex++), - getCategoryColumn(columnIndex++), - getSubmissionDateColumn(columnIndex++), - getStatusColumn(columnIndex++), - getRequestedAmountColumn(columnIndex++), - getApprovedAmountColumn(columnIndex++), - getApplicantNameColumn(columnIndex++), - getProjectNameColumn(columnIndex++), - getSectorColumn(columnIndex++), - getSubSectorColumn(columnIndex++), - getTotalProjectBudgetColumn(columnIndex++), - getAssigneesColumn(columnIndex++), - getEconomicRegionColumn(columnIndex++), - getRegionalDistrictColumn(columnIndex++), - getCommunityColumn(columnIndex++), - getOrganizationNumberColumn(columnIndex++), - getOrgBookStatusColumn(columnIndex++), - getProjectStartDateColumn(columnIndex++), - getProjectEndDateColumn(columnIndex++), - getProjectedFundingTotalColumn(columnIndex++), - getTotalProjectBudgetPercentageColumn(columnIndex++), - getTotalPaidAmountColumn(columnIndex++), - getElectoralDistrictColumn(columnIndex++), - getApplicantElectoralDistrictColumn(columnIndex++), - getForestryOrNonForestryColumn(columnIndex++), - getForestryFocusColumn(columnIndex++), - getAcquisitionColumn(columnIndex++), - getCityColumn(columnIndex++), - getCommunityPopulationColumn(columnIndex++), - getLikelihoodOfFundingColumn(columnIndex++), - getSubStatusColumn(columnIndex++), - getTagsColumn(columnIndex++), - getTotalScoreColumn(columnIndex++), - getAssessmentResultColumn(columnIndex++), - getRecommendedAmountColumn(columnIndex++), - getDueDateColumn(columnIndex++), - getOwnerColumn(columnIndex++), - getDecisionDateColumn(columnIndex++), - getProjectSummaryColumn(columnIndex++), - getOrganizationTypeColumn(columnIndex++), - getOrganizationNameColumn(columnIndex++), - getBusinessNumberColumn(columnIndex++), - getDueDiligenceStatusColumn(columnIndex++), - getDeclineRationaleColumn(columnIndex++), - getContactFullNameColumn(columnIndex++), - getContactTitleColumn(columnIndex++), - getContactEmailColumn(columnIndex++), - getContactBusinessPhoneColumn(columnIndex++), - getContactCellPhoneColumn(columnIndex++), - getSectorSubSectorIndustryDescColumn(columnIndex++), - getSigningAuthorityFullNameColumn(columnIndex++), - getSigningAuthorityTitleColumn(columnIndex++), - getSigningAuthorityEmailColumn(columnIndex++), - getSigningAuthorityBusinessPhoneColumn(columnIndex++), - getSigningAuthorityCellPhoneColumn(columnIndex++), - getPlaceColumn(columnIndex++), - getRiskRankingColumn(columnIndex++), - getNotesColumn(columnIndex++), - getRedStopColumn(columnIndex++), - getIndigenousColumn(columnIndex++), - getFyeDayColumn(columnIndex++), - getFyeMonthColumn(columnIndex++), - getApplicantIdColumn(columnIndex++), - getPayoutColumn(columnIndex++), - getNonRegisteredOrganizationNameColumn(columnIndex++), - getUnityApplicationIdColumn(columnIndex++) - ].map((column) => ({ ...column, targets: [column.index], orderData: [column.index, 0] })) - .sort((a, b) => a.index - b.index); - return sortedColumns; - } - - // Select column - function getSelectColumn(columnIndex) { - return { - title: '', - data: 'rowCount', - name: 'select', - orderable: false, - className: 'notexport dt-checkboxes-cell', - checkboxes: { - selectRow: true, - selectAllRender: '', - }, - render: function (data, type, row) { - return ''; - }, - index: columnIndex - }; - } - - // Submission # (referenceNo) - clickable link to Application Details - function getReferenceNoColumn(columnIndex) { - return { - title: 'Submission #', - data: 'referenceNo', - name: 'referenceNo', - className: 'data-table-header text-nowrap', - render: function (data, type, row) { - return `${data || ''}`; - }, - index: columnIndex - }; - } - - // All other column definitions copied from Application List - function getApplicantNameColumn(columnIndex) { - return { - title: 'Applicant Name', - data: 'applicant.applicantName', - name: 'applicantName', - className: 'data-table-header', - index: columnIndex - }; - } - - function getCategoryColumn(columnIndex) { - return { - title: 'Category', - data: 'category', - name: 'category', - className: 'data-table-header', - index: columnIndex - }; - } - - function getSubmissionDateColumn(columnIndex) { - return { - title: l('SubmissionDate'), - data: 'submissionDate', - name: 'submissionDate', - className: 'data-table-header', - index: columnIndex, - render: function (data, type) { - return DateUtils.formatUtcDateToLocal(data, type); - } - }; - } - - function getProjectNameColumn(columnIndex) { - return { - title: 'Project Name', - data: 'projectName', - name: 'projectName', - className: 'data-table-header', - index: columnIndex - }; - } - - function getSectorColumn(columnIndex) { - return { - title: 'Sector', - name: 'sector', - data: 'applicant.sector', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSubSectorColumn(columnIndex) { - return { - title: 'SubSector', - name: 'subsector', - data: 'applicant.subSector', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getTotalProjectBudgetColumn(columnIndex) { - return { - title: 'Total Project Budget', - name: 'totalProjectBudget', - data: 'totalProjectBudget', - className: 'data-table-header currency-display', - render: function (data) { - return formatter.format(data); - }, - index: columnIndex - }; - } - - function getAssigneesColumn(columnIndex) { - return { - title: l('Assignee'), - data: 'assignees', - name: 'assignees', - className: 'dt-editable', - render: function (data, type, row) { - let displayText = ' '; - - if (data != null && data.length == 1) { - displayText = type === 'fullName' ? getNames(data) : (data[0].fullName + getDutyText(data[0])); - } else if (data.length > 1) { - displayText = getNames(data); - } - - return ` - - ' + displayText + '' + - ``; - }, - index: columnIndex - }; - } - - function getStatusColumn(columnIndex) { - return { - title: l('GrantApplicationStatus'), - data: 'status', - name: 'status', - className: 'data-table-header', - index: columnIndex - }; - } - - function getRequestedAmountColumn(columnIndex) { - return { - title: l('RequestedAmount'), - data: 'requestedAmount', - name: 'requestedAmount', - className: 'data-table-header currency-display', - render: function (data) { - return formatter.format(data); - }, - index: columnIndex - }; - } - - function getApprovedAmountColumn(columnIndex) { - return { - title: 'Approved Amount', - name: 'approvedAmount', - data: 'approvedAmount', - className: 'data-table-header currency-display', - render: function (data) { - return formatter.format(data); - }, - index: columnIndex - }; - } + updateOpenButtonState(dataTable); - function getEconomicRegionColumn(columnIndex) { - return { - title: 'Economic Region', - name: 'economicRegion', - data: 'economicRegion', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } +}); - function getRegionalDistrictColumn(columnIndex) { - return { - title: 'Regional District', - name: 'regionalDistrict', - data: 'regionalDistrict', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getCommunityColumn(columnIndex) { - return { - title: 'Community', - name: 'community', - data: 'community', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getOrganizationNumberColumn(columnIndex) { - return { - title: l('ApplicantInfoView:ApplicantInfo.OrgNumber'), - name: 'orgNumber', - data: 'applicant.orgNumber', - className: 'data-table-header', - visible: false, - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getOrgBookStatusColumn(columnIndex) { - return { - title: 'Org Book Status', - name: 'orgBookStatus', - data: 'applicant.orgStatus', - className: 'data-table-header', - render: function (data) { - if (data != null && data == 'ACTIVE') { - return 'Active'; - } else if (data != null && data == 'HISTORICAL') { - return 'Historical'; - } else { - return data ?? ''; - } - }, - index: columnIndex - }; - } - - function getProjectStartDateColumn(columnIndex) { - return { - title: 'Project Start Date', - name: 'projectStartDate', - data: 'projectStartDate', - className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; - }, - index: columnIndex - }; - } - - function getProjectEndDateColumn(columnIndex) { - return { - title: 'Project End Date', - name: 'projectEndDate', - data: 'projectEndDate', - className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; - }, - index: columnIndex - }; - } - - function getProjectedFundingTotalColumn(columnIndex) { - return { - title: 'Projected Funding Total', - name: 'projectFundingTotal', - data: 'projectFundingTotal', - className: 'data-table-header currency-display', - render: function (data) { - return formatter.format(data) ?? ''; - }, - index: columnIndex - }; - } - - function getTotalProjectBudgetPercentageColumn(columnIndex) { - return { - title: '% of Total Project Budget', - name: 'percentageTotalProjectBudget', - data: 'percentageTotalProjectBudget', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getTotalPaidAmountColumn(columnIndex) { - return { - title: 'Total Paid Amount $', - name: 'totalPaidAmount', - data: 'paymentInfo', - className: 'data-table-header currency-display', - render: function (data) { - let totalPaid = data?.totalPaid ?? ''; - return formatter.format(totalPaid); - }, - index: columnIndex - }; - } - - function getElectoralDistrictColumn(columnIndex) { - return { - title: 'Project Electoral District', - name: 'electoralDistrict', - data: 'electoralDistrict', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getApplicantElectoralDistrictColumn(columnIndex) { - return { - title: 'Applicant Electoral District', - name: 'applicantElectoralDistrict', - data: 'applicantElectoralDistrict', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getForestryOrNonForestryColumn(columnIndex) { - return { - title: 'Forestry or Non-Forestry', - name: 'forestryOrNonForestry', - data: 'forestry', - className: 'data-table-header', - render: function (data) { - if (data != null) - return data == 'FORESTRY' ? 'Forestry' : 'Non Forestry'; - else - return ''; - }, - index: columnIndex - }; - } - - function getForestryFocusColumn(columnIndex) { - return { - title: 'Forestry Focus', - name: 'forestryFocus', - data: 'forestryFocus', - className: 'data-table-header', - render: function (data) { - if (data) { - if (data == 'PRIMARY') { - return 'Primary processing' - } - else if (data == 'SECONDARY') { - return 'Secondary/Value-Added/Not Mass Timber' - } else if (data == 'MASS_TIMBER') { - return 'Mass Timber'; - } else if (data != '') { - return data; - } else { - return ''; - } - } - else { - return ''; - } - }, - index: columnIndex - }; - } - - function getAcquisitionColumn(columnIndex) { - return { - title: 'Acquisition', - name: 'acquisition', - data: 'acquisition', - className: 'data-table-header', - render: function (data) { - if (data) { - return titleCase(data); - } - else { - return ''; - } - }, - index: columnIndex - }; - } - - function getCityColumn(columnIndex) { - return { - title: 'City', - name: 'city', - data: 'city', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getCommunityPopulationColumn(columnIndex) { - return { - title: 'Community Population', - name: 'communityPopulation', - data: 'communityPopulation', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getLikelihoodOfFundingColumn(columnIndex) { - return { - title: 'Likelihood of Funding', - name: 'likelihoodOfFunding', - data: 'likelihoodOfFunding', - className: 'data-table-header', - render: function (data) { - if (data != null) { - return titleCase(data); - } - else { - return ''; - } - }, - index: columnIndex - }; - } - - function getSubStatusColumn(columnIndex) { - return { - title: 'Sub-Status', - name: 'subStatusDisplayValue', - data: 'subStatusDisplayValue', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getTagsColumn(columnIndex) { - return { - title: 'Tags', - name: 'applicationTag', - data: 'applicationTag', - className: '', - render: function (data) { - if (data && Array.isArray(data)) { - let tagNames = data - .filter(x => x?.tag?.name) - .map(x => x.tag.name); - return tagNames.join(', ') ?? ''; - } - return ''; - }, - index: columnIndex - }; - } - - function getTotalScoreColumn(columnIndex) { - return { - title: 'Total Score', - name: 'totalScore', - data: 'totalScore', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getAssessmentResultColumn(columnIndex) { - return { - title: 'Assessment Result', - name: 'assessmentResult', - data: 'assessmentResultStatus', - className: 'data-table-header', - render: function (data) { - if (data != null) { - return titleCase(data); - } - else { - return ''; - } - }, - index: columnIndex - }; - } - - function getRecommendedAmountColumn(columnIndex) { - return { - title: 'Recommended Amount', - name: 'recommendedAmount', - data: 'recommendedAmount', - className: 'data-table-header currency-display', - render: function (data) { - return formatter.format(data) ?? ''; - }, - index: columnIndex - }; - } - - function getDueDateColumn(columnIndex) { - return { - title: 'Due Date', - name: 'dueDate', - data: 'dueDate', - className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; - }, - index: columnIndex - }; - } - - function getOwnerColumn(columnIndex) { - return { - title: 'Owner', - name: 'Owner', - data: 'owner', - className: 'data-table-header', - render: function (data) { - return data != null ? data.fullName : ''; - }, - index: columnIndex - }; - } - - function getDecisionDateColumn(columnIndex) { - return { - title: 'Decision Date', - name: 'finalDecisionDate', - data: 'finalDecisionDate', - className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; - }, - index: columnIndex - }; - } - - function getProjectSummaryColumn(columnIndex) { - return { - title: 'Project Summary', - name: 'projectSummary', - data: 'projectSummary', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getOrganizationTypeColumn(columnIndex) { - return { - title: 'Organization Type', - name: 'organizationType', - data: 'organizationType', - className: 'data-table-header', - render: function (data) { - return getFullType(data) ?? ''; - }, - index: columnIndex - }; - } - - function getOrganizationNameColumn(columnIndex) { - return { - title: l('Summary:Application.OrganizationName'), - name: 'organizationName', - data: 'organizationName', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getBusinessNumberColumn(columnIndex) { - return { - title: l('Summary:Application.BusinessNumber'), - name: 'businessNumber', - data: 'applicant.businessNumber', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getNonRegisteredOrganizationNameColumn(columnIndex) { - return { - title: l('Summary:Application.NonRegOrgName'), - name: 'nonRegOrgName', - data: 'nonRegOrgName', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getUnityApplicationIdColumn(columnIndex) { - return { - title: 'Unity Application ID', - name: 'unityApplicationId', - data: 'unityApplicationId', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getDueDiligenceStatusColumn(columnIndex) { - return { - title: 'Due Diligence Status', - name: 'dueDiligenceStatus', - data: 'dueDiligenceStatus', - className: 'data-table-header', - render: function (data) { - return titleCase(data ?? '') ?? ''; - }, - index: columnIndex - }; - } - - function getDeclineRationaleColumn(columnIndex) { - return { - title: 'Decline Rationale', - name: 'declineRationale', - data: 'declineRational', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getContactFullNameColumn(columnIndex) { - return { - title: 'Contact Full Name', - name: 'contactFullName', - data: 'contactFullName', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getContactTitleColumn(columnIndex) { - return { - title: 'Contact Title', - name: 'contactTitle', - data: 'contactTitle', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getContactEmailColumn(columnIndex) { - return { - title: 'Contact Email', - name: 'contactEmail', - data: 'contactEmail', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getContactBusinessPhoneColumn(columnIndex) { - return { - title: 'Contact Business Phone', - name: 'contactBusinessPhone', - data: 'contactBusinessPhone', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getContactCellPhoneColumn(columnIndex) { - return { - title: 'Contact Cell Phone', - name: 'contactCellPhone', - data: 'contactCellPhone', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSectorSubSectorIndustryDescColumn(columnIndex) { - return { - title: 'Other Sector/Sub/Industry Description', - name: 'sectorSubSectorIndustryDesc', - data: 'applicant.sectorSubSectorIndustryDesc', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSigningAuthorityFullNameColumn(columnIndex) { - return { - title: 'Signing Authority Full Name', - name: 'signingAuthorityFullName', - data: 'signingAuthorityFullName', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSigningAuthorityTitleColumn(columnIndex) { - return { - title: 'Signing Authority Title', - name: 'signingAuthorityTitle', - data: 'signingAuthorityTitle', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSigningAuthorityEmailColumn(columnIndex) { - return { - title: 'Signing Authority Email', - name: 'signingAuthorityEmail', - data: 'signingAuthorityEmail', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSigningAuthorityBusinessPhoneColumn(columnIndex) { - return { - title: 'Signing Authority Business Phone', - name: 'signingAuthorityBusinessPhone', - data: 'signingAuthorityBusinessPhone', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSigningAuthorityCellPhoneColumn(columnIndex) { - return { - title: 'Signing Authority Cell Phone', - name: 'signingAuthorityCellPhone', - data: 'signingAuthorityCellPhone', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getPlaceColumn(columnIndex) { - return { - title: 'Place', - name: 'place', - data: 'place', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getRiskRankingColumn(columnIndex) { - return { - title: 'Risk Ranking', - name: 'riskranking', - data: 'riskRanking', - className: 'data-table-header', - render: function (data) { - return titleCase(data ?? '') ?? ''; - }, - index: columnIndex - }; - } - - function getNotesColumn(columnIndex) { - return { - title: 'Notes', - name: 'notes', - data: 'notes', - className: 'data-table-header multi-line', - width: "20rem", - createdCell: function (td) { - $(td).css('min-width', '20rem'); - }, - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getRedStopColumn(columnIndex) { - return { - title: 'Red-Stop', - name: 'redstop', - data: 'applicant.redStop', - className: 'data-table-header', - render: function (data) { - return convertToYesNo(data); - }, - index: columnIndex - }; - } - - function getIndigenousColumn(columnIndex) { - return { - title: 'Indigenous', - name: 'indigenous', - data: 'applicant.indigenousOrgInd', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getFyeDayColumn(columnIndex) { - return { - title: 'FYE Day', - name: 'fyeDay', - data: 'applicant.fiscalDay', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getFyeMonthColumn(columnIndex) { - return { - title: 'FYE Month', - name: 'fyeMonth', - data: 'applicant.fiscalMonth', - className: 'data-table-header', - render: function (data) { - if (data) { - return titleCase(data); - } - else { - return ''; - } - }, - index: columnIndex - }; - } - - function getApplicantIdColumn(columnIndex) { - return { - title: 'Applicant Id', - name: 'applicantId', - data: 'applicant.unityApplicantId', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getPayoutColumn(columnIndex) { - return { - title: 'Payout', - name: 'paymentInfo', - data: 'paymentInfo', - className: 'data-table-header', - render: function (data) { - return payoutDefinition(data?.approvedAmount ?? 0, data?.totalPaid ?? 0); - }, - index: columnIndex - }; - } - - // Helper functions - function titleCase(str) { - if (!str) return ''; - str = str.toLowerCase().split(' '); - for (let i = 0; i < str.length; i++) { - str[i] = str[i].charAt(0).toUpperCase() + str[i].slice(1); - } - return str.join(' '); - } - - function convertToYesNo(str) { - switch (str) { - case true: - return "Yes"; - case false: - return "No"; - default: - return ''; - } - } - - function getFullType(code) { - const companyTypes = [ - { code: "BC", name: "BC Company" }, - { code: "CP", name: "Cooperative" }, - { code: "GP", name: "General Partnership" }, - { code: "S", name: "Society" }, - { code: "SP", name: "Sole Proprietorship" }, - { code: "A", name: "Extraprovincial Company" }, - { code: "B", name: "Extraprovincial" }, - { code: "BEN", name: "Benefit Company" }, - { code: "C", name: "Continuation In" }, - { code: "CC", name: "BC Community Contribution Company" }, - { code: "CS", name: "Continued In Society" }, - { code: "CUL", name: "Continuation In as a BC ULC" }, - { code: "EPR", name: "Extraprovincial Registration" }, - { code: "FI", name: "Financial Institution" }, - { code: "FOR", name: "Foreign Registration" }, - { code: "LIB", name: "Public Library Association" }, - { code: "LIC", name: "Licensed (Extra-Pro)" }, - { code: "LL", name: "Limited Liability Partnership" }, - { code: "LLC", name: "Limited Liability Company" }, - { code: "LP", name: "Limited Partnership" }, - { code: "MF", name: "Miscellaneous Firm" }, - { code: "PA", name: "Private Act" }, - { code: "PAR", name: "Parish" }, - { code: "QA", name: "CO 1860" }, - { code: "QB", name: "CO 1862" }, - { code: "QC", name: "CO 1878" }, - { code: "QD", name: "CO 1890" }, - { code: "QE", name: "CO 1897" }, - { code: "REG", name: "Registraton (Extra-pro)" }, - { code: "ULC", name: "BC Unlimited Liability Company" }, - { code: "XCP", name: "Extraprovincial Cooperative" }, - { code: "XL", name: "Extrapro Limited Liability Partnership" }, - { code: "XP", name: "Extraprovincial Limited Partnership" }, - { code: "XS", name: "Extraprovincial Society" } - ]; - const match = companyTypes.find(entry => entry.code === code); - return match ? match.name : "Unknown"; - } - - function payoutDefinition(approvedAmount, totalPaid) { - if ((approvedAmount > 0 && totalPaid > 0) && (approvedAmount === totalPaid)) { - return 'Fully Paid'; - } else if (totalPaid === 0) { - return ''; - } else { - return 'Partially Paid'; - } - } - - function getNames(data) { - let name = ''; - data.forEach((d, index) => { - name = name + (' ' + d.fullName + getDutyText(d)); - if (index != (data.length - 1)) { - name = name + ','; - } - }); - - return name; - } - - function getDutyText(data) { - return data.duty ? (" [" + data.duty + "]") : ''; - } -}); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs index bbcab59471..53b88bfd8d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs @@ -17,6 +17,9 @@ using Unity.GrantManager.AI; using Unity.GrantManager.Applications; using System.Text.Json; +using Unity.AI.Permissions; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.Features; namespace Unity.GrantManager.Web.Views.Shared.Components.AssessmentScoresWidget { @@ -28,7 +31,9 @@ namespace Unity.GrantManager.Web.Views.Shared.Components.AssessmentScoresWidget public class AssessmentScoresWidgetViewComponent(IAssessmentRepository assessmentRepository, IScoresheetRepository scoresheetRepository, IScoresheetInstanceRepository scoresheetInstanceRepository, - IApplicationRepository applicationRepository) : AbpViewComponent + IApplicationRepository applicationRepository, + IFeatureChecker featureChecker, + IPermissionChecker permissionChecker) : AbpViewComponent { public async Task InvokeAsync(Guid assessmentId, Guid currentUserId) { @@ -94,6 +99,9 @@ public async Task InvokeAsync(Guid assessmentId, Guid curr Status = assessment.Status, CurrentUserId = currentUserId, AssessorId = assessment.AssessorId, + IsAIScoringEnabled = await featureChecker.IsEnabledAsync("Unity.AI.Scoring") && + await permissionChecker.IsGrantedAsync(AIPermissions.ScoringAssistant.ScoringAssistantDefault), + IsAiAssessment = assessment.IsAiAssessment, }; return View(model); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs index 4a74d0c5b3..a2f595173b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs @@ -25,6 +25,8 @@ public class AssessmentScoresWidgetViewModel public Guid CurrentUserId { get; set; } public Guid AssessorId { get; set; } public ScoresheetDto? Scoresheet { get; set; } + public bool IsAIScoringEnabled { get; set; } + public bool IsAiAssessment { get; set; } public bool IsDisabled() { if(CurrentUserId != AssessorId) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index ab766c0069..6fcb6be0be 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -22,12 +22,15 @@
Assessment Scores
- + @if (Model.IsAIScoringEnabled && Model.IsAiAssessment) + { + + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/Default.cshtml index 6c63bff7c2..8ef45f1c8a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/Default.cshtml @@ -1,9 +1,9 @@ - - -
-
Assessment List
+ + +
+
Assessments
-
+
@* Button items can be included here through the Review List JS*@
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.cs index af298870e0..e23072cdd0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.cs @@ -1,17 +1,18 @@ -using Microsoft.AspNetCore.Mvc; -using Volo.Abp.AspNetCore.Mvc.UI.Widgets; +using Microsoft.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.AspNetCore.Mvc.UI.Widgets; namespace Unity.GrantManager.Web.Views.Shared.Components.ReviewList { - [Widget( - ScriptFiles = new[] { - "/Views/Shared/Components/ReviewList/ReviewList.js" - }, - StyleFiles = new[] { - "/Views/Shared/Components/ReviewList/ReviewList.css" - })] + ScriptFiles = new[] + { + "/Views/Shared/Components/ReviewList/ReviewList.js" + }, + StyleFiles = new[] + { + "/Views/Shared/Components/ReviewList/ReviewList.css" + })] public class ReviewList : AbpViewComponent { public IViewComponentResult Invoke() diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.css index 54687c9f58..f259658c3a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.css @@ -8,3 +8,9 @@ table.dataTable > tbody > tr.even.selected > * { box-shadow: inset 0 0 0 9999px rgba(230, 239, 247, 0.95) !important; color: #003366; } + +#AdjudicationTeamLeadActionBar .dt-buttons, +#AdjudicationTeamLeadActionBar .btn { + display: inline-flex; + align-items: center; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js index 22ebc8ecaf..eed32018c7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js @@ -1,13 +1,24 @@ -const l = abp.localization.getResource('GrantManager'); -const pageApplicationId = decodeURIComponent(document.querySelector("#DetailsViewApplicationId").value); - -const actionButtonConfigMap = { - Create: { buttonType: 'createButton', order: 1 }, - Complete: { buttonType: 'unityWorkflow', order: 2 }, - SendBack: { buttonType: 'unityWorkflow', order: 3 }, - Clone: { buttonType: 'cloneButton', order: 4 }, - _Fallback: { buttonType: 'unityWorkflow', order: 100 } -} +const l = abp.localization.getResource('GrantManager'); +const pageApplicationId = decodeURIComponent(document.querySelector("#DetailsViewApplicationId").value); +const isAiScoringEnabled = document.querySelector("#AIScoringFeatureEnabled")?.value === 'True'; +const canUseAiScoring = isAiScoringEnabled; + +const actionButtonConfigMap = { + Generate: { buttonType: 'generateAiButton', order: 1 }, + Clone: { buttonType: 'cloneButton', order: 2 }, + Create: { buttonType: 'createButton', order: 3 }, + SendBack: { buttonType: 'unityWorkflow', order: 4 }, + Complete: { buttonType: 'unityWorkflow', order: 5 }, + _Fallback: { buttonType: 'unityWorkflow', order: 100 } +} + +const actionButtonLabelMap = { + Generate: 'Generate', + Clone: 'Clone', + Create: 'Create', + SendBack: 'Send Back', + Complete: 'Complete' +}; const finalApplicationStates = [ 'GRANT_NOT_APPROVED', @@ -41,19 +52,24 @@ $(function () { renderEnum: (data) => l('Enum:AssessmentState.' + data), }; - $.fn.dataTable.Buttons.defaults.dom.button.className = 'btn btn-light'; - $.fn.dataTable.Buttons.defaults.dom.button.liner.tag = false; - - $.extend(DataTable.ext.buttons, { - unityWorkflow: { - className: 'btn btn-light', - enabled: false, - text: unityWorkflowButtonText, - action: unityWorkflowButtonAction - }, - createButton: { - extend: 'unityWorkflow', - init: createButtonInit, + $.fn.dataTable.Buttons.defaults.dom.button.className = 'btn unt-btn-outline-primary btn-outline-primary'; + $.fn.dataTable.Buttons.defaults.dom.button.liner.tag = false; + + $.extend(DataTable.ext.buttons, { + unityWorkflow: { + className: 'btn unt-btn-outline-primary btn-outline-primary', + enabled: false, + text: unityWorkflowButtonText, + action: unityWorkflowButtonAction + }, + generateAiButton: { + extend: 'unityWorkflow', + text: generateAiButtonText, + action: generateAiButtonAction + }, + createButton: { + extend: 'unityWorkflow', + init: createButtonInit, action: createButtonAction }, cloneButton: { @@ -70,20 +86,25 @@ $(function () { const actionArray = getActionArray(); - let assessmentButtonsGroup = { - name: 'assessmentActionButtons', - buttons: getButtonArray(actionArray) - }; - - let assessmentCreateButtonGroup = { - name: 'assessmentCreateButtonsGroup', - buttons: Array(renderUnityWorkflowButton('Create')) - }; - - let assessmentCloneButtonGroup = { - name: 'assessmentCloneButtonsGroup', - buttons: Array(renderUnityWorkflowButton('Clone')) - }; + let assessmentButtonsGroup = { + name: 'assessmentActionButtons', + buttons: getButtonArray(actionArray) + }; + + let assessmentGenerateButtonGroup = { + name: 'assessmentGenerateButtonsGroup', + buttons: new Array(renderUnityWorkflowButton('Generate')) + }; + + let assessmentCreateButtonGroup = { + name: 'assessmentCreateButtonsGroup', + buttons: new Array(renderUnityWorkflowButton('Create')) + }; + + let assessmentCloneButtonGroup = { + name: 'assessmentCloneButtonsGroup', + buttons: new Array(renderUnityWorkflowButton('Clone')) + }; const reviewListDiv = "ReviewListTable"; @@ -187,49 +208,55 @@ $(function () { }) ); - $('#' + reviewListDiv).on('xhr.dt', function (e, settings, json, xhr) { - if (!json.isUsingDefaultScoresheet) { - reviewListTable.column(7).visible(false); // 'FinancialAnalysis' column - reviewListTable.column(8).visible(false); // 'EconomicImpact' column - reviewListTable.column(9).visible(false); // 'InclusiveGrowth' column - reviewListTable.column(10).visible(false); // 'CleanGrowth' column - } else { - reviewListTable.column(7).visible(true); - reviewListTable.column(8).visible(true); - reviewListTable.column(9).visible(true); - reviewListTable.column(10).visible(true); - } - }); - - if (abp.auth.isGranted('Unity.GrantManager.ApplicationManagement.Review.AssessmentReviewList.Create')) { - CreateAssessmentButton(); - } - - if (abp.auth.isGranted('AI.ScoringAssistant')) { - CloneAssessmentButton(); - } - - async function CreateAssessmentButton() { - let createButtons = new $.fn.dataTable.Buttons(reviewListTable, assessmentCreateButtonGroup); - createButtons.container().prependTo("#AdjudicationTeamLeadActionBar"); - let isPermitted = await CheckAssessmentCreateButton(); - if (!isPermitted) { - reviewListTable.buttons('Create:name').disable(); - } - } - async function CheckAssessmentCreateButton() { - let applicationStatus = await getActionButtonConfigMap(); - return !finalApplicationStates.includes(applicationStatus.statusCode); - } - - function CloneAssessmentButton() { - let cloneButtons = new $.fn.dataTable.Buttons(reviewListTable, assessmentCloneButtonGroup); - cloneButtons.container().prependTo("#AdjudicationTeamLeadActionBar"); - reviewListTable.buttons('Clone:name').disable(); - } - - reviewListTable.buttons(0, null).container().appendTo("#AdjudicationTeamLeadActionBar"); - $("#AdjudicationTeamLeadActionBar .dt-buttons").contents().unwrap(); + $('#' + reviewListDiv).on('xhr.dt', function (e, settings, json, xhr) { + if (!json.isUsingDefaultScoresheet) { + reviewListTable.column(7).visible(false); // 'FinancialAnalysis' column + reviewListTable.column(8).visible(false); // 'EconomicImpact' column + reviewListTable.column(9).visible(false); // 'InclusiveGrowth' column + reviewListTable.column(10).visible(false); // 'CleanGrowth' column + } else { + reviewListTable.column(7).visible(true); + reviewListTable.column(8).visible(true); + reviewListTable.column(9).visible(true); + reviewListTable.column(10).visible(true); + } + + updateAiActionButtonsVisibility(reviewListTable, json.data ?? []); + }); + + if (canUseAiScoring) { + GenerateAiAssessmentButton(); + } + + if (canUseAiScoring) { + CloneAssessmentButton(); + } + + if (abp.auth.isGranted('Unity.GrantManager.ApplicationManagement.Review.AssessmentReviewList.Create')) { + CreateAssessmentButton(); + } + + function GenerateAiAssessmentButton() { + let generateButtons = new $.fn.dataTable.Buttons(reviewListTable, assessmentGenerateButtonGroup); + generateButtons.container().appendTo("#AdjudicationTeamLeadActionBar"); + reviewListTable.buttons('Generate:name').enable(); + } + + async function CreateAssessmentButton() { + let createButtons = new $.fn.dataTable.Buttons(reviewListTable, assessmentCreateButtonGroup); + createButtons.container().appendTo("#AdjudicationTeamLeadActionBar"); + await updateCreateButtonState(reviewListTable); + } + + function CloneAssessmentButton() { + let cloneButtons = new $.fn.dataTable.Buttons(reviewListTable, assessmentCloneButtonGroup); + cloneButtons.container().appendTo("#AdjudicationTeamLeadActionBar"); + reviewListTable.buttons('Clone:name').disable(); + } + + reviewListTable.buttons(0, null).container().appendTo("#AdjudicationTeamLeadActionBar"); + $("#AdjudicationTeamLeadActionBar .dt-buttons").contents().unwrap(); + updateAiActionButtonsVisibility(reviewListTable); reviewListTable.on('select', function (e, dt, type, indexes) { handleRowSelection(e, dt, type, indexes, reviewListTable); @@ -253,15 +280,12 @@ $(function () { $('#detailsTab a[href="#nav-review-and-assessment"]').tab('show'); } ); - PubSub.subscribe( - 'application_status_changed', - async (msg, data) => { - let isPermitted = await CheckAssessmentCreateButton(); - if (!isPermitted) { - reviewListTable.buttons('Create:name').disable(); - } - } - ); + PubSub.subscribe( + 'application_status_changed', + async (msg, data) => { + await updateCreateButtonState(reviewListTable); + } + ); $('#nav-review-and-assessment-tab').one('click', function () { reviewListTable.columns.adjust(); @@ -319,36 +343,24 @@ function getButtonArray(actionArray) { .sort((a, b) => a.sortOrder - b.sortOrder); } -function refreshActionButtons(dataTableContext, assessmentId, selectedData) { - dataTableContext.buttons(0, null).disable(); - dataTableContext.buttons('Clone:name').disable(); - - if (assessmentId) { - if (selectedData?.isAiAssessment) { - // AI assessment: Clone is only available if the current user has no existing human assessment - unity.grantManager.assessments.assessment.getCurrentUserAssessmentId(pageApplicationId, {}) - .done(function (existingAssessmentId) { - if (existingAssessmentId == null) { - dataTableContext.buttons('Clone:name').enable(); - } - }); - } else { - // Human assessment: enable workflow buttons based on permitted actions - unity.grantManager.assessments.assessment.getPermittedActions(assessmentId, {}) - .then(function (actionListResult) { - let enabledButtons = actionListResult.map((x) => x + ':name'); - dataTableContext.buttons(enabledButtons).enable(); - }); - } - } - - if (typeof CheckAssessmentCreateButton === 'function') { - let isPermitted = CheckAssessmentCreateButton(); - if (!isPermitted) { - dataTableContext.buttons('Create:name').disable(); - } - } -} +function refreshActionButtons(dataTableContext, assessmentId, selectedData) { + dataTableContext.buttons(0, null).disable(); + dataTableContext.buttons('Clone:name').disable(); + + if (assessmentId) { + if (!selectedData?.isAiAssessment) { + // Human assessment: enable workflow buttons based on permitted actions + unity.grantManager.assessments.assessment.getPermittedActions(assessmentId, {}) + .then(function (actionListResult) { + let enabledButtons = actionListResult.map((x) => x + ':name'); + dataTableContext.buttons(enabledButtons).enable(); + }); + } + } + + updateCloneButtonState(dataTableContext); + updateCreateButtonState(dataTableContext); +} function renderApproval(data) { if (data !== null) { @@ -357,13 +369,63 @@ function renderApproval(data) { return nullPlaceholder; } } -async function getActionButtonConfigMap() { - let applicationId = document.getElementById('DetailsViewApplicationId').value; - let applicationStatus = await unity.grantManager.grantApplications.grantApplication.getApplicationStatus(applicationId).then(data => { - return data; - }); - return applicationStatus; -} +async function getActionButtonConfigMap() { + let applicationId = document.getElementById('DetailsViewApplicationId').value; + let applicationStatus = await unity.grantManager.grantApplications.grantApplication.getApplicationStatus(applicationId).then(data => { + return data; + }); + return applicationStatus; +} + +async function canCreateAssessment() { + const applicationStatus = await getActionButtonConfigMap(); + return !finalApplicationStates.includes(applicationStatus.statusCode); +} + +async function updateCreateButtonState(dataTableContext) { + if (!dataTableContext.button('Create:name').any()) { + return; + } + + const [isPermittedByStatus, currentAssessmentId] = await Promise.all([ + canCreateAssessment(), + unity.grantManager.assessments.assessment.getCurrentUserAssessmentId(pageApplicationId, {}) + ]); + + if (isPermittedByStatus && currentAssessmentId == null) { + dataTableContext.buttons('Create:name').enable(); + } else { + dataTableContext.buttons('Create:name').disable(); + } +} + +async function updateCloneButtonState(dataTableContext) { + if (!dataTableContext.button('Clone:name').any()) { + return; + } + + const hasAiAssessment = dataTableContext.rows().data().toArray().some(row => row?.isAiAssessment === true); + const currentAssessmentId = await unity.grantManager.assessments.assessment.getCurrentUserAssessmentId(pageApplicationId, {}); + + if (hasAiAssessment && currentAssessmentId == null) { + dataTableContext.buttons('Clone:name').enable(); + } else { + dataTableContext.buttons('Clone:name').disable(); + } +} + +function updateAiActionButtonsVisibility(dataTableContext, rowsData) { + const rowData = rowsData ?? dataTableContext.rows().data().toArray(); + const hasAiAssessment = rowData.some(row => row?.isAiAssessment === true); + + if (dataTableContext.button('Generate:name').any()) { + $('#GenerateButton').toggle(!hasAiAssessment); + } + + if (dataTableContext.button('Clone:name').any()) { + $('#CloneButton').toggle(hasAiAssessment); + } +} function renderUnityWorkflowButton(actionValue) { let buttonConfig = actionButtonConfigMap[actionValue] ?? actionButtonConfigMap['_Fallback'] @@ -376,21 +438,62 @@ function renderUnityWorkflowButton(actionValue) { } /* Cutom Unity Workflow Buttons */ -function unityWorkflowButtonText(dt, button, config) { - let buttonText = l(`Enum:AssessmentAction.${config.name}`); - return '' + buttonText + ''; -} - -function cloneButtonText(dt, button, config) { - return '' + l('ReviewerList:CloneAssessment') + ''; -} - -function unityWorkflowButtonAction(e, dt, button, config) { - let selectedRow = dt.rows({ selected: true }).data()[0]; - if (typeof (selectedRow) === 'object') { - executeAssessmentAction(selectedRow.id, config.name); - } -} +function unityWorkflowButtonText(dt, button, config) { + let buttonText = actionButtonLabelMap[config.name] ?? l(`Enum:AssessmentAction.${config.name}`); + return '' + buttonText + ''; +} + +function cloneButtonText(dt, button, config) { + return '' + actionButtonLabelMap.Clone + ''; +} + +function generateAiButtonText(dt, button, config) { + return 'Generate'; +} + +function unityWorkflowButtonAction(e, dt, button, config) { + let selectedRow = dt.rows({ selected: true }).data()[0]; + if (typeof (selectedRow) === 'object') { + executeAssessmentAction(selectedRow.id, config.name); + } +} + +function generateAiButtonAction(e, dt, button, config) { + const triggerButton = button?.node ? $(button.node) : null; + + if (triggerButton?.length) { + triggerButton.prop('disabled', true); + triggerButton.html('Generating...'); + } + + unity.grantManager.grantApplications.applicationAIScoring.generateAIScoresheetAnswers(pageApplicationId) + .done(function () { + refreshReviewListSelectingAiAssessment(dt); + abp.notify.success('AI scoring generated successfully.'); + }) + .fail(function () { + abp.message.error('Failed to generate AI scoring. Please try again.'); + }) + .always(function () { + if (triggerButton?.length) { + triggerButton.prop('disabled', false); + triggerButton.html(generateAiButtonText(null, null, null)); + } + }); +} + +function refreshReviewListSelectingAiAssessment(reviewListTable) { + reviewListTable.ajax.reload(function () { + const aiRowIndexes = reviewListTable.rows().eq(0).filter(function (rowIdx) { + const rowData = reviewListTable.row(rowIdx).data(); + return rowData?.isAiAssessment === true; + }); + + if (aiRowIndexes.length > 0) { + reviewListTable.row(aiRowIndexes[0]).selectWithParams({ refreshSidePanel: true }); + } + }); +} function executeAssessmentAction(assessmentId, triggerAction) { unity.grantManager.assessments.assessment.executeAssessmentAction(assessmentId, triggerAction, {}) @@ -416,23 +519,25 @@ function createButtonInit(dt, button, config) { }); } -function cloneButtonAction(e, dt, button, config) { - let selectedRow = dt.rows({ selected: true }).data()[0]; - if (typeof (selectedRow) === 'object') { - unity.grantManager.assessments.assessment.cloneFromAi(selectedRow.id, {}) - .done(function (data) { - dt.buttons('Create:name').disable(); - PubSub.publish('assessment_action_completed'); - PubSub.publish('refresh_review_list', data.id); - PubSub.publish("application_status_changed"); - PubSub.publish("refresh_detail_panel_summary"); - abp.notify.success( - l('ReviewerList:CloneAssessment'), - "Completed Successfully" - ); - }); - } -} +function cloneButtonAction(e, dt, button, config) { + const aiRowData = dt.rows().data().toArray().find(row => row?.isAiAssessment === true); + + if (typeof (aiRowData) === 'object') { + unity.grantManager.assessments.assessment.cloneFromAi(aiRowData.id, {}) + .done(function (data) { + dt.buttons('Create:name').disable(); + dt.buttons('Clone:name').disable(); + PubSub.publish('assessment_action_completed'); + PubSub.publish('refresh_review_list', data.id); + PubSub.publish("application_status_changed"); + PubSub.publish("refresh_detail_panel_summary"); + abp.notify.success( + l('ReviewerList:CloneAssessment'), + "Completed Successfully" + ); + }); + } +} function createButtonAction(e, dt, button, config) { unity.grantManager.assessments.assessment.create({ "applicationId": pageApplicationId }, {}) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AssessmentScoresWidgetTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AssessmentScoresWidgetTests.cs index 332fb09532..2202927cd9 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AssessmentScoresWidgetTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AssessmentScoresWidgetTests.cs @@ -10,6 +10,8 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.Assessments; using Unity.GrantManager.Web.Views.Shared.Components.AssessmentScoresWidget; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.Features; using Xunit; namespace Unity.GrantManager.Components @@ -25,6 +27,8 @@ public async Task AssessmentScoresWidgetReturnsStatus() var scoresheetRepository = Substitute.For(); var instanceRepository = Substitute.For(); var applicationRepository = Substitute.For(); + var featureChecker = Substitute.For(); + var permissionChecker = Substitute.For(); var expectedFinancialAnalysis = 1; var expectedEconomicImpact = 2; var expectedInclusiveGrowth = 3; @@ -48,6 +52,8 @@ public async Task AssessmentScoresWidgetReturnsStatus() })); instanceRepository.GetByCorrelationAsync(assessmentId).Returns(Task.FromResult(null)); + featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(Task.FromResult(true)); + permissionChecker.IsGrantedAsync(Arg.Any()).Returns(Task.FromResult(true)); var viewContext = new ViewContext { @@ -58,7 +64,13 @@ public async Task AssessmentScoresWidgetReturnsStatus() ViewContext = viewContext }; - var viewComponent = new AssessmentScoresWidgetViewComponent(assessmentRepository, scoresheetRepository, instanceRepository, applicationRepository) + var viewComponent = new AssessmentScoresWidgetViewComponent( + assessmentRepository, + scoresheetRepository, + instanceRepository, + applicationRepository, + featureChecker, + permissionChecker) { ViewComponentContext = viewComponentContext };