Merge branch 'dev'
This commit is contained in:
@@ -122,6 +122,51 @@ Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123!
|
||||
- Global query filters enforce company isolation at database level
|
||||
- Users have both system role (SuperAdmin) and company role (CompanyAdmin, Manager, Worker, Viewer)
|
||||
|
||||
## Data Access Rules (ENFORCE THESE)
|
||||
|
||||
> **`ApplicationDbContext` is NEVER injected into a controller.**
|
||||
> All data access in controllers goes through `IUnitOfWork`. No exceptions outside the list below.
|
||||
> **This rule is enforced at startup:** `EnforceDataAccessArchitecture()` in `Program.cs` scans all
|
||||
> controllers at boot and throws if any non-exempt controller injects `ApplicationDbContext`.
|
||||
> Full rationale and permanent exceptions list: `docs/DATA_ACCESS_ARCHITECTURE.md`
|
||||
|
||||
### Three tiers — use the right one:
|
||||
|
||||
**Tier 1 — Simple CRUD** → `IUnitOfWork.EntityName` (generic `IRepository<T>`)
|
||||
```csharp
|
||||
var items = await _unitOfWork.CatalogItems.GetAllAsync();
|
||||
await _unitOfWork.Announcements.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
```
|
||||
|
||||
**Tier 2 — Complex domain queries** → typed repositories on `IUnitOfWork`
|
||||
```csharp
|
||||
// Include chains and domain-specific queries belong in the repository, not the controller
|
||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
|
||||
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
|
||||
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
|
||||
```
|
||||
Typed repositories: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`,
|
||||
`ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
|
||||
— defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/`
|
||||
|
||||
**Tier 3 — Aggregate/reporting queries** → injected read services
|
||||
```csharp
|
||||
// P&L, AR aging, cycle time, powder usage — shaped DTOs, never tracked entities
|
||||
var aging = await _financialReports.GetArAgingAsync(companyId);
|
||||
```
|
||||
Services: `IFinancialReportService`, `IOperationalReportService`
|
||||
— defined in `Core/Interfaces/Services/`, implemented in `Infrastructure/Services/`
|
||||
|
||||
### Permanent exceptions (ApplicationDbContext allowed — intentional, documented):
|
||||
`StripeWebhookController`, `WebhooksController`, `PaymentController`, `RegistrationController`,
|
||||
`DataExportController`, `AccountDataExportController`, `DataPurgeController`,
|
||||
`SystemInfoController`, `SystemLogsController`, `CompanyHealthController`
|
||||
|
||||
If you think you need a new exception, you almost certainly don't. Check the spec first.
|
||||
|
||||
---
|
||||
|
||||
## Data Access Patterns
|
||||
|
||||
### Common Controller Pattern
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# Data Access Architecture
|
||||
|
||||
## Status: Complete ✓ (2026-04-28)
|
||||
|
||||
This document defines the target data access architecture for Powder Coating Logix and tracks
|
||||
the migration from the current mixed pattern to the clean layered pattern.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
The codebase currently has ~50 controllers injecting `ApplicationDbContext` directly alongside
|
||||
`IUnitOfWork`. This happened organically: the generic `Repository<T>` could not express complex
|
||||
multi-level include queries, so `_context` became the escape hatch. Once injected for one complex
|
||||
query, it was used for everything else in that controller too. The inconsistency compounds with
|
||||
every new controller a developer writes.
|
||||
|
||||
For a solo developer this is manageable. For a team it creates a daily decision tax — "which
|
||||
pattern do I follow?" — with no clear answer. New developers copy the nearest example, which is
|
||||
usually `_context`, so the problem grows.
|
||||
|
||||
---
|
||||
|
||||
## The Rule (Short Version)
|
||||
|
||||
> **`ApplicationDbContext` is never injected into a controller. Ever.**
|
||||
>
|
||||
> All data access in controllers goes through `IUnitOfWork`.
|
||||
> Complex queries that the generic `Repository<T>` cannot express live in typed repositories or
|
||||
> read services — both accessible through `IUnitOfWork`.
|
||||
|
||||
---
|
||||
|
||||
## Target Architecture
|
||||
|
||||
```
|
||||
Controllers (Presentation Layer)
|
||||
│
|
||||
├── IUnitOfWork.EntityName → IRepository<T> Simple CRUD
|
||||
├── IUnitOfWork.Jobs → IJobRepository Complex domain queries
|
||||
├── IUnitOfWork.Invoices → IInvoiceRepository Complex domain queries
|
||||
├── IUnitOfWork.Quotes → IQuoteRepository Complex domain queries
|
||||
├── IUnitOfWork.Customers → ICustomerRepository Complex domain queries
|
||||
├── IUnitOfWork.Bills → IBillRepository Complex domain queries
|
||||
│
|
||||
├── IFinancialReportService Aggregate/reporting reads
|
||||
└── IOperationalReportService Aggregate/reporting reads
|
||||
|
||||
Infrastructure Layer (the only layer that knows about ApplicationDbContext)
|
||||
│
|
||||
├── Repository<T> Generic implementation
|
||||
├── JobRepository : IJobRepository Typed implementations
|
||||
├── InvoiceRepository ...
|
||||
├── QuoteRepository ...
|
||||
├── CustomerRepository ...
|
||||
├── BillRepository ...
|
||||
│
|
||||
├── FinancialReportService DbContext used directly (read-only, no tracking)
|
||||
└── OperationalReportService DbContext used directly (read-only, no tracking)
|
||||
```
|
||||
|
||||
`ApplicationDbContext` never crosses into the Presentation layer. It lives in Infrastructure and
|
||||
only Infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Three Tiers of Data Access
|
||||
|
||||
### Tier 1 — Simple CRUD → Generic `IRepository<T>` via `IUnitOfWork`
|
||||
|
||||
Use for: single-entity lookups, lists, adds, soft deletes, simple filtered queries.
|
||||
|
||||
```csharp
|
||||
// Good
|
||||
var items = await _unitOfWork.CatalogItems.GetAllAsync();
|
||||
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id);
|
||||
await _unitOfWork.Announcements.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
```
|
||||
|
||||
Entities in this tier (generic repo is sufficient):
|
||||
- Announcements, BugReports, CatalogItems, CatalogCategories, CatalogPriceCheckReports
|
||||
- CompanyBlastSetups, CompanyOperatingCosts, CompanyPreferences
|
||||
- ContactSubmissions, CreditMemos, CreditMemoApplications
|
||||
- DashboardTips, Deposits, Equipment
|
||||
- GiftCertificates, GiftCertificateRedemptions
|
||||
- InventoryItems, InventoryTransactions
|
||||
- JobChangeHistories, JobDailyPriorities, JobItemCoats, JobItems, JobNotes, JobPhotos
|
||||
- JobStatusHistory, JobTemplates, JobTemplateItems, JobTemplateItemCoats, JobTemplateItemPrepServices
|
||||
- JobTimeEntries, MaintenanceRecords, ManufacturerLookupPatterns
|
||||
- NotificationLogs, NotificationTemplates
|
||||
- OvenBatches, OvenBatchItems, OvenCosts
|
||||
- Payments, PrepServices, PricingTiers
|
||||
- PowderUsageLogs, PurchaseOrderItems
|
||||
- QuoteChangeHistories, QuoteItemCoats, QuoteItems, QuoteItemPrepServices, QuotePhotos
|
||||
- Refunds, ReworkRecords
|
||||
- ShopWorkers, ShopWorkerRoleCosts, SubscriptionPlanConfigs
|
||||
- Vendors
|
||||
|
||||
### Tier 2 — Complex Domain Queries → Typed Repositories
|
||||
|
||||
Use for: multi-level include chains, domain-specific filtered loads, queries that require
|
||||
`IgnoreQueryFilters`, queries that span multiple related entities in non-trivial ways.
|
||||
|
||||
The typed repository interface lives in `Core/Interfaces/Repositories/`.
|
||||
The implementation lives in `Infrastructure/Repositories/`.
|
||||
The property is on `IUnitOfWork` — same access point as Tier 1.
|
||||
|
||||
```csharp
|
||||
// Good — the complex include chain lives in the repository, not the controller
|
||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
|
||||
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
|
||||
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
|
||||
```
|
||||
|
||||
#### `IJobRepository`
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `LoadForDetailsAsync(int id)` | Full include chain for Job Details view |
|
||||
| `LoadForEditAsync(int id)` | Includes needed for Job Edit form |
|
||||
| `LoadForBoardAsync(int companyId, ...)` | Jobs for the kanban board with status/priority filters |
|
||||
| `GetByStatusAsync(int companyId, int statusId)` | Filtered by status with customer include |
|
||||
| `GetAssignedToWorkerAsync(int workerId)` | All active jobs for a worker |
|
||||
| `GetOverdueAsync(int companyId)` | Jobs past due date |
|
||||
|
||||
#### `IInvoiceRepository`
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `LoadForViewAsync(int id)` | Full 8-table include chain (current `LoadInvoiceForViewAsync`) |
|
||||
| `GetOverdueAsync(int companyId)` | Invoices past due date with customer info |
|
||||
| `GetByPaymentTokenAsync(string token)` | Online payment portal lookup |
|
||||
| `GetForJobAsync(int jobId, bool includeDeleted)` | Invoice for a given job (1:1 check) |
|
||||
|
||||
#### `IQuoteRepository`
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `LoadForViewAsync(int id)` | Full include chain for Quote Details |
|
||||
| `LoadForEditAsync(int id)` | Includes needed for wizard edit |
|
||||
| `GetByApprovalTokenAsync(string token)` | Customer approval portal lookup |
|
||||
| `GetPendingApprovalsAsync(int companyId)` | Quotes awaiting customer approval |
|
||||
|
||||
#### `ICustomerRepository`
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `LoadForDetailsAsync(int id)` | Customer with jobs, quotes, invoices, notes summary |
|
||||
| `GetWithOutstandingBalancesAsync(int companyId)` | AR summary data |
|
||||
| `FindByEmailAsync(string email, int companyId)` | Duplicate check on create/edit |
|
||||
|
||||
#### `IBillRepository`
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `LoadForViewAsync(int id)` | Bill with line items, payments, vendor |
|
||||
| `GetApPayablesAsync(int companyId)` | Open AP ledger with aging |
|
||||
|
||||
#### `IPurchaseOrderRepository`
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `LoadForViewAsync(int id)` | PO with line items and vendor |
|
||||
| `GetByStatusAsync(int companyId, string status)` | Filtered PO list |
|
||||
|
||||
### Tier 3 — Aggregate/Reporting Queries → Read Services
|
||||
|
||||
Use for: P&L calculations, AR aging, powder usage aggregates, job cycle time, any query that
|
||||
uses `GROUP BY`, window functions, or multi-table joins that return shaped result DTOs rather
|
||||
than tracked entities.
|
||||
|
||||
These services are injected directly into controllers alongside `IUnitOfWork`. They use
|
||||
`ApplicationDbContext` internally (with `.AsNoTracking()`) — that is correct and intentional,
|
||||
because they live in Infrastructure.
|
||||
|
||||
```csharp
|
||||
// Controller constructor
|
||||
public ReportsController(IUnitOfWork unitOfWork, IFinancialReportService financialReports, ...)
|
||||
|
||||
// Usage
|
||||
var aging = await _financialReports.GetArAgingAsync(companyId);
|
||||
var pl = await _financialReports.GetProfitLossAsync(companyId, startDate, endDate);
|
||||
```
|
||||
|
||||
#### `IFinancialReportService`
|
||||
- `GetArAgingAsync(int companyId)` → AR aging buckets (current, 30, 60, 90+ days)
|
||||
- `GetProfitLossAsync(int companyId, DateTime start, DateTime end)` → P&L summary
|
||||
- `GetMonthlyRevenueAsync(int companyId, int months)` → monthly invoiced vs collected
|
||||
- `GetTopOutstandingCustomersAsync(int companyId, int count)` → largest open balances
|
||||
- `GetCashFlowProjectionAsync(int companyId, int days)` → forward-looking cash position
|
||||
- `GetAnomaliesAsync(int companyId, int lookbackDays)` → bill/expense anomaly detection
|
||||
- `GetRecentPaymentsAsync(int companyId, int count)` → recent payment activity
|
||||
|
||||
#### `IOperationalReportService`
|
||||
- `GetJobCycleTimeAsync(int companyId, DateTime start, DateTime end)` → avg days per stage
|
||||
- `GetPowderUsageAsync(int companyId, DateTime start, DateTime end)` → usage by color/vendor
|
||||
- `GetWorkerProductivityAsync(int companyId, DateTime start, DateTime end)` → jobs per worker
|
||||
- `GetOvenUtilizationAsync(int companyId, DateTime start, DateTime end)` → oven throughput
|
||||
- `GetReworkRateAsync(int companyId, DateTime start, DateTime end)` → defect/rework trends
|
||||
- `GetStatusFlowAsync(int companyId, DateTime start, DateTime end)` → job status transitions
|
||||
|
||||
---
|
||||
|
||||
## Permanent Exceptions
|
||||
|
||||
The following controllers are **intentionally allowed** to inject `ApplicationDbContext` directly.
|
||||
This is not a smell — it is correct for their use cases. Each file has a comment explaining why.
|
||||
|
||||
| Controller | Reason |
|
||||
|------------|--------|
|
||||
| `StripeWebhookController` | Idempotency key lookup must bypass soft-delete and tenant filters |
|
||||
| `WebhooksController` | Twilio raw event handling; same reasoning as Stripe |
|
||||
| `PaymentController` | Stripe Connect embedded payment flow; raw session state needed |
|
||||
| `RegistrationController` | PendingRegistrationSession queries bypass normal tenant scoping |
|
||||
| `DataExportController` | Bulk streaming export; repository pattern adds unnecessary overhead |
|
||||
| `AccountDataExportController` | Same as above |
|
||||
| `DataPurgeController` | Destructive bulk operations; needs direct transaction control |
|
||||
| `SystemInfoController` | Infrastructure diagnostics; queries metadata, not business data |
|
||||
| `SystemLogsController` | Log table queries; not a business entity |
|
||||
| `CompanyHealthController` | Cross-tenant health checks for SuperAdmin; ignores all filters |
|
||||
| `PasskeyController` | WebAuthn/FIDO2 identity infrastructure; UserPasskeys is an ASP.NET Identity concern outside IUnitOfWork; anonymous login path has no tenant context |
|
||||
| `AuditLogController` | Append-only audit log with `long` PK; platform infrastructure table outside the business entity graph; same reasoning as `SystemLogsController` |
|
||||
| `UserActivityController` | Queries ASP.NET Identity `ApplicationUser` across all tenants with `Include(u => u.Company)`; Identity entities live outside IUnitOfWork |
|
||||
| `EmailBroadcastController` | Cross-tenant fan-out querying ASP.NET Identity Users table with company joins; Identity entities live outside IUnitOfWork |
|
||||
| `RevenueController` | Cross-tenant MRR/ARR metrics joining Company + SubscriptionPlanConfig; same pattern as `CompanyHealthController` |
|
||||
| `StripeEventsController` | `StripeWebhookEvents` is a platform infrastructure table, not a business entity; same reasoning as `StripeWebhookController` |
|
||||
| `SubscriptionManagementController` | Cross-tenant Company management with raw SQL audit log writes that bypass the tenant pipeline; platform-level concern |
|
||||
| `UsageQuotaController` | Cross-tenant bulk GROUP BY quota queries; routing through IUnitOfWork would require O(n) repository round-trips |
|
||||
|
||||
If you think you need to add a controller to this list, you almost certainly don't. Ask first.
|
||||
|
||||
---
|
||||
|
||||
## Migration Roadmap
|
||||
|
||||
### Phase 1 — Foundation (no behavior change)
|
||||
- [ ] Create `Core/Interfaces/Repositories/` directory
|
||||
- [ ] Define `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`, `ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
|
||||
- [ ] Define `IFinancialReportService`, `IOperationalReportService` in `Core/Interfaces/Services/`
|
||||
- [ ] Create `Infrastructure/Repositories/` directory
|
||||
- [ ] Implement all typed repositories (move include chains from controllers)
|
||||
- [ ] Implement `FinancialReportService` (move aggregate queries from `ReportsController`)
|
||||
- [ ] Implement `OperationalReportService`
|
||||
- [ ] Extend `IUnitOfWork` with typed repository properties
|
||||
- [ ] Register all new types in `Program.cs`
|
||||
- [ ] Build passes, all tests green — no controller has changed yet
|
||||
|
||||
### Phase 2 — Complex controller migration ✓ COMPLETE (2026-04-27)
|
||||
- [x] `InvoicesController` → `IInvoiceRepository`
|
||||
- [x] `JobsController` → `IJobRepository`
|
||||
- [x] `QuotesController` → `IQuoteRepository`
|
||||
- [x] `CustomersController` → `ICustomerRepository`
|
||||
- [x] `BillsController` → `IBillRepository`
|
||||
- [x] `PurchaseOrdersController` → `IPurchaseOrderRepository`
|
||||
- [x] `ReportsController` → `IFinancialReportService` + `IOperationalReportService`
|
||||
|
||||
### Phase 3 — Simple controller sweep ✓ COMPLETE (2026-04-28)
|
||||
Remove `ApplicationDbContext` injection from all controllers not in the permanent exceptions list,
|
||||
replacing with existing `IUnitOfWork` generic repository calls.
|
||||
|
||||
- [x] `AnnouncementsController`
|
||||
- [x] `AiQuickQuoteController`
|
||||
- [x] `AiUsageReportController`
|
||||
- [x] `AuditLogController` → permanent exception (Identity/platform infra)
|
||||
- [x] `BannedIpsController`
|
||||
- [x] `BugReportController`
|
||||
- [x] `CompaniesController`
|
||||
- [x] `CompanySettingsController`
|
||||
- [x] `CompanyUsersController`
|
||||
- [x] `DashboardController`
|
||||
- [x] `DashboardTipsController`
|
||||
- [x] `DepositsController`
|
||||
- [x] `EmailBroadcastController` → permanent exception (Identity fan-out)
|
||||
- [x] `ExpensesController`
|
||||
- [x] `InAppNotificationsController`
|
||||
- [x] `InventoryController`
|
||||
- [x] `JobsPriorityController`
|
||||
- [x] `JobTemplatesController`
|
||||
- [x] `NotificationLogsController`
|
||||
- [x] `PasskeyController` → permanent exception (WebAuthn/FIDO2 identity infra)
|
||||
- [x] `PlatformNotificationsController`
|
||||
- [x] `QuoteApprovalController`
|
||||
- [x] `ReleaseNotesController`
|
||||
- [x] `RevenueController` → permanent exception (cross-tenant MRR/ARR)
|
||||
- [x] `SetupWizardController`
|
||||
- [x] `SmsConsentAuditController`
|
||||
- [x] `StripeEventsController` → permanent exception (platform infra table)
|
||||
- [x] `SubscriptionManagementController` → permanent exception (platform-level cross-tenant)
|
||||
- [x] `UnsubscribeController`
|
||||
- [x] `UsageQuotaController` → permanent exception (bulk GROUP BY)
|
||||
- [x] `UserActivityController` → permanent exception (Identity entities)
|
||||
- [x] `VendorsController`
|
||||
|
||||
### Phase 4 — Enforcement ✓ COMPLETE (2026-04-28)
|
||||
- [x] `EnforceDataAccessArchitecture()` added to `Program.cs` — scans all Controller subclasses at
|
||||
startup via reflection and throws `InvalidOperationException` if any non-exempt controller
|
||||
has `ApplicationDbContext` in its constructor. The app cannot start with a violation.
|
||||
- [x] Permanent exceptions list hardcoded in the enforcement function (18 controllers).
|
||||
- [x] This document status updated to Complete.
|
||||
- [ ] Update `CLAUDE.md` to mark migration complete (optional — CLAUDE.md already reflects the rule)
|
||||
|
||||
---
|
||||
|
||||
## File Locations Reference
|
||||
|
||||
```
|
||||
src/
|
||||
PowderCoating.Core/
|
||||
Interfaces/
|
||||
IRepository.cs existing
|
||||
IUnitOfWork.cs existing — extended in Phase 1
|
||||
Repositories/ NEW in Phase 1
|
||||
IJobRepository.cs
|
||||
IInvoiceRepository.cs
|
||||
IQuoteRepository.cs
|
||||
ICustomerRepository.cs
|
||||
IBillRepository.cs
|
||||
IPurchaseOrderRepository.cs
|
||||
Services/ NEW in Phase 1
|
||||
IFinancialReportService.cs
|
||||
IOperationalReportService.cs
|
||||
|
||||
PowderCoating.Infrastructure/
|
||||
Repositories/ NEW in Phase 1
|
||||
UnitOfWork.cs existing — extended
|
||||
Repository.cs existing
|
||||
JobRepository.cs
|
||||
InvoiceRepository.cs
|
||||
QuoteRepository.cs
|
||||
CustomerRepository.cs
|
||||
BillRepository.cs
|
||||
PurchaseOrderRepository.cs
|
||||
Services/
|
||||
FinancialReportService.cs NEW in Phase 1
|
||||
OperationalReportService.cs NEW in Phase 1
|
||||
NotificationService.cs existing — correct as-is
|
||||
PdfService.cs existing — correct as-is
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
When reviewing a PR that touches data access:
|
||||
|
||||
1. Does the controller inject `ApplicationDbContext`? If yes and it's not in the permanent
|
||||
exceptions list → request changes.
|
||||
2. Is a complex include chain written inline in a controller action? → move to typed repository.
|
||||
3. Is a GROUP BY / aggregate query inline in a controller action? → move to report service.
|
||||
4. Does a new typed repository method duplicate logic already in another repository? → consolidate.
|
||||
5. Are all DbContext calls in report services using `.AsNoTracking()`? → required for read services.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,7 @@ public class JobListDto
|
||||
public string PriorityDisplayName { get; set; } = string.Empty;
|
||||
public string PriorityColorClass { get; set; } = "secondary";
|
||||
|
||||
public bool CustomerNotifyByEmail { get; set; } = true;
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
|
||||
@@ -10,6 +10,7 @@ public class NotificationLogDto
|
||||
public NotificationType NotificationType { get; set; }
|
||||
public string NotificationTypeDisplay => NotificationType switch
|
||||
{
|
||||
NotificationType.AdminEmail => "Admin Email",
|
||||
NotificationType.QuoteSent => "Quote Sent",
|
||||
NotificationType.QuoteApproved => "Quote Approved",
|
||||
NotificationType.JobStatusChanged => "Job Status Changed",
|
||||
|
||||
@@ -12,7 +12,7 @@ public class WizardProgressDto
|
||||
public bool Completed { get; set; }
|
||||
public List<int> DoneSteps { get; set; } = new();
|
||||
public List<int> SkippedSteps { get; set; } = new();
|
||||
public const int TotalSteps = 10;
|
||||
public const int TotalSteps = 5;
|
||||
|
||||
public bool IsStepDone(int step) => DoneSteps.Contains(step);
|
||||
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
/// <summary>Pre-aggregated AI call counts for one company across four time windows.</summary>
|
||||
public record AiCompanyUsage(int CompanyId, int Today, int Last7Days, int Last30Days, int AllTime);
|
||||
|
||||
/// <summary>Count of AI calls for a specific feature within a company (last 30 days).</summary>
|
||||
public record AiFeatureStat(int CompanyId, string Feature, int Count);
|
||||
|
||||
/// <summary>Bundled result returned by <see cref="IAiUsageReportService.GetReportDataAsync"/>.</summary>
|
||||
public record AiUsageReportData(
|
||||
List<AiCompanyUsage> UsageByCompany,
|
||||
List<AiFeatureStat> FeatureStats,
|
||||
Dictionary<int, int> PhotoCountsByCompany);
|
||||
|
||||
/// <summary>
|
||||
/// Read-only service for the platform AI usage analytics report. Queries <c>AiUsageLogs</c>
|
||||
/// and <c>QuotePhotos</c> (cross-tenant, non-BaseEntity) via <c>ApplicationDbContext</c>
|
||||
/// directly so that <see cref="AiUsageReportController"/> does not need a direct DB context reference.
|
||||
/// Implemented in Infrastructure; used as Tier-3 aggregate report service.
|
||||
/// </summary>
|
||||
public interface IAiUsageReportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns all the aggregated AI usage data needed to render the platform AI usage report:
|
||||
/// per-company call counts across today / 7-day / 30-day / all-time windows,
|
||||
/// feature stats for the last 30 days, and AI photo upload counts per company.
|
||||
/// </summary>
|
||||
Task<AiUsageReportData> GetReportDataAsync();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only service for financial aggregate reports. All methods query the database
|
||||
/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned.
|
||||
/// Implemented in Infrastructure; uses ApplicationDbContext directly.
|
||||
/// </summary>
|
||||
public interface IFinancialReportService
|
||||
{
|
||||
/// <summary>Returns a Profit & Loss report for the given company and date range.</summary>
|
||||
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>Returns a Balance Sheet snapshot as of the given date.</summary>
|
||||
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf);
|
||||
|
||||
/// <summary>Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.</summary>
|
||||
Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf);
|
||||
|
||||
/// <summary>Returns a Sales & Income report for the given company and date range.</summary>
|
||||
Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder return types for operational reports. These will be replaced with proper
|
||||
/// Application DTOs as each report is migrated from ReportsController in Phase 2/3.
|
||||
/// </summary>
|
||||
public record JobCycleTimeReport(List<JobCycleTimeRow> Rows, int Months);
|
||||
public record JobCycleTimeRow(string StatusName, double AvgDaysInStatus, int JobCount);
|
||||
|
||||
public record PowderUsageReport(List<PowderUsageRow> Rows, int Months);
|
||||
public record PowderUsageRow(string ColorName, string VendorName, decimal TotalLbs, decimal TotalCost);
|
||||
|
||||
/// <summary>
|
||||
/// Read-only service for operational aggregate reports. All methods query the database
|
||||
/// with AsNoTracking and return pre-shaped objects — no tracked entities are returned.
|
||||
/// Implemented in Infrastructure; uses ApplicationDbContext directly.
|
||||
/// </summary>
|
||||
public interface IOperationalReportService
|
||||
{
|
||||
/// <summary>Returns average time jobs spend in each status over the given lookback period.</summary>
|
||||
Task<JobCycleTimeReport> GetJobCycleTimeAsync(int companyId, int months);
|
||||
|
||||
/// <summary>Returns powder usage (lbs and cost) broken down by color and vendor.</summary>
|
||||
Task<PowderUsageReport> GetPowderUsageAsync(int companyId, int months);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all active (non-deleted, non-voided) bills with their Vendor and non-deleted
|
||||
/// Payments navigations loaded. Used by Analytics, ExpensesAp, and AI accounting actions
|
||||
/// so those controllers do not need a direct ApplicationDbContext reference.
|
||||
/// </summary>
|
||||
Task<List<Bill>> GetActiveBillsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Returns all non-deleted direct expenses with their ExpenseAccount navigation loaded.
|
||||
/// Used by Analytics, ExpensesAp, and AI accounting actions so those controllers do not
|
||||
/// need a direct ApplicationDbContext reference.
|
||||
/// </summary>
|
||||
Task<List<Expense>> GetAllExpensesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full job status history log with FromStatus and ToStatus navigations
|
||||
/// loaded. Used by Analytics and JobCycleTime so those actions do not need a direct
|
||||
/// ApplicationDbContext reference.
|
||||
/// </summary>
|
||||
Task<List<JobStatusHistory>> GetAllJobStatusHistoryAsync();
|
||||
}
|
||||
@@ -33,6 +33,10 @@ public class InvoiceProfile : Profile
|
||||
.ForMember(d => d.BalanceDue, o => o.MapFrom(s => s.BalanceDue))
|
||||
.ForMember(d => d.SalesTaxAccountName, o => o.MapFrom(s => s.SalesTaxAccount != null
|
||||
? $"{s.SalesTaxAccount.AccountNumber} – {s.SalesTaxAccount.Name}" : null))
|
||||
// These three collections are built manually in BuildInvoiceDtoAsync — no AutoMapper element map exists.
|
||||
// AutoMapper 12+ throws for non-empty collections with no registered element mapping, so Ignore here.
|
||||
.ForMember(d => d.Refunds, o => o.Ignore())
|
||||
.ForMember(d => d.CreditApplications, o => o.Ignore())
|
||||
.ForMember(d => d.GiftCertificateRedemptions, o => o.Ignore());
|
||||
|
||||
CreateMap<InvoiceItem, InvoiceItemDto>()
|
||||
|
||||
@@ -109,7 +109,9 @@ public class JobProfile : Profile
|
||||
.ForMember(dest => dest.JobPriorityId, opt => opt.MapFrom(src => src.JobPriorityId))
|
||||
.ForMember(dest => dest.PriorityCode, opt => opt.MapFrom(src => src.JobPriority.PriorityCode))
|
||||
.ForMember(dest => dest.PriorityDisplayName, opt => opt.MapFrom(src => src.JobPriority.DisplayName))
|
||||
.ForMember(dest => dest.PriorityColorClass, opt => opt.MapFrom(src => src.JobPriority.ColorClass));
|
||||
.ForMember(dest => dest.PriorityColorClass, opt => opt.MapFrom(src => src.JobPriority.ColorClass))
|
||||
.ForMember(dest => dest.CustomerNotifyByEmail,
|
||||
opt => opt.MapFrom(src => src.Customer == null || src.Customer.NotifyByEmail));
|
||||
|
||||
// JobItem mappings
|
||||
CreateMap<JobItem, JobItemDto>()
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace PowderCoating.Application.Services;
|
||||
/// </summary>
|
||||
public class FileService : IFileService
|
||||
{
|
||||
private const string UploadsRootFolder = "uploads";
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<FileService> _logger;
|
||||
|
||||
@@ -31,7 +32,9 @@ public class FileService : IFileService
|
||||
/// Validation order: null/empty check, size limit, then extension allowlist. The original file
|
||||
/// name is sanitised with <see cref="Path.GetFileName"/> to strip any directory components before
|
||||
/// prepending the GUID prefix, preventing path traversal if the browser supplies a name with
|
||||
/// slashes. Returns a relative path (from <c>wwwroot</c>) suitable for storing in the database.
|
||||
/// slashes. The target subfolder is resolved and confined under <c>wwwroot/uploads/</c> before
|
||||
/// any file system access occurs. Returns a relative path (from <c>wwwroot</c>) suitable for
|
||||
/// storing in the database.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveFileAsync(
|
||||
IFormFile file,
|
||||
@@ -65,7 +68,11 @@ public class FileService : IFileService
|
||||
// Create upload directory if it doesn't exist
|
||||
// NOTE: WebRootPath is read-only on Azure Linux App Service; this service is legacy
|
||||
// and should only be called for on-premises deployments. New uploads use Azure Blob.
|
||||
var uploadPath = Path.Combine(_environment.WebRootPath, "uploads", subfolder);
|
||||
if (!TryResolveUploadSubfolder(subfolder, out var uploadPath, out var relativeSubfolder, out var subfolderError))
|
||||
{
|
||||
return (false, string.Empty, subfolderError);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(uploadPath))
|
||||
{
|
||||
try
|
||||
@@ -93,7 +100,7 @@ public class FileService : IFileService
|
||||
}
|
||||
|
||||
// Return relative path from wwwroot
|
||||
var relativePath = Path.Combine("uploads", subfolder, uniqueFileName).Replace("\\", "/");
|
||||
var relativePath = Path.Combine(UploadsRootFolder, relativeSubfolder, uniqueFileName).Replace("\\", "/");
|
||||
|
||||
_logger.LogInformation("File saved successfully: {FilePath}", relativePath);
|
||||
return (true, relativePath, string.Empty);
|
||||
@@ -108,8 +115,8 @@ public class FileService : IFileService
|
||||
/// <summary>
|
||||
/// Deletes a file given its relative path from <c>wwwroot</c>.
|
||||
/// Returns success if the file does not exist (idempotent) so that callers do not need to check
|
||||
/// existence before calling. The relative path is converted to an absolute path with
|
||||
/// <see cref="Path.Combine"/> rather than string concatenation to prevent directory traversal.
|
||||
/// existence before calling. The relative path is normalized and must remain under
|
||||
/// <c>wwwroot/uploads/</c>; paths outside that root are rejected.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string ErrorMessage)> DeleteFileAsync(string filePath)
|
||||
{
|
||||
@@ -120,7 +127,10 @@ public class FileService : IFileService
|
||||
return (false, "File path is required.");
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError))
|
||||
{
|
||||
return (false, pathError);
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
@@ -142,8 +152,8 @@ public class FileService : IFileService
|
||||
|
||||
/// <summary>
|
||||
/// Reads a file from disk and returns its raw bytes along with a derived MIME content type.
|
||||
/// Intended for serving files that are stored outside <c>wwwroot</c> (or otherwise not directly
|
||||
/// accessible via the static-files middleware) so controllers can stream them as file responses.
|
||||
/// Intended for serving files that are stored under the legacy <c>wwwroot/uploads/</c> path but
|
||||
/// are otherwise not directly exposed through the static-files middleware.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetFileAsync(string filePath)
|
||||
{
|
||||
@@ -154,7 +164,10 @@ public class FileService : IFileService
|
||||
return (false, Array.Empty<byte>(), string.Empty, "File path is required.");
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError))
|
||||
{
|
||||
return (false, Array.Empty<byte>(), string.Empty, pathError);
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
@@ -175,7 +188,7 @@ public class FileService : IFileService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a file exists at the given <c>wwwroot</c>-relative path without reading it.
|
||||
/// Checks whether a file exists at the given <c>wwwroot/uploads/</c>-relative path without reading it.
|
||||
/// Used by views and controllers to conditionally show download links only when the file is present.
|
||||
/// </summary>
|
||||
public bool FileExists(string filePath)
|
||||
@@ -185,7 +198,11 @@ public class FileService : IFileService
|
||||
return false;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return File.Exists(fullPath);
|
||||
}
|
||||
|
||||
@@ -212,4 +229,96 @@ public class FileService : IFileService
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
private bool TryResolveUploadSubfolder(
|
||||
string subfolder,
|
||||
out string uploadPath,
|
||||
out string relativeSubfolder,
|
||||
out string errorMessage)
|
||||
{
|
||||
uploadPath = string.Empty;
|
||||
relativeSubfolder = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subfolder))
|
||||
{
|
||||
errorMessage = "Upload subfolder is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedSubfolder = subfolder.Replace('\\', '/').Trim('/');
|
||||
var resolvedPath = Path.GetFullPath(
|
||||
Path.Combine(uploadsRoot, normalizedSubfolder.Replace('/', Path.DirectorySeparatorChar)));
|
||||
|
||||
if (!IsWithinDirectory(resolvedPath, uploadsRoot))
|
||||
{
|
||||
errorMessage = "Invalid upload subfolder.";
|
||||
_logger.LogWarning("Rejected upload subfolder outside uploads root: {Subfolder}", subfolder);
|
||||
return false;
|
||||
}
|
||||
|
||||
relativeSubfolder = Path.GetRelativePath(uploadsRoot, resolvedPath).Replace("\\", "/");
|
||||
uploadPath = resolvedPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryResolveLegacyUploadPath(string filePath, out string fullPath, out string errorMessage)
|
||||
{
|
||||
fullPath = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedRelativePath = filePath.Replace('\\', '/').TrimStart('/');
|
||||
if (!normalizedRelativePath.StartsWith($"{UploadsRootFolder}/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errorMessage = "Invalid file path.";
|
||||
_logger.LogWarning("Rejected legacy file path outside uploads root: {FilePath}", filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
var resolvedPath = Path.GetFullPath(
|
||||
Path.Combine(_environment.WebRootPath, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar)));
|
||||
|
||||
if (!IsWithinDirectory(resolvedPath, uploadsRoot))
|
||||
{
|
||||
errorMessage = "Invalid file path.";
|
||||
_logger.LogWarning("Rejected path traversal attempt for legacy file path: {FilePath}", filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
fullPath = resolvedPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryGetUploadsRootPath(out string uploadsRoot, out string errorMessage)
|
||||
{
|
||||
uploadsRoot = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_environment.WebRootPath))
|
||||
{
|
||||
errorMessage = "File storage is not available in this environment.";
|
||||
_logger.LogWarning("WebRootPath is not configured for the legacy file service.");
|
||||
return false;
|
||||
}
|
||||
|
||||
uploadsRoot = Path.GetFullPath(Path.Combine(_environment.WebRootPath, UploadsRootFolder));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsWithinDirectory(string candidatePath, string rootPath)
|
||||
{
|
||||
var normalizedRoot = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
return candidatePath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,22 @@ public class CompanyPreferences : BaseEntity
|
||||
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
||||
public string? QbMigrationStateJson { get; set; }
|
||||
|
||||
// Guided activation / first-workflow onboarding
|
||||
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
||||
public string? OnboardingPath { get; set; }
|
||||
/// <summary>True once the company completes its first guided real workflow.</summary>
|
||||
public bool FirstWorkflowCompleted { get; set; } = false;
|
||||
/// <summary>UTC timestamp of when the first guided workflow was completed.</summary>
|
||||
public DateTime? FirstWorkflowCompletedAt { get; set; }
|
||||
/// <summary>UTC timestamp of the company's first quote creation.</summary>
|
||||
public DateTime? FirstQuoteCreatedAt { get; set; }
|
||||
/// <summary>UTC timestamp of the company's first job creation.</summary>
|
||||
public DateTime? FirstJobCreatedAt { get; set; }
|
||||
/// <summary>UTC timestamp of the company's first invoice creation.</summary>
|
||||
public DateTime? FirstInvoiceCreatedAt { get; set; }
|
||||
/// <summary>UTC timestamp of when the company dismissed guided activation without completing it.</summary>
|
||||
public DateTime? GuidedActivationDismissedAt { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Company Company { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ public enum NotificationType
|
||||
SubscriptionExpiryReminder = 10,
|
||||
SubscriptionExpired = 11,
|
||||
SmsInboundStop = 12,
|
||||
SmsInboundHelp = 13
|
||||
SmsInboundHelp = 13,
|
||||
AdminEmail = 14
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight repository interface for platform-level entities that do not inherit
|
||||
/// <see cref="PowderCoating.Core.Entities.BaseEntity"/> (e.g. Announcement, BannedIp,
|
||||
/// DashboardTip, ReleaseNote). These entities have no CompanyId, no IsDeleted, and no
|
||||
/// soft-delete semantics — so the full IRepository<T> contract (SoftDeleteAsync,
|
||||
/// ignoreQueryFilters) does not apply.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Any EF-mapped class (does not need to inherit BaseEntity).</typeparam>
|
||||
public interface IPlainRepository<T> where T : class
|
||||
{
|
||||
Task<T?> GetByIdAsync(int id);
|
||||
Task<IEnumerable<T>> GetAllAsync();
|
||||
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);
|
||||
Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate);
|
||||
Task<bool> AnyAsync(Expression<Func<T, bool>> predicate);
|
||||
Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null);
|
||||
|
||||
Task<T> AddAsync(T entity);
|
||||
Task<IEnumerable<T>> AddRangeAsync(IEnumerable<T> entities);
|
||||
|
||||
Task UpdateAsync(T entity);
|
||||
|
||||
Task DeleteAsync(T entity);
|
||||
Task DeleteAsync(int id);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces;
|
||||
|
||||
@@ -13,29 +14,31 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<AiItemPrediction> AiItemPredictions { get; }
|
||||
|
||||
// Powder Insights
|
||||
IRepository<PowderUsageLog> PowderUsageLogs { get; }
|
||||
IPowderUsageLogRepository PowderUsageLogs { get; }
|
||||
|
||||
// Core entities
|
||||
IRepository<Customer> Customers { get; }
|
||||
IRepository<Job> Jobs { get; }
|
||||
// Core entities — typed repositories for complex domains
|
||||
ICustomerRepository Customers { get; }
|
||||
IJobRepository Jobs { get; }
|
||||
IRepository<JobDailyPriority> JobDailyPriorities { get; }
|
||||
IRepository<JobItem> JobItems { get; }
|
||||
IRepository<JobItemCoat> JobItemCoats { get; }
|
||||
IJobItemCoatRepository JobItemCoats { get; }
|
||||
IRepository<JobItemPrepService> JobItemPrepServices { get; }
|
||||
IRepository<JobChangeHistory> JobChangeHistories { get; }
|
||||
IRepository<Quote> Quotes { get; }
|
||||
IRepository<JobPrepService> JobPrepServices { get; }
|
||||
IQuoteRepository Quotes { get; }
|
||||
IRepository<QuotePhoto> QuotePhotos { get; }
|
||||
IRepository<QuoteItem> QuoteItems { get; }
|
||||
IRepository<QuoteItemCoat> QuoteItemCoats { get; }
|
||||
IRepository<QuoteItemPrepService> QuoteItemPrepServices { get; }
|
||||
IRepository<QuoteChangeHistory> QuoteChangeHistories { get; }
|
||||
IRepository<InventoryItem> InventoryItems { get; }
|
||||
IRepository<InventoryTransaction> InventoryTransactions { get; }
|
||||
IInventoryTransactionRepository InventoryTransactions { get; }
|
||||
IRepository<Equipment> Equipment { get; }
|
||||
IRepository<OvenCost> OvenCosts { get; }
|
||||
IRepository<CompanyBlastSetup> BlastSetups { get; }
|
||||
IRepository<MaintenanceRecord> MaintenanceRecords { get; }
|
||||
IRepository<Vendor> Vendors { get; }
|
||||
IRepository<JobPhoto> JobPhotos { get; }
|
||||
IJobPhotoRepository JobPhotos { get; }
|
||||
IRepository<JobNote> JobNotes { get; }
|
||||
IRepository<CustomerNote> CustomerNotes { get; }
|
||||
IRepository<JobStatusHistory> JobStatusHistory { get; }
|
||||
@@ -69,38 +72,46 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<OvenBatch> OvenBatches { get; }
|
||||
IRepository<OvenBatchItem> OvenBatchItems { get; }
|
||||
|
||||
// Invoices, Payments & Deposits
|
||||
IRepository<Invoice> Invoices { get; }
|
||||
// Invoices, Payments & Deposits — typed repository for complex include chains
|
||||
IInvoiceRepository Invoices { get; }
|
||||
IRepository<InvoiceItem> InvoiceItems { get; }
|
||||
IRepository<Payment> Payments { get; }
|
||||
IRepository<Deposit> Deposits { get; }
|
||||
|
||||
// Purchase Orders
|
||||
IRepository<PurchaseOrder> PurchaseOrders { get; }
|
||||
// Purchase Orders — typed repository for paged/filtered list and detail load
|
||||
IPurchaseOrderRepository PurchaseOrders { get; }
|
||||
IRepository<PurchaseOrderItem> PurchaseOrderItems { get; }
|
||||
|
||||
// Expense Tracking / Accounts Payable
|
||||
// Expense Tracking / Accounts Payable — typed repository for Bills
|
||||
IRepository<Account> Accounts { get; }
|
||||
IRepository<Bill> Bills { get; }
|
||||
IBillRepository Bills { get; }
|
||||
IRepository<BillLineItem> BillLineItems { get; }
|
||||
IRepository<BillPayment> BillPayments { get; }
|
||||
IRepository<Expense> Expenses { get; }
|
||||
|
||||
// Notifications
|
||||
IRepository<NotificationLog> NotificationLogs { get; }
|
||||
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
|
||||
INotificationLogRepository NotificationLogs { get; }
|
||||
IRepository<NotificationTemplate> NotificationTemplates { get; }
|
||||
|
||||
// Subscription
|
||||
IRepository<SubscriptionPlanConfig> SubscriptionPlanConfigs { get; }
|
||||
|
||||
// Job Templates
|
||||
IRepository<JobTemplate> JobTemplates { get; }
|
||||
IJobTemplateRepository JobTemplates { get; }
|
||||
IRepository<JobTemplateItem> JobTemplateItems { get; }
|
||||
IRepository<JobTemplateItemCoat> JobTemplateItemCoats { get; }
|
||||
IRepository<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; }
|
||||
|
||||
// Platform content (SuperAdmin-managed, no tenant filter, no soft delete)
|
||||
IPlainRepository<Announcement> Announcements { get; }
|
||||
IPlainRepository<BannedIp> BannedIps { get; }
|
||||
IPlainRepository<DashboardTip> DashboardTips { get; }
|
||||
IRepository<InAppNotification> InAppNotifications { get; }
|
||||
IPlainRepository<ReleaseNote> ReleaseNotes { get; }
|
||||
|
||||
// Bug Reports
|
||||
IRepository<BugReport> BugReports { get; }
|
||||
IRepository<BugReportAttachment> BugReportAttachments { get; }
|
||||
|
||||
// Contact Us
|
||||
IRepository<ContactSubmission> ContactSubmissions { get; }
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="Bill"/> that adds domain-specific queries on top of
|
||||
/// the generic CRUD interface.
|
||||
/// </summary>
|
||||
public interface IBillRepository : IRepository<Bill>
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads a single bill with the full include chain required by the Details view: Vendor,
|
||||
/// APAccount, LineItems (filtered to non-deleted) with Account and Job navigations, and
|
||||
/// Payments (filtered to non-deleted) with BankAccount. Returns null if not found.
|
||||
/// </summary>
|
||||
Task<Bill?> LoadForViewAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a single bill with only its line items for the Edit form. Excludes payment
|
||||
/// navigations since those are read-only after the bill is opened.
|
||||
/// </summary>
|
||||
Task<Bill?> LoadForEditAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all bills for the Index/AP ledger view filtered by status and/or search term.
|
||||
/// Includes Vendor so the list row can display vendor name without a second round trip.
|
||||
/// LineItems are included for the search-in-description condition only.
|
||||
/// </summary>
|
||||
Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the last bill number with the given prefix (including soft-deleted records) for
|
||||
/// sequential number generation. Uses IgnoreQueryFilters so deleted bills are counted.
|
||||
/// </summary>
|
||||
Task<string?> GetLastBillNumberAsync(string prefix);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the last payment number with the given prefix (including soft-deleted records)
|
||||
/// for sequential payment reference generation.
|
||||
/// </summary>
|
||||
Task<string?> GetLastPaymentNumberAsync(string prefix);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all non-deleted bills whose <c>BillDate</c> falls within [<paramref name="start"/>,
|
||||
/// <paramref name="end"/>], with Vendor, LineItems → Account, and Payments loaded.
|
||||
/// Used by the accounting data export to produce QuickBooks IIF / CSV files.
|
||||
/// </summary>
|
||||
Task<List<Bill>> GetForDateRangeAsync(DateTime start, DateTime end);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="Customer"/> that adds domain-specific queries on top of
|
||||
/// the generic CRUD interface.
|
||||
/// </summary>
|
||||
public interface ICustomerRepository : IRepository<Customer>
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads a single customer with the navigations needed by the Details view: PricingTier,
|
||||
/// and recent CustomerNotes ordered newest-first. Returns null if not found or soft-deleted.
|
||||
/// </summary>
|
||||
Task<Customer?> LoadForDetailsAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a customer by email address within the current tenant. Used for duplicate-email
|
||||
/// validation on create and edit. Returns null if no match is found.
|
||||
/// </summary>
|
||||
Task<Customer?> FindByEmailAsync(string email);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="InventoryTransaction"/> that adds a dynamic-filter
|
||||
/// query for the Inventory Ledger view on top of the generic CRUD interface.
|
||||
/// </summary>
|
||||
public interface IInventoryTransactionRepository : IRepository<InventoryTransaction>
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns up to 500 non-deleted inventory transactions matching the supplied filters,
|
||||
/// ordered newest-first, with InventoryItem, PurchaseOrder, and Job navigations loaded.
|
||||
/// Null parameter values are treated as "no filter" for that dimension.
|
||||
/// </summary>
|
||||
Task<List<InventoryTransaction>> GetForLedgerAsync(
|
||||
int? itemId,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
InventoryTransactionType? type);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="Invoice"/> that adds domain-specific queries on top of
|
||||
/// the generic CRUD interface.
|
||||
/// </summary>
|
||||
public interface IInvoiceRepository : IRepository<Invoice>
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads a single invoice with the full eight-table include chain required by the Details
|
||||
/// view and PDF generation: Customer, Job, PreparedBy, SalesTaxAccount, InvoiceItems with
|
||||
/// RevenueAccount and GeneratedGiftCertificate, Payments with RecordedBy and DepositAccount,
|
||||
/// Refunds with IssuedBy, CreditApplications with CreditMemo, and GiftCertificateRedemptions.
|
||||
/// Filtered includes exclude soft-deleted children. Returns null if not found or soft-deleted.
|
||||
/// </summary>
|
||||
Task<Invoice?> LoadForViewAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the invoice linked to a job, or null if none exists. Pass
|
||||
/// <paramref name="includeDeleted"/> = true to also surface soft-deleted invoices (used by
|
||||
/// the 1:1 uniqueness guard that prevents duplicate invoices for the same job).
|
||||
/// </summary>
|
||||
Task<Invoice?> GetForJobAsync(int jobId, bool includeDeleted = false);
|
||||
|
||||
/// <summary>
|
||||
/// Looks up an invoice by its online-payment token. Ignores query filters so the payment
|
||||
/// portal can load the invoice even when the anonymous request has no tenant context.
|
||||
/// Returns null if the token does not match any invoice.
|
||||
/// </summary>
|
||||
Task<Invoice?> GetByPaymentTokenAsync(string token);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the last invoice number that starts with <paramref name="prefix"/> for the given
|
||||
/// company (including soft-deleted invoices) for sequential number generation.
|
||||
/// </summary>
|
||||
Task<string?> GetLastInvoiceNumberByPrefixAsync(int companyId, string prefix);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all non-deleted invoices that have at least one online payment for the given company
|
||||
/// and date window, with Customer navigation loaded. Used by the Online Payments reconciliation view.
|
||||
/// </summary>
|
||||
Task<List<Invoice>> GetOnlineInvoicesForPeriodAsync(int companyId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all non-deleted CreditDebitCard refunds for the given company and date window,
|
||||
/// with Invoice→Customer navigation loaded. Used by the Online Payments reconciliation view.
|
||||
/// </summary>
|
||||
Task<List<Refund>> GetOnlineRefundsForPeriodAsync(int companyId, DateTime from, DateTime to);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="JobItemCoat"/> that adds ThenInclude-based load methods the
|
||||
/// generic <see cref="IRepository{T}"/> cannot express. Used by DashboardController for powder
|
||||
/// order marking, powder receipt, and custom powder inventory creation.
|
||||
/// </summary>
|
||||
public interface IJobItemCoatRepository : IRepository<JobItemCoat>
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads a coat with the full vendor + job chain needed by the <c>MarkPowderOrdered</c> action:
|
||||
/// <c>JobItem → Job → Customer</c>, <c>InventoryItem → PrimaryVendor</c>, and direct
|
||||
/// <c>Vendor</c>. Returns <see langword="null"/> if not found.
|
||||
/// </summary>
|
||||
Task<JobItemCoat?> LoadForOrderMarkingAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a coat with only <c>InventoryItem</c> included — used by <c>ReceivePowder</c> for
|
||||
/// the initial stock update. Returns <see langword="null"/> if not found.
|
||||
/// </summary>
|
||||
Task<JobItemCoat?> LoadWithInventoryAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a coat with <c>JobItem → Job</c> included — used by <c>ReceivePowder</c> to verify
|
||||
/// company ownership when the initial load did not include the job chain. EF Core identity-map
|
||||
/// fixup propagates <c>JobItem</c> back to any previously tracked instance of the same coat.
|
||||
/// Returns <see langword="null"/> if not found.
|
||||
/// </summary>
|
||||
Task<JobItemCoat?> LoadWithJobChainAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all non-deleted coats that have no linked inventory item and belong to
|
||||
/// <paramref name="companyId"/>, excluding <paramref name="excludeCoatId"/>. Used by
|
||||
/// <c>AddCustomPowderToInventory</c> to link sibling coats to the newly created item.
|
||||
/// Entities are tracked so that <c>InventoryItemId</c> mutations are saved via UnitOfWork.
|
||||
/// </summary>
|
||||
Task<List<JobItemCoat>> GetCandidateCoatsForLinkingAsync(int excludeCoatId, int companyId);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="JobPhoto"/> that adds inventory-specific photo lookup
|
||||
/// queries on top of the generic CRUD interface. These queries require multi-level
|
||||
/// ThenInclude chains and dynamic filtering that the generic <see cref="IRepository{T}"/>
|
||||
/// cannot express.
|
||||
/// </summary>
|
||||
public interface IJobPhotoRepository : IRepository<JobPhoto>
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns all non-deleted, tagged job photos whose <c>Tags</c> field contains
|
||||
/// <paramref name="colorName"/> or <paramref name="itemName"/> (SQL LIKE), ordered
|
||||
/// newest-first, with Job → Customer navigation loaded. The caller performs an
|
||||
/// exact-token match in memory to reject false positives before paginating.
|
||||
/// </summary>
|
||||
Task<List<JobPhoto>> GetTaggedPhotosAsync(string? colorName, string? itemName);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all non-deleted job photos from jobs that use a specific inventory item
|
||||
/// in any coat, matched via <c>JobItemCoat.InventoryItemId</c>. Loads
|
||||
/// Job → Customer and Job → JobItems → Coats navigations. Used by the
|
||||
/// Photos by Powder panel on the inventory item detail page.
|
||||
/// </summary>
|
||||
Task<List<JobPhoto>> GetPhotosByPowderItemAsync(int inventoryItemId);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="Job"/> that extends the generic CRUD interface with
|
||||
/// domain-specific queries that require multi-level include chains the generic
|
||||
/// <see cref="IRepository{T}"/> cannot express.
|
||||
/// </summary>
|
||||
public interface IJobRepository : IRepository<Job>
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads all active jobs with the minimal set of navigations needed to render the Kanban
|
||||
/// board columns (Customer name, status, priority, assigned user, due date). Uses
|
||||
/// AsNoTracking for read performance.
|
||||
/// </summary>
|
||||
Task<List<Job>> GetBoardJobsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Loads a single job with the full include chain required by the Details view: Customer,
|
||||
/// JobStatus, JobPriority, AssignedUser, Quote, OvenCost, OriginalJob, IntakeCheckedBy,
|
||||
/// and all JobItems with their Coats (InventoryItem + Vendor) and PrepServices.
|
||||
/// Also loads JobPrepServices (job-level prep) separately. Returns null if not found.
|
||||
/// </summary>
|
||||
Task<Job?> LoadForDetailsAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a single job with the include chain required by the Edit form: same as
|
||||
/// <see cref="LoadForDetailsAsync"/> but without the read-only audit navigations, and
|
||||
/// with tracking enabled so changes can be saved.
|
||||
/// </summary>
|
||||
Task<Job?> LoadForEditAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the lightweight job record needed for status-change operations (MoveCard, StatusBump).
|
||||
/// Includes only JobStatus. Returns null if not found or soft-deleted.
|
||||
/// </summary>
|
||||
Task<Job?> LoadForStatusChangeAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the change history for a job, ordered newest-first, with ChangedBy navigation
|
||||
/// loaded. Used by the Details view changelog tab.
|
||||
/// </summary>
|
||||
Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the last job number that starts with <paramref name="prefix"/> for the given
|
||||
/// company (including soft-deleted jobs) for sequential number generation.
|
||||
/// </summary>
|
||||
Task<string?> GetLastJobNumberByPrefixAsync(int companyId, string prefix);
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a job created from <paramref name="quoteId"/> that may be incomplete (items not
|
||||
/// saved). Ignores query filters so it catches soft-deleted leftover rows from a previous
|
||||
/// failed conversion attempt. Used for orphan cleanup before retrying conversion.
|
||||
/// </summary>
|
||||
Task<Job?> GetOrphanedConversionJobAsync(int quoteId, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Loads all jobs scheduled for <paramref name="date"/> that are not in a terminal status,
|
||||
/// with Customer, JobStatus, JobPriority, AssignedUser, and JobItems (with Coats) navigations.
|
||||
/// Optionally filtered to a single worker when <paramref name="userId"/> is supplied.
|
||||
/// Used by the ShopDisplay (TV board) action.
|
||||
/// </summary>
|
||||
Task<List<Job>> GetScheduledJobsForDateAsync(DateTime date, string? userId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Loads all active (non-terminal, non-hold, non-cancelled) jobs for a company's shop mobile
|
||||
/// view, with Customer, JobStatus, JobPriority, AssignedUser, and JobItems (with Coats).
|
||||
/// Optionally filtered to a single worker when <paramref name="workerId"/> is supplied.
|
||||
/// </summary>
|
||||
Task<List<Job>> GetActiveJobsForMobileAsync(int companyId, string? workerId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a single job with the navigations required by the costing breakdown endpoint:
|
||||
/// OvenCost, Invoice, JobItems (with Coats), and TimeEntries (with Worker).
|
||||
/// Scoped to <paramref name="companyId"/> as an extra safety check.
|
||||
/// Returns null if not found.
|
||||
/// </summary>
|
||||
Task<Job?> LoadForCostingAsync(int jobId, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a single job with JobItems → Coats and JobItems → PrepServices for deep-copying
|
||||
/// into a new <see cref="JobTemplate"/> via <c>SaveJobAsTemplate</c>.
|
||||
/// Returns null if not found or soft-deleted.
|
||||
/// </summary>
|
||||
Task<Job?> LoadForTemplateSnapshotAsync(int jobId);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="JobTemplate"/> that extends the generic CRUD interface with
|
||||
/// domain-specific queries requiring multi-level include chains the generic
|
||||
/// <see cref="IRepository{T}"/> cannot express.
|
||||
/// </summary>
|
||||
public interface IJobTemplateRepository : IRepository<JobTemplate>
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads a single template with the full include chain for the Details view:
|
||||
/// Customer, Items (with Coats → InventoryItem and PrepServices → PrepService).
|
||||
/// Returns null if not found or soft-deleted.
|
||||
/// </summary>
|
||||
Task<JobTemplate?> LoadForDetailsAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all active, non-deleted templates for the current company with Customer,
|
||||
/// Items → Coats, and Items → PrepServices → PrepService loaded. Used by the
|
||||
/// GetTemplatesJson AJAX endpoint to hydrate the job creation wizard.
|
||||
/// </summary>
|
||||
Task<List<JobTemplate>> GetAllActiveWithFullIncludesAsync();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="NotificationLog"/> that adds IgnoreQueryFilters-based lookups
|
||||
/// by entity FK (InvoiceId, QuoteId, JobId) on top of the generic CRUD interface.
|
||||
/// All methods bypass soft-delete and tenant filters so notification history is always visible
|
||||
/// regardless of whether the linked entity has been soft-deleted.
|
||||
/// </summary>
|
||||
public interface INotificationLogRepository : IRepository<NotificationLog>
|
||||
{
|
||||
/// <summary>Returns the most recent notification log entry for the given invoice, or null.</summary>
|
||||
Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId);
|
||||
|
||||
/// <summary>Returns all notification log entries for the given invoice, newest-first.</summary>
|
||||
Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId);
|
||||
|
||||
/// <summary>Returns the most recent notification log entry for the given quote, or null.</summary>
|
||||
Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId);
|
||||
|
||||
/// <summary>Returns all notification log entries for the given quote, newest-first.</summary>
|
||||
Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId);
|
||||
|
||||
/// <summary>Returns the most recent notification log entry for the given job, or null.</summary>
|
||||
Task<NotificationLog?> GetLatestForJobAsync(int jobId);
|
||||
|
||||
/// <summary>Returns all notification log entries for the given job, newest-first.</summary>
|
||||
Task<List<NotificationLog>> GetAllForJobAsync(int jobId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a paginated, filtered, and sorted page of notification log entries with Customer,
|
||||
/// Job, and Quote navigations loaded. All filter parameters are optional — omit to include all.
|
||||
/// Used by the company-scoped notification log index view.
|
||||
/// </summary>
|
||||
Task<(List<NotificationLog> Items, int TotalCount)> GetPagedFilteredAsync(
|
||||
int pageNumber, int pageSize,
|
||||
string? searchTerm = null,
|
||||
NotificationChannel? channel = null,
|
||||
NotificationStatus? status = null,
|
||||
NotificationType? type = null,
|
||||
int? jobId = null,
|
||||
string sortColumn = "SentAt",
|
||||
string sortDirection = "desc");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="PowderUsageLog"/> that adds a dynamic-filter
|
||||
/// query for the Inventory Ledger usage tab on top of the generic CRUD interface.
|
||||
/// </summary>
|
||||
public interface IPowderUsageLogRepository : IRepository<PowderUsageLog>
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns up to 500 non-deleted powder usage logs matching the supplied filters,
|
||||
/// ordered newest-first, with Job → Customer, InventoryItem, and JobItemCoat
|
||||
/// navigations loaded. Null parameter values are treated as "no filter".
|
||||
/// </summary>
|
||||
Task<List<PowderUsageLog>> GetForLedgerAsync(int? itemId, DateTime? from, DateTime? to);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
/// <summary>Lightweight projection used by the Index KPI cards — avoids loading full entities for aggregate stats.</summary>
|
||||
public record PurchaseOrderStats(int TotalCount, int OpenCount, decimal CommittedValue, int OverdueCount);
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="PurchaseOrder"/> that adds domain-specific queries on top of
|
||||
/// the generic CRUD interface.
|
||||
/// </summary>
|
||||
public interface IPurchaseOrderRepository : IRepository<PurchaseOrder>
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads a single purchase order with the full include chain required by the Details view:
|
||||
/// Vendor, Bill, and Items (filtered to non-deleted) with InventoryItem navigation.
|
||||
/// Returns null if not found or not owned by the current tenant.
|
||||
/// </summary>
|
||||
Task<PurchaseOrder?> LoadForViewAsync(int id, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns KPI aggregate stats for the Index view using a server-side projection so only three
|
||||
/// columns are fetched rather than full entities.
|
||||
/// </summary>
|
||||
Task<PurchaseOrderStats> GetStatsAsync(int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a paged, filtered, and sorted list of purchase orders for the Index view.
|
||||
/// All filter parameters are optional — passing null/empty applies no restriction for
|
||||
/// that dimension.
|
||||
/// </summary>
|
||||
Task<(List<PurchaseOrder> Items, int TotalCount)> GetPagedAsync(
|
||||
int companyId,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
PurchaseOrderStatus? statusFilter = null,
|
||||
int? vendorId = null,
|
||||
DateTime? dateFrom = null,
|
||||
DateTime? dateTo = null,
|
||||
string? searchTerm = null,
|
||||
string? sortColumn = null,
|
||||
string? sortDirection = null);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
/// <summary>Aggregate counts and totals for the Quotes Index stat cards.</summary>
|
||||
public record QuoteIndexStats(int OpenCount, int ApprovedConvertedCount, decimal TotalValue);
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="Quote"/> that adds domain-specific queries on top of
|
||||
/// the generic CRUD interface.
|
||||
/// </summary>
|
||||
public interface IQuoteRepository : IRepository<Quote>
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads a single quote with the full include chain required by the Details view: Customer,
|
||||
/// PreparedBy, QuoteStatus, OvenCost, QuoteItems with Coats (InventoryItem + Vendor),
|
||||
/// CatalogItem, and PrepServices; plus QuotePrepServices (quote-level prep).
|
||||
/// Returns null if not found or soft-deleted.
|
||||
/// </summary>
|
||||
Task<Quote?> LoadForDetailsAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a single quote by its customer-facing approval token. Ignores global query filters
|
||||
/// so the unauthenticated approval portal can resolve any tenant's quote by token alone.
|
||||
/// Includes Customer navigation. Returns null if the token does not match any live quote.
|
||||
/// </summary>
|
||||
Task<Quote?> GetByApprovalTokenAsync(string token);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded.
|
||||
/// </summary>
|
||||
Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns aggregate stat counts and total value for the Index view stat cards, scoped to the
|
||||
/// current company by the global query filter. Pass status ID sets (derived from QuoteStatusLookup)
|
||||
/// to classify open vs. approved/converted quotes.
|
||||
/// </summary>
|
||||
Task<QuoteIndexStats> GetIndexStatsAsync(List<int> openStatusIds, List<int> approvedConvertedStatusIds);
|
||||
|
||||
/// <summary>
|
||||
/// Loads quote items with their coat passes (InventoryItem + Vendor) and prep services for
|
||||
/// PDF generation and quote→job conversion. Cheaper than <see cref="LoadForDetailsAsync"/>
|
||||
/// because it skips the parent-quote navigations that callers already have.
|
||||
/// </summary>
|
||||
Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the last quote number that starts with <paramref name="prefix"/> for the given
|
||||
/// company (including soft-deleted quotes) for sequential number generation.
|
||||
/// </summary>
|
||||
Task<string?> GetLastQuoteNumberByPrefixAsync(int companyId, string prefix);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Writes platform-wide audit trail entries. <see cref="AuditLog"/> is not a
|
||||
/// <see cref="BaseEntity"/> (no soft delete, no tenant filter), so it cannot use the generic
|
||||
/// <see cref="IRepository{T}"/>; this service provides the only write path for audit records
|
||||
/// and keeps <c>ApplicationDbContext</c> out of controller constructors.
|
||||
/// </summary>
|
||||
public interface IAuditLogService
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists <paramref name="entry"/> to the audit log immediately.
|
||||
/// </summary>
|
||||
Task LogAsync(AuditLog entry);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the most recent <paramref name="limit"/> audit log entries for the given user
|
||||
/// where <c>EntityType == "ApplicationUser"</c>, ordered newest-first. Used by the
|
||||
/// SuperAdmin user login history panel.
|
||||
/// </summary>
|
||||
Task<List<AuditLog>> GetUserActivityAsync(string userId, int limit = 50);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace PowderCoating.Core.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Destructive data-purge operations for the SuperAdmin company management UI.
|
||||
/// All methods use bulk <c>ExecuteDeleteAsync</c> against <c>ApplicationDbContext</c> directly;
|
||||
/// they are intentional exceptions to the IUnitOfWork pattern, mirroring
|
||||
/// <c>DataPurgeController</c> and <c>AccountDataExportController</c> in the documented exceptions list.
|
||||
/// </summary>
|
||||
public interface ICompanyDataPurgeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Deletes all business-data tables for <paramref name="companyId"/> but does NOT delete the
|
||||
/// company record or Identity users. The caller is responsible for deleting users via
|
||||
/// <c>UserManager</c> and the company record via <see cref="IUnitOfWork"/> after this call.
|
||||
/// <paramref name="companyUserIds"/> must be loaded beforehand so announcement-dismissal
|
||||
/// records that reference users (rather than the company directly) can be cleaned up.
|
||||
/// </summary>
|
||||
Task DeleteAllBusinessDataAsync(int companyId, IReadOnlyList<string> companyUserIds);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all business data for <paramref name="companyId"/> while preserving the company
|
||||
/// record, users, operating costs, preferences, and lookup tables. Also clears the
|
||||
/// QuickBooks migration state from <c>CompanyPreferences</c>. Used by the ResetData action.
|
||||
/// </summary>
|
||||
Task ResetBusinessDataAsync(int companyId);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wizard completion metadata surfaced in the company list view.
|
||||
/// </summary>
|
||||
public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? CompletedByName);
|
||||
|
||||
/// <summary>
|
||||
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
|
||||
/// </summary>
|
||||
public record CompanyCountSummary(
|
||||
IReadOnlyDictionary<int, int> JobCounts,
|
||||
IReadOnlyDictionary<int, int> QuoteCounts,
|
||||
IReadOnlyDictionary<int, int> CustomerCounts,
|
||||
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Read service for the SuperAdmin company list. Wraps queries that require
|
||||
/// <c>IgnoreQueryFilters()</c>, dynamic search/sort, and cross-entity GROUP BY aggregations —
|
||||
/// patterns the generic <see cref="IRepository{T}"/> cannot express.
|
||||
/// </summary>
|
||||
public interface ICompanyListService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
|
||||
/// total unfiltered count for pagination.
|
||||
/// </summary>
|
||||
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
|
||||
/// company IDs in three GROUP BY queries instead of N+1 individual lookups.
|
||||
/// </summary>
|
||||
Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result record carrying all pre-fetched entity lists and aggregates needed to render the operator
|
||||
/// dashboard index view. Raw entities are returned so the controller can apply in-memory
|
||||
/// filtering, grouping, and DTO projection without additional round-trips.
|
||||
/// </summary>
|
||||
public record DashboardIndexData(
|
||||
List<Job> ActiveJobs,
|
||||
decimal MonthlyRevenue,
|
||||
List<Appointment> TodaysAppointments,
|
||||
List<MaintenanceRecord> UpcomingMaintenance,
|
||||
List<Quote> PendingQuotes,
|
||||
List<Invoice> OpenInvoices,
|
||||
decimal InvoicedThisMonth,
|
||||
decimal CollectedThisMonth,
|
||||
List<Payment> RecentPayments,
|
||||
List<Quote> RecentQuotes,
|
||||
List<Job> RecentJobs,
|
||||
List<Job> JobsNeedingPowder,
|
||||
List<Job> JobsWithOrderedPowder,
|
||||
List<Bill> BillsDue,
|
||||
string? TipOfTheDay
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Read-only service for the dashboard. All methods execute complex queries that require
|
||||
/// ThenInclude chains or navigation-property predicates beyond what the generic
|
||||
/// <see cref="IRepository{T}"/> can express. Lives in Infrastructure so <c>ApplicationDbContext</c>
|
||||
/// is available; injected into the controller via DI.
|
||||
/// </summary>
|
||||
public interface IDashboardReadService
|
||||
{
|
||||
/// <summary>Fetches all data needed to render the tenant operator dashboard.</summary>
|
||||
/// <param name="today">The local date used for date-range predicates (today, start-of-month, etc.).</param>
|
||||
Task<DashboardIndexData> GetIndexDataAsync(DateTime today);
|
||||
|
||||
/// <summary>Returns the total count of tenant users (CompanyId > 0) for the SuperAdmin dashboard.</summary>
|
||||
Task<int> GetTotalUserCountAsync();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Moved to PowderCoating.Application.Interfaces.IFinancialReportService — Application layer owns DTO-returning service interfaces.
|
||||
namespace PowderCoating.Core.Interfaces.Services;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Moved to PowderCoating.Application.Interfaces.IOperationalReportService — Application layer owns DTO-returning service interfaces.
|
||||
namespace PowderCoating.Core.Interfaces.Services;
|
||||
Generated
+9325
File diff suppressed because it is too large
Load Diff
+132
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGuidedActivationFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "FirstInvoiceCreatedAt",
|
||||
table: "CompanyPreferences",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "FirstJobCreatedAt",
|
||||
table: "CompanyPreferences",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "FirstQuoteCreatedAt",
|
||||
table: "CompanyPreferences",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "FirstWorkflowCompleted",
|
||||
table: "CompanyPreferences",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "FirstWorkflowCompletedAt",
|
||||
table: "CompanyPreferences",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GuidedActivationDismissedAt",
|
||||
table: "CompanyPreferences",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "OnboardingPath",
|
||||
table: "CompanyPreferences",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5055));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5063));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5065));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FirstInvoiceCreatedAt",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FirstJobCreatedAt",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FirstQuoteCreatedAt",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FirstWorkflowCompleted",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FirstWorkflowCompletedAt",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GuidedActivationDismissedAt",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OnboardingPath",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1969,6 +1969,24 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("EmailNotificationsEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("FirstInvoiceCreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("FirstJobCreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("FirstQuoteCreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("FirstWorkflowCompleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("FirstWorkflowCompletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("GuidedActivationDismissedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("InAccentColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
@@ -2017,6 +2035,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("NotifyOnQuoteApproval")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("OnboardingPath")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PaymentReminderDays")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
@@ -5839,7 +5860,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921),
|
||||
CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5055),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -5850,7 +5871,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931),
|
||||
CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5063),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -5861,7 +5882,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932),
|
||||
CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5065),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="Bill"/> that provides domain-specific multi-level
|
||||
/// include queries previously expressed inline in <c>BillsController</c>.
|
||||
/// </summary>
|
||||
public class BillRepository : Repository<Bill>, IBillRepository
|
||||
{
|
||||
public BillRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Bill?> LoadForViewAsync(int id)
|
||||
{
|
||||
return await _context.Bills
|
||||
.Where(b => b.Id == id && !b.IsDeleted)
|
||||
.Include(b => b.Vendor)
|
||||
.Include(b => b.APAccount)
|
||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||
.ThenInclude(li => li.Account)
|
||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||
.ThenInclude(li => li.Job)
|
||||
.Include(b => b.Payments.Where(p => !p.IsDeleted))
|
||||
.ThenInclude(p => p.BankAccount)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Bill?> LoadForEditAsync(int id)
|
||||
{
|
||||
return await _context.Bills
|
||||
.Where(b => b.Id == id && !b.IsDeleted)
|
||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount)
|
||||
{
|
||||
var query = _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||
.Where(b => !b.IsDeleted);
|
||||
|
||||
if (statusFilter == "Unpaid")
|
||||
query = query.Where(b => b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid);
|
||||
else if (statusFilter == "Overdue")
|
||||
query = query.Where(b => b.Status != BillStatus.Paid && b.Status != BillStatus.Voided &&
|
||||
b.DueDate.HasValue && b.DueDate.Value.Date < DateTime.Today);
|
||||
|
||||
if (!string.IsNullOrEmpty(searchTerm))
|
||||
{
|
||||
var term = searchTerm;
|
||||
query = query.Where(b =>
|
||||
b.BillNumber.Contains(term) ||
|
||||
b.Vendor.CompanyName.Contains(term) ||
|
||||
(b.VendorInvoiceNumber != null && b.VendorInvoiceNumber.Contains(term)) ||
|
||||
(b.Memo != null && b.Memo.Contains(term)) ||
|
||||
b.LineItems.Any(li => li.Description.Contains(term)) ||
|
||||
(searchAmount.HasValue && (b.Total == searchAmount.Value || b.AmountPaid == searchAmount.Value)));
|
||||
}
|
||||
|
||||
return await query.OrderByDescending(b => b.BillDate).ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string?> GetLastBillNumberAsync(string prefix)
|
||||
{
|
||||
return await _context.Bills
|
||||
.IgnoreQueryFilters()
|
||||
.Where(b => b.BillNumber.StartsWith(prefix))
|
||||
.OrderByDescending(b => b.BillNumber)
|
||||
.Select(b => b.BillNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string?> GetLastPaymentNumberAsync(string prefix)
|
||||
{
|
||||
return await _context.BillPayments
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.PaymentNumber.StartsWith(prefix))
|
||||
.OrderByDescending(p => p.PaymentNumber)
|
||||
.Select(p => p.PaymentNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Bill>> GetForDateRangeAsync(DateTime start, DateTime end)
|
||||
{
|
||||
return await _context.Bills
|
||||
.Where(b => !b.IsDeleted && b.BillDate >= start && b.BillDate <= end)
|
||||
.Include(b => b.Vendor)
|
||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||
.ThenInclude(li => li.Account)
|
||||
.Include(b => b.Payments.Where(p => !p.IsDeleted))
|
||||
.OrderBy(b => b.BillDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="Customer"/> that adds domain-specific queries on top of
|
||||
/// the generic CRUD interface.
|
||||
/// </summary>
|
||||
public class CustomerRepository : Repository<Customer>, ICustomerRepository
|
||||
{
|
||||
public CustomerRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Customer?> LoadForDetailsAsync(int id)
|
||||
{
|
||||
return await _context.Customers
|
||||
.Where(c => c.Id == id && !c.IsDeleted)
|
||||
.Include(c => c.PricingTier)
|
||||
.Include(c => c.CustomerNotes.Where(n => !n.IsDeleted))
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Customer?> FindByEmailAsync(string email)
|
||||
{
|
||||
return await _context.Customers
|
||||
.FirstOrDefaultAsync(c => c.Email == email && !c.IsDeleted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="InventoryTransaction"/> that adds a dynamic-filter
|
||||
/// ledger query on top of the generic <see cref="Repository{T}"/>.
|
||||
/// </summary>
|
||||
public class InventoryTransactionRepository : Repository<InventoryTransaction>, IInventoryTransactionRepository
|
||||
{
|
||||
public InventoryTransactionRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<InventoryTransaction>> GetForLedgerAsync(
|
||||
int? itemId,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
InventoryTransactionType? type)
|
||||
{
|
||||
var query = _context.InventoryTransactions
|
||||
.AsNoTracking()
|
||||
.Include(t => t.InventoryItem)
|
||||
.Include(t => t.PurchaseOrder)
|
||||
.Include(t => t.Job)
|
||||
.Where(t => !t.IsDeleted);
|
||||
|
||||
if (itemId.HasValue)
|
||||
query = query.Where(t => t.InventoryItemId == itemId.Value);
|
||||
if (from.HasValue)
|
||||
query = query.Where(t => t.TransactionDate >= from.Value);
|
||||
if (to.HasValue)
|
||||
query = query.Where(t => t.TransactionDate < to.Value.AddDays(1));
|
||||
if (type.HasValue)
|
||||
query = query.Where(t => t.TransactionType == type.Value);
|
||||
|
||||
return await query
|
||||
.OrderByDescending(t => t.TransactionDate)
|
||||
.Take(500)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="Invoice"/> that provides domain-specific multi-level
|
||||
/// include queries previously expressed inline in <c>InvoicesController.LoadInvoiceForViewAsync</c>.
|
||||
/// </summary>
|
||||
public class InvoiceRepository : Repository<Invoice>, IInvoiceRepository
|
||||
{
|
||||
public InvoiceRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Invoice?> LoadForViewAsync(int id)
|
||||
{
|
||||
return await _context.Set<Invoice>()
|
||||
.Where(i => i.Id == id && !i.IsDeleted)
|
||||
.Include(i => i.Customer)
|
||||
.Include(i => i.Job)
|
||||
.Include(i => i.PreparedBy)
|
||||
.Include(i => i.SalesTaxAccount)
|
||||
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
|
||||
.ThenInclude(ii => ii.RevenueAccount)
|
||||
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
|
||||
.ThenInclude(ii => ii.GeneratedGiftCertificate)
|
||||
.Include(i => i.Payments.Where(p => !p.IsDeleted))
|
||||
.ThenInclude(p => p.RecordedBy)
|
||||
.Include(i => i.Payments.Where(p => !p.IsDeleted))
|
||||
.ThenInclude(p => p.DepositAccount)
|
||||
.Include(i => i.Refunds.Where(r => !r.IsDeleted))
|
||||
.ThenInclude(r => r.IssuedBy)
|
||||
.Include(i => i.CreditApplications.Where(ca => !ca.IsDeleted))
|
||||
.ThenInclude(ca => ca.CreditMemo)
|
||||
.Include(i => i.GiftCertificateRedemptions)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Invoice?> GetForJobAsync(int jobId, bool includeDeleted = false)
|
||||
{
|
||||
var query = _context.Set<Invoice>().Where(i => i.JobId == jobId);
|
||||
if (!includeDeleted)
|
||||
query = query.Where(i => !i.IsDeleted);
|
||||
else
|
||||
query = query.IgnoreQueryFilters().Where(i => i.JobId == jobId);
|
||||
return await query.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Invoice?> GetByPaymentTokenAsync(string token)
|
||||
{
|
||||
return await _context.Set<Invoice>()
|
||||
.IgnoreQueryFilters()
|
||||
.Include(i => i.Customer)
|
||||
.Include(i => i.Job)
|
||||
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
|
||||
.FirstOrDefaultAsync(i => i.PaymentLinkToken == token && !i.IsDeleted);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string?> GetLastInvoiceNumberByPrefixAsync(int companyId, string prefix)
|
||||
{
|
||||
return await _context.Set<Invoice>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(i => i.CompanyId == companyId && i.InvoiceNumber.StartsWith(prefix))
|
||||
.OrderByDescending(i => i.InvoiceNumber)
|
||||
.Select(i => i.InvoiceNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Invoice>> GetOnlineInvoicesForPeriodAsync(int companyId, DateTime from, DateTime to)
|
||||
{
|
||||
return await _context.Set<Invoice>()
|
||||
.AsNoTracking()
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => !i.IsDeleted
|
||||
&& i.CompanyId == companyId
|
||||
&& i.OnlineAmountPaid > 0
|
||||
&& i.UpdatedAt >= from
|
||||
&& i.UpdatedAt < to)
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Refund>> GetOnlineRefundsForPeriodAsync(int companyId, DateTime from, DateTime to)
|
||||
{
|
||||
return await _context.Set<Refund>()
|
||||
.AsNoTracking()
|
||||
.Include(r => r.Invoice).ThenInclude(inv => inv!.Customer)
|
||||
.Where(r => !r.IsDeleted
|
||||
&& r.CompanyId == companyId
|
||||
&& r.RefundMethod == PaymentMethod.CreditDebitCard
|
||||
&& r.RefundDate >= from
|
||||
&& r.RefundDate < to)
|
||||
.OrderByDescending(r => r.RefundDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="JobItemCoat"/> that adds ThenInclude-based load methods the
|
||||
/// generic <see cref="Repository{T}"/> cannot express.
|
||||
/// </summary>
|
||||
public class JobItemCoatRepository : Repository<JobItemCoat>, IJobItemCoatRepository
|
||||
{
|
||||
public JobItemCoatRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<JobItemCoat?> LoadForOrderMarkingAsync(int id)
|
||||
{
|
||||
return await _context.JobItemCoats
|
||||
.Include(c => c.JobItem).ThenInclude(i => i.Job).ThenInclude(j => j.Customer)
|
||||
.Include(c => c.Vendor)
|
||||
.Include(c => c.InventoryItem).ThenInclude(i => i!.PrimaryVendor)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<JobItemCoat?> LoadWithInventoryAsync(int id)
|
||||
{
|
||||
return await _context.JobItemCoats
|
||||
.Include(c => c.InventoryItem)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<JobItemCoat?> LoadWithJobChainAsync(int id)
|
||||
{
|
||||
return await _context.JobItemCoats
|
||||
.Include(c => c.JobItem).ThenInclude(i => i.Job)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<JobItemCoat>> GetCandidateCoatsForLinkingAsync(int excludeCoatId, int companyId)
|
||||
{
|
||||
return await _context.JobItemCoats
|
||||
.Include(c => c.JobItem)
|
||||
.Where(c => c.Id != excludeCoatId
|
||||
&& c.InventoryItemId == null
|
||||
&& c.JobItem.CompanyId == companyId)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="JobPhoto"/> that provides inventory-specific photo
|
||||
/// lookup queries requiring multi-level ThenInclude chains that the generic
|
||||
/// <see cref="Repository{T}"/> cannot express.
|
||||
/// </summary>
|
||||
public class JobPhotoRepository : Repository<JobPhoto>, IJobPhotoRepository
|
||||
{
|
||||
public JobPhotoRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<JobPhoto>> GetTaggedPhotosAsync(string? colorName, string? itemName)
|
||||
{
|
||||
var query = _context.JobPhotos
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Job)
|
||||
.ThenInclude(j => j!.Customer)
|
||||
.Where(p => !p.IsDeleted && p.Tags != null && p.Tags != "");
|
||||
|
||||
if (!string.IsNullOrEmpty(colorName) && !string.IsNullOrEmpty(itemName) && colorName != itemName)
|
||||
query = query.Where(p => p.Tags!.ToLower().Contains(colorName) || p.Tags!.ToLower().Contains(itemName));
|
||||
else if (!string.IsNullOrEmpty(colorName))
|
||||
query = query.Where(p => p.Tags!.ToLower().Contains(colorName));
|
||||
else if (!string.IsNullOrEmpty(itemName))
|
||||
query = query.Where(p => p.Tags!.ToLower().Contains(itemName));
|
||||
|
||||
return await query.OrderByDescending(p => p.UploadedDate).ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<JobPhoto>> GetPhotosByPowderItemAsync(int inventoryItemId)
|
||||
{
|
||||
return await _context.JobPhotos
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Job)
|
||||
.ThenInclude(j => j!.Customer)
|
||||
.Include(p => p.Job)
|
||||
.ThenInclude(j => j!.JobItems)
|
||||
.ThenInclude(ji => ji.Coats)
|
||||
.Where(p => !p.IsDeleted &&
|
||||
p.Job != null &&
|
||||
p.Job.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == inventoryItemId)))
|
||||
.OrderByDescending(p => p.UploadedDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="Job"/> that provides domain-specific multi-level
|
||||
/// include queries that the generic <see cref="Repository{T}"/> cannot express.
|
||||
/// The base class handles all standard CRUD operations; this class adds read queries
|
||||
/// that were previously scattered as inline EF expressions inside controllers.
|
||||
/// </summary>
|
||||
public class JobRepository : Repository<Job>, IJobRepository
|
||||
{
|
||||
public JobRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Job>> GetBoardJobsAsync()
|
||||
{
|
||||
return await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Where(j => !j.IsDeleted)
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Include(j => j.AssignedUser)
|
||||
.OrderBy(j => j.DueDate.HasValue ? 0 : 1)
|
||||
.ThenBy(j => j.DueDate)
|
||||
.ThenBy(j => j.JobPriority!.DisplayOrder)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Job?> LoadForDetailsAsync(int id)
|
||||
{
|
||||
// Single query replaces the per-item N+1 loop that was in JobsController.Details.
|
||||
// EF Core splits the multi-level ThenIncludes across two SQL queries automatically
|
||||
// (split query behavior), keeping result set size manageable.
|
||||
return await _context.Jobs
|
||||
.Where(j => j.Id == id && !j.IsDeleted)
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Include(j => j.AssignedUser)
|
||||
.Include(j => j.Quote)
|
||||
.Include(j => j.OvenCost)
|
||||
.Include(j => j.OriginalJob)
|
||||
.Include(j => j.IntakeCheckedBy)
|
||||
.Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
|
||||
.ThenInclude(ji => ji.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
|
||||
.ThenInclude(ji => ji.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
|
||||
.ThenInclude(ji => ji.PrepServices)
|
||||
.ThenInclude(ps => ps.PrepService)
|
||||
.Include(j => j.JobPrepServices.Where(jps => !jps.IsDeleted))
|
||||
.ThenInclude(jps => jps.PrepService)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Job?> LoadForEditAsync(int id)
|
||||
{
|
||||
return await _context.Jobs
|
||||
.Where(j => j.Id == id && !j.IsDeleted)
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Include(j => j.AssignedUser)
|
||||
.Include(j => j.OvenCost)
|
||||
.Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
|
||||
.ThenInclude(ji => ji.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
|
||||
.ThenInclude(ji => ji.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
|
||||
.ThenInclude(ji => ji.PrepServices)
|
||||
.ThenInclude(ps => ps.PrepService)
|
||||
.Include(j => j.JobPrepServices.Where(jps => !jps.IsDeleted))
|
||||
.ThenInclude(jps => jps.PrepService)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Job?> LoadForStatusChangeAsync(int id)
|
||||
{
|
||||
return await _context.Jobs
|
||||
.Include(j => j.JobStatus)
|
||||
.FirstOrDefaultAsync(j => j.Id == id && !j.IsDeleted);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId)
|
||||
{
|
||||
return await _context.JobChangeHistories
|
||||
.Where(h => h.JobId == jobId && !h.IsDeleted)
|
||||
.Include(h => h.ChangedBy)
|
||||
.OrderByDescending(h => h.ChangedAt)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string?> GetLastJobNumberByPrefixAsync(int companyId, string prefix)
|
||||
{
|
||||
return await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => j.CompanyId == companyId && j.JobNumber.StartsWith(prefix))
|
||||
.OrderByDescending(j => j.JobNumber)
|
||||
.Select(j => j.JobNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Job?> GetOrphanedConversionJobAsync(int quoteId, int companyId)
|
||||
{
|
||||
return await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(j => j.QuoteId == quoteId && j.CompanyId == companyId);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Job>> GetScheduledJobsForDateAsync(DateTime date, string? userId = null)
|
||||
{
|
||||
var query = _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Include(j => j.AssignedUser)
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats)
|
||||
.Where(j => j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == date.Date
|
||||
&& !j.IsDeleted && !j.JobStatus.IsTerminalStatus);
|
||||
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
query = query.Where(j => j.AssignedUserId == userId);
|
||||
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Job>> GetActiveJobsForMobileAsync(int companyId, string? workerId = null)
|
||||
{
|
||||
var query = _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Include(j => j.AssignedUser)
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats)
|
||||
.Where(j => j.CompanyId == companyId && !j.IsDeleted && !j.JobStatus.IsTerminalStatus
|
||||
&& j.JobStatus.StatusCode != "ON_HOLD" && j.JobStatus.StatusCode != "CANCELLED");
|
||||
|
||||
if (!string.IsNullOrEmpty(workerId))
|
||||
query = query.Where(j => j.AssignedUserId == workerId);
|
||||
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Job?> LoadForCostingAsync(int jobId, int companyId)
|
||||
{
|
||||
return await _context.Jobs
|
||||
.Where(j => j.Id == jobId && j.CompanyId == companyId && !j.IsDeleted)
|
||||
.Include(j => j.OvenCost)
|
||||
.Include(j => j.Invoice)
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
|
||||
.ThenInclude(t => t.Worker)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Job?> LoadForTemplateSnapshotAsync(int jobId)
|
||||
{
|
||||
return await _context.Jobs
|
||||
.Where(j => j.Id == jobId && !j.IsDeleted)
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="JobTemplate"/> that provides domain-specific multi-level
|
||||
/// include queries that the generic <see cref="Repository{T}"/> cannot express.
|
||||
/// The base class handles all standard CRUD operations; this class adds the read queries
|
||||
/// that require ThenInclude chains for items, coats, and prep services.
|
||||
/// </summary>
|
||||
public class JobTemplateRepository : Repository<JobTemplate>, IJobTemplateRepository
|
||||
{
|
||||
public JobTemplateRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<JobTemplate?> LoadForDetailsAsync(int id)
|
||||
{
|
||||
return await _context.JobTemplates
|
||||
.Where(t => t.Id == id && !t.IsDeleted)
|
||||
.Include(t => t.Customer)
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
|
||||
.ThenInclude(p => p.PrepService)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<JobTemplate>> GetAllActiveWithFullIncludesAsync()
|
||||
{
|
||||
return await _context.JobTemplates
|
||||
.Where(t => !t.IsDeleted && t.IsActive)
|
||||
.Include(t => t.Customer)
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
|
||||
.ThenInclude(p => p.PrepService)
|
||||
.OrderBy(t => t.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="NotificationLog"/> that provides IgnoreQueryFilters-based
|
||||
/// lookups by entity FK (InvoiceId, QuoteId, JobId).
|
||||
/// </summary>
|
||||
public class NotificationLogRepository : Repository<NotificationLog>, INotificationLogRepository
|
||||
{
|
||||
public NotificationLogRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId) =>
|
||||
await _context.Set<NotificationLog>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.InvoiceId == invoiceId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId) =>
|
||||
await _context.Set<NotificationLog>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.InvoiceId == invoiceId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.ToListAsync();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId) =>
|
||||
await _context.Set<NotificationLog>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.QuoteId == quoteId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId) =>
|
||||
await _context.Set<NotificationLog>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.QuoteId == quoteId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.ToListAsync();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<NotificationLog?> GetLatestForJobAsync(int jobId) =>
|
||||
await _context.Set<NotificationLog>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.JobId == jobId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<NotificationLog>> GetAllForJobAsync(int jobId) =>
|
||||
await _context.Set<NotificationLog>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.JobId == jobId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.ToListAsync();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(List<NotificationLog> Items, int TotalCount)> GetPagedFilteredAsync(
|
||||
int pageNumber, int pageSize,
|
||||
string? searchTerm = null,
|
||||
NotificationChannel? channel = null,
|
||||
NotificationStatus? status = null,
|
||||
NotificationType? type = null,
|
||||
int? jobId = null,
|
||||
string sortColumn = "SentAt",
|
||||
string sortDirection = "desc")
|
||||
{
|
||||
var query = _dbSet
|
||||
.AsNoTracking()
|
||||
.Include(n => n.Customer)
|
||||
.Include(n => n.Job)
|
||||
.Include(n => n.Quote)
|
||||
.AsQueryable();
|
||||
|
||||
if (jobId.HasValue)
|
||||
query = query.Where(n => n.JobId == jobId.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var s = searchTerm.ToLower();
|
||||
query = query.Where(n =>
|
||||
n.RecipientName.ToLower().Contains(s) ||
|
||||
n.Recipient.ToLower().Contains(s) ||
|
||||
(n.Subject != null && n.Subject.ToLower().Contains(s)) ||
|
||||
(n.Job != null && n.Job.JobNumber.ToLower().Contains(s)) ||
|
||||
(n.Quote != null && n.Quote.QuoteNumber.ToLower().Contains(s)));
|
||||
}
|
||||
|
||||
if (channel.HasValue)
|
||||
query = query.Where(n => n.Channel == channel.Value);
|
||||
|
||||
if (status.HasValue)
|
||||
query = query.Where(n => n.Status == status.Value);
|
||||
|
||||
if (type.HasValue)
|
||||
query = query.Where(n => n.NotificationType == type.Value);
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
query = (sortColumn, sortDirection) switch
|
||||
{
|
||||
("RecipientName", "asc") => query.OrderBy(n => n.RecipientName),
|
||||
("RecipientName", _) => query.OrderByDescending(n => n.RecipientName),
|
||||
("Channel", "asc") => query.OrderBy(n => n.Channel),
|
||||
("Channel", _) => query.OrderByDescending(n => n.Channel),
|
||||
("Status", "asc") => query.OrderBy(n => n.Status),
|
||||
("Status", _) => query.OrderByDescending(n => n.Status),
|
||||
("Type", "asc") => query.OrderBy(n => n.NotificationType),
|
||||
("Type", _) => query.OrderByDescending(n => n.NotificationType),
|
||||
(_, "asc") => query.OrderBy(n => n.SentAt),
|
||||
_ => query.OrderByDescending(n => n.SentAt)
|
||||
};
|
||||
|
||||
var items = await query
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return (items, totalCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Linq.Expressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Generic repository for platform-level entities that do not inherit BaseEntity
|
||||
/// (Announcement, BannedIp, DashboardTip, ReleaseNote). No global query filters apply
|
||||
/// to these entities, so no IgnoreQueryFilters support is needed. All writes are staged
|
||||
/// in the EF change tracker — call IUnitOfWork.CompleteAsync() to flush.
|
||||
/// </summary>
|
||||
public class PlainRepository<T> : IPlainRepository<T> where T : class
|
||||
{
|
||||
protected readonly ApplicationDbContext _context;
|
||||
protected readonly DbSet<T> _dbSet;
|
||||
|
||||
public PlainRepository(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
_dbSet = context.Set<T>();
|
||||
}
|
||||
|
||||
public virtual async Task<T?> GetByIdAsync(int id)
|
||||
=> await _dbSet.FindAsync(id);
|
||||
|
||||
public virtual async Task<IEnumerable<T>> GetAllAsync()
|
||||
=> await _dbSet.ToListAsync();
|
||||
|
||||
public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate)
|
||||
=> await _dbSet.Where(predicate).ToListAsync();
|
||||
|
||||
public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate)
|
||||
=> await _dbSet.FirstOrDefaultAsync(predicate);
|
||||
|
||||
public virtual async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate)
|
||||
=> await _dbSet.AnyAsync(predicate);
|
||||
|
||||
public virtual async Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null)
|
||||
=> predicate == null ? await _dbSet.CountAsync() : await _dbSet.CountAsync(predicate);
|
||||
|
||||
public virtual async Task<T> AddAsync(T entity)
|
||||
{
|
||||
await _dbSet.AddAsync(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
public virtual async Task<IEnumerable<T>> AddRangeAsync(IEnumerable<T> entities)
|
||||
{
|
||||
await _dbSet.AddRangeAsync(entities);
|
||||
return entities;
|
||||
}
|
||||
|
||||
public virtual Task UpdateAsync(T entity)
|
||||
{
|
||||
_dbSet.Update(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual async Task DeleteAsync(T entity)
|
||||
{
|
||||
_dbSet.Remove(entity);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual async Task DeleteAsync(int id)
|
||||
{
|
||||
var entity = await GetByIdAsync(id);
|
||||
if (entity != null)
|
||||
await DeleteAsync(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="PowderUsageLog"/> that adds a dynamic-filter
|
||||
/// ledger query on top of the generic <see cref="Repository{T}"/>.
|
||||
/// </summary>
|
||||
public class PowderUsageLogRepository : Repository<PowderUsageLog>, IPowderUsageLogRepository
|
||||
{
|
||||
public PowderUsageLogRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<PowderUsageLog>> GetForLedgerAsync(int? itemId, DateTime? from, DateTime? to)
|
||||
{
|
||||
var query = _context.PowderUsageLogs
|
||||
.AsNoTracking()
|
||||
.Include(u => u.Job)
|
||||
.ThenInclude(j => j!.Customer)
|
||||
.Include(u => u.InventoryItem)
|
||||
.Include(u => u.JobItemCoat)
|
||||
.Where(u => !u.IsDeleted);
|
||||
|
||||
if (itemId.HasValue)
|
||||
query = query.Where(u => u.InventoryItemId == itemId.Value);
|
||||
if (from.HasValue)
|
||||
query = query.Where(u => u.RecordedAt >= from.Value);
|
||||
if (to.HasValue)
|
||||
query = query.Where(u => u.RecordedAt < to.Value.AddDays(1));
|
||||
|
||||
return await query
|
||||
.OrderByDescending(u => u.RecordedAt)
|
||||
.Take(500)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="PurchaseOrder"/> that provides domain-specific queries
|
||||
/// previously expressed inline in <c>PurchaseOrdersController</c>.
|
||||
/// </summary>
|
||||
public class PurchaseOrderRepository : Repository<PurchaseOrder>, IPurchaseOrderRepository
|
||||
{
|
||||
public PurchaseOrderRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<PurchaseOrder?> LoadForViewAsync(int id, int companyId)
|
||||
{
|
||||
return await _context.Set<PurchaseOrder>()
|
||||
.Where(p => p.Id == id && !p.IsDeleted && p.CompanyId == companyId)
|
||||
.Include(p => p.Vendor)
|
||||
.Include(p => p.Bill)
|
||||
.Include(p => p.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.InventoryItem)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<PurchaseOrderStats> GetStatsAsync(int companyId)
|
||||
{
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var items = await _context.Set<PurchaseOrder>()
|
||||
.Where(po => !po.IsDeleted && po.CompanyId == companyId)
|
||||
.Select(po => new { po.Status, po.TotalAmount, po.ExpectedDeliveryDate })
|
||||
.ToListAsync();
|
||||
|
||||
return new PurchaseOrderStats(
|
||||
TotalCount: items.Count,
|
||||
OpenCount: items.Count(p =>
|
||||
p.Status == PurchaseOrderStatus.Draft ||
|
||||
p.Status == PurchaseOrderStatus.Submitted ||
|
||||
p.Status == PurchaseOrderStatus.PartiallyReceived),
|
||||
CommittedValue: items.Where(p => p.Status != PurchaseOrderStatus.Cancelled).Sum(p => p.TotalAmount),
|
||||
OverdueCount: items.Count(p =>
|
||||
(p.Status == PurchaseOrderStatus.Draft || p.Status == PurchaseOrderStatus.Submitted || p.Status == PurchaseOrderStatus.PartiallyReceived) &&
|
||||
p.ExpectedDeliveryDate.HasValue &&
|
||||
p.ExpectedDeliveryDate.Value.Date < today)
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(List<PurchaseOrder> Items, int TotalCount)> GetPagedAsync(
|
||||
int companyId,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
PurchaseOrderStatus? statusFilter = null,
|
||||
int? vendorId = null,
|
||||
DateTime? dateFrom = null,
|
||||
DateTime? dateTo = null,
|
||||
string? searchTerm = null,
|
||||
string? sortColumn = null,
|
||||
string? sortDirection = null)
|
||||
{
|
||||
var query = _context.Set<PurchaseOrder>()
|
||||
.Include(po => po.Vendor)
|
||||
.Include(po => po.Items.Where(i => !i.IsDeleted))
|
||||
.Where(po => !po.IsDeleted && po.CompanyId == companyId)
|
||||
.AsQueryable();
|
||||
|
||||
if (statusFilter.HasValue)
|
||||
query = query.Where(po => po.Status == statusFilter.Value);
|
||||
|
||||
if (vendorId.HasValue)
|
||||
query = query.Where(po => po.VendorId == vendorId.Value);
|
||||
|
||||
if (dateFrom.HasValue)
|
||||
query = query.Where(po => po.OrderDate >= dateFrom.Value);
|
||||
|
||||
if (dateTo.HasValue)
|
||||
query = query.Where(po => po.OrderDate <= dateTo.Value.AddDays(1));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var term = searchTerm.Trim().ToLower();
|
||||
query = query.Where(po =>
|
||||
po.PoNumber.ToLower().Contains(term) ||
|
||||
po.Vendor.CompanyName.ToLower().Contains(term) ||
|
||||
(po.Notes != null && po.Notes.ToLower().Contains(term)));
|
||||
}
|
||||
|
||||
query = (sortColumn?.ToLower(), sortDirection?.ToLower()) switch
|
||||
{
|
||||
("ponumber", "asc") => query.OrderBy(po => po.PoNumber),
|
||||
("ponumber", _) => query.OrderByDescending(po => po.PoNumber),
|
||||
("vendor", "asc") => query.OrderBy(po => po.Vendor.CompanyName),
|
||||
("vendor", _) => query.OrderByDescending(po => po.Vendor.CompanyName),
|
||||
("status", "asc") => query.OrderBy(po => po.Status),
|
||||
("status", _) => query.OrderByDescending(po => po.Status),
|
||||
("orderdate", "asc") => query.OrderBy(po => po.OrderDate),
|
||||
("orderdate", _) => query.OrderByDescending(po => po.OrderDate),
|
||||
("expected", "asc") => query.OrderBy(po => po.ExpectedDeliveryDate),
|
||||
("expected", _) => query.OrderByDescending(po => po.ExpectedDeliveryDate),
|
||||
("total", "asc") => query.OrderBy(po => po.TotalAmount),
|
||||
("total", _) => query.OrderByDescending(po => po.TotalAmount),
|
||||
_ => query.OrderByDescending(po => po.OrderDate)
|
||||
};
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var items = await query
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return (items, totalCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Typed repository for <see cref="Quote"/> that provides domain-specific queries previously
|
||||
/// scattered as inline EF expressions across <c>QuotesController</c> and
|
||||
/// <c>QuoteApprovalController</c>.
|
||||
/// </summary>
|
||||
public class QuoteRepository : Repository<Quote>, IQuoteRepository
|
||||
{
|
||||
public QuoteRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Quote?> LoadForDetailsAsync(int id)
|
||||
{
|
||||
var quote = await _context.Quotes
|
||||
.Where(q => q.Id == id && !q.IsDeleted)
|
||||
.Include(q => q.Customer)
|
||||
.Include(q => q.PreparedBy)
|
||||
.Include(q => q.QuoteStatus)
|
||||
.Include(q => q.OvenCost)
|
||||
.Include(q => q.QuotePrepServices.Where(qps => !qps.IsDeleted))
|
||||
.ThenInclude(qps => qps.PrepService)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (quote == null) return null;
|
||||
|
||||
// QuoteItems with nested coats and prep services loaded separately to avoid
|
||||
// cartesian explosion from multiple collection includes in a single query.
|
||||
quote.QuoteItems = await _context.QuoteItems
|
||||
.Where(qi => qi.QuoteId == id && !qi.IsDeleted)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Include(qi => qi.CatalogItem)
|
||||
.Include(qi => qi.PrepServices)
|
||||
.ThenInclude(ps => ps.PrepService)
|
||||
.ToListAsync();
|
||||
|
||||
return quote;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Quote?> GetByApprovalTokenAsync(string token)
|
||||
{
|
||||
// IgnoreQueryFilters: approval portal is unauthenticated — no tenant context on the request.
|
||||
return await _context.Quotes
|
||||
.IgnoreQueryFilters()
|
||||
.Include(q => q.Customer)
|
||||
.Include(q => q.QuoteStatus)
|
||||
.Include(q => q.QuoteItems.Where(qi => !qi.IsDeleted))
|
||||
.FirstOrDefaultAsync(q => q.ApprovalToken == token && !q.IsDeleted);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId)
|
||||
{
|
||||
return await _context.QuoteChangeHistories
|
||||
.Where(h => h.QuoteId == quoteId && !h.IsDeleted)
|
||||
.Include(h => h.ChangedBy)
|
||||
.OrderByDescending(h => h.ChangedAt)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<QuoteIndexStats> GetIndexStatsAsync(List<int> openStatusIds, List<int> approvedConvertedStatusIds)
|
||||
{
|
||||
var stats = await _context.Quotes
|
||||
.Where(q => !q.IsDeleted)
|
||||
.Select(q => new { q.QuoteStatusId, q.Total })
|
||||
.ToListAsync();
|
||||
|
||||
return new QuoteIndexStats(
|
||||
OpenCount: stats.Count(q => openStatusIds.Contains(q.QuoteStatusId)),
|
||||
ApprovedConvertedCount: stats.Count(q => approvedConvertedStatusIds.Contains(q.QuoteStatusId)),
|
||||
TotalValue: stats.Sum(q => q.Total));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId)
|
||||
{
|
||||
return await _context.QuoteItems
|
||||
.Where(qi => qi.QuoteId == quoteId && !qi.IsDeleted)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Include(qi => qi.CatalogItem)
|
||||
.Include(qi => qi.PrepServices)
|
||||
.ThenInclude(ps => ps.PrepService)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string?> GetLastQuoteNumberByPrefixAsync(int companyId, string prefix)
|
||||
{
|
||||
return await _context.Quotes
|
||||
.IgnoreQueryFilters()
|
||||
.Where(q => q.CompanyId == companyId && q.QuoteNumber.StartsWith(prefix))
|
||||
.OrderByDescending(q => q.QuoteNumber)
|
||||
.Select(q => q.QuoteNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
@@ -40,29 +41,31 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<AiItemPrediction>? _aiItemPredictions;
|
||||
|
||||
// Powder Insights
|
||||
private IRepository<PowderUsageLog>? _powderUsageLogs;
|
||||
private IPowderUsageLogRepository? _powderUsageLogs;
|
||||
|
||||
// Core repositories
|
||||
private IRepository<Customer>? _customers;
|
||||
private IRepository<Job>? _jobs;
|
||||
private ICustomerRepository? _customers;
|
||||
private IJobRepository? _jobs;
|
||||
private IRepository<JobDailyPriority>? _jobDailyPriorities;
|
||||
private IRepository<JobItem>? _jobItems;
|
||||
private IRepository<JobItemCoat>? _jobItemCoats;
|
||||
private IJobItemCoatRepository? _jobItemCoats;
|
||||
private IRepository<JobItemPrepService>? _jobItemPrepServices;
|
||||
private IRepository<JobChangeHistory>? _jobChangeHistories;
|
||||
private IRepository<Quote>? _quotes;
|
||||
private IRepository<JobPrepService>? _jobPrepServices;
|
||||
private IQuoteRepository? _quotes;
|
||||
private IRepository<QuotePhoto>? _quotePhotos;
|
||||
private IRepository<QuoteItem>? _quoteItems;
|
||||
private IRepository<QuoteItemCoat>? _quoteItemCoats;
|
||||
private IRepository<QuoteItemPrepService>? _quoteItemPrepServices;
|
||||
private IRepository<QuoteChangeHistory>? _quoteChangeHistories;
|
||||
private IRepository<InventoryItem>? _inventoryItems;
|
||||
private IRepository<InventoryTransaction>? _inventoryTransactions;
|
||||
private IInventoryTransactionRepository? _inventoryTransactions;
|
||||
private IRepository<Equipment>? _equipment;
|
||||
private IRepository<OvenCost>? _ovenCosts;
|
||||
private IRepository<CompanyBlastSetup>? _blastSetups;
|
||||
private IRepository<MaintenanceRecord>? _maintenanceRecords;
|
||||
private IRepository<Vendor>? _vendors;
|
||||
private IRepository<JobPhoto>? _jobPhotos;
|
||||
private IJobPhotoRepository? _jobPhotos;
|
||||
private IRepository<JobNote>? _jobNotes;
|
||||
private IRepository<CustomerNote>? _customerNotes;
|
||||
private IRepository<JobStatusHistory>? _jobStatusHistory;
|
||||
@@ -87,20 +90,28 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<CatalogPriceCheckReport>? _catalogPriceCheckReports;
|
||||
|
||||
// Notifications
|
||||
private IRepository<NotificationLog>? _notificationLogs;
|
||||
private INotificationLogRepository? _notificationLogs;
|
||||
private IRepository<NotificationTemplate>? _notificationTemplates;
|
||||
|
||||
// Subscription
|
||||
private IRepository<SubscriptionPlanConfig>? _subscriptionPlanConfigs;
|
||||
|
||||
// Job Templates
|
||||
private IRepository<JobTemplate>? _jobTemplates;
|
||||
private IJobTemplateRepository? _jobTemplates;
|
||||
private IRepository<JobTemplateItem>? _jobTemplateItems;
|
||||
private IRepository<JobTemplateItemCoat>? _jobTemplateItemCoats;
|
||||
private IRepository<JobTemplateItemPrepService>? _jobTemplateItemPrepServices;
|
||||
|
||||
// Platform content
|
||||
private IPlainRepository<Announcement>? _announcements;
|
||||
private IPlainRepository<BannedIp>? _bannedIps;
|
||||
private IPlainRepository<DashboardTip>? _dashboardTips;
|
||||
private IRepository<InAppNotification>? _inAppNotifications;
|
||||
private IPlainRepository<ReleaseNote>? _releaseNotes;
|
||||
|
||||
// Bug Reports
|
||||
private IRepository<BugReport>? _bugReports;
|
||||
private IRepository<BugReportAttachment>? _bugReportAttachments;
|
||||
private IRepository<ContactSubmission>? _contactSubmissions;
|
||||
private IRepository<ManufacturerLookupPattern>? _manufacturerLookupPatterns;
|
||||
|
||||
@@ -109,7 +120,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
|
||||
|
||||
// Purchase Orders
|
||||
private IRepository<PurchaseOrder>? _purchaseOrders;
|
||||
private IPurchaseOrderRepository? _purchaseOrders;
|
||||
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
||||
|
||||
// Oven Scheduling
|
||||
@@ -117,14 +128,14 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<OvenBatchItem>? _ovenBatchItems;
|
||||
|
||||
// Invoices, Payments & Deposits
|
||||
private IRepository<Invoice>? _invoices;
|
||||
private IInvoiceRepository? _invoices;
|
||||
private IRepository<InvoiceItem>? _invoiceItems;
|
||||
private IRepository<Payment>? _payments;
|
||||
private IRepository<Deposit>? _deposits;
|
||||
|
||||
// Expense Tracking / Accounts Payable
|
||||
private IRepository<Account>? _accounts;
|
||||
private IRepository<Bill>? _bills;
|
||||
private IBillRepository? _bills;
|
||||
private IRepository<BillLineItem>? _billLineItems;
|
||||
private IRepository<BillPayment>? _billPayments;
|
||||
private IRepository<Expense>? _expenses;
|
||||
@@ -166,17 +177,17 @@ public class UnitOfWork : IUnitOfWork
|
||||
|
||||
// Powder Insights
|
||||
/// <summary>Repository for <see cref="PowderUsageLog"/> records capturing per-coat powder consumption; used by powder-usage analytics.</summary>
|
||||
public IRepository<PowderUsageLog> PowderUsageLogs =>
|
||||
_powderUsageLogs ??= new Repository<PowderUsageLog>(_context);
|
||||
public IPowderUsageLogRepository PowderUsageLogs =>
|
||||
_powderUsageLogs ??= new PowderUsageLogRepository(_context);
|
||||
|
||||
// Core repositories
|
||||
/// <summary>Repository for <see cref="Customer"/> records (commercial and non-commercial); tenant-filtered with soft delete.</summary>
|
||||
public IRepository<Customer> Customers =>
|
||||
_customers ??= new Repository<Customer>(_context);
|
||||
public ICustomerRepository Customers =>
|
||||
_customers ??= new CustomerRepository(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="Job"/> records progressing through the 16-status lifecycle; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<Job> Jobs =>
|
||||
_jobs ??= new Repository<Job>(_context);
|
||||
public IJobRepository Jobs =>
|
||||
_jobs ??= new JobRepository(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="JobDailyPriority"/> overrides that let supervisors re-order the shop floor queue.</summary>
|
||||
public IRepository<JobDailyPriority> JobDailyPriorities =>
|
||||
@@ -187,16 +198,22 @@ public class UnitOfWork : IUnitOfWork
|
||||
_jobItems ??= new Repository<JobItem>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="JobItemCoat"/> powder coat passes; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<JobItemCoat> JobItemCoats =>
|
||||
_jobItemCoats ??= new Repository<JobItemCoat>(_context);
|
||||
public IJobItemCoatRepository JobItemCoats =>
|
||||
_jobItemCoats ??= new JobItemCoatRepository(_context);
|
||||
public IRepository<JobItemPrepService> JobItemPrepServices =>
|
||||
_jobItemPrepServices ??= new Repository<JobItemPrepService>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="JobChangeHistory"/> audit entries; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<JobChangeHistory> JobChangeHistories =>
|
||||
_jobChangeHistories ??= new Repository<JobChangeHistory>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="JobPrepService"/> job-level prep service assignments; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<JobPrepService> JobPrepServices =>
|
||||
_jobPrepServices ??= new Repository<JobPrepService>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="Quote"/> records with multi-item pricing; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<Quote> Quotes =>
|
||||
_quotes ??= new Repository<Quote>(_context);
|
||||
public IQuoteRepository Quotes =>
|
||||
_quotes ??= new QuoteRepository(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="QuotePhoto"/> AI photo uploads; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<QuotePhoto> QuotePhotos =>
|
||||
@@ -223,8 +240,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
_inventoryItems ??= new Repository<InventoryItem>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="InventoryTransaction"/> stock movements; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<InventoryTransaction> InventoryTransactions =>
|
||||
_inventoryTransactions ??= new Repository<InventoryTransaction>(_context);
|
||||
public IInventoryTransactionRepository InventoryTransactions =>
|
||||
_inventoryTransactions ??= new InventoryTransactionRepository(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="Equipment"/> records (ovens, sandblasters, booths); tenant-filtered with soft delete.</summary>
|
||||
public IRepository<Equipment> Equipment =>
|
||||
@@ -247,8 +264,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
_vendors ??= new Repository<Vendor>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="JobPhoto"/> attachments; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<JobPhoto> JobPhotos =>
|
||||
_jobPhotos ??= new Repository<JobPhoto>(_context);
|
||||
public IJobPhotoRepository JobPhotos =>
|
||||
_jobPhotos ??= new JobPhotoRepository(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="JobNote"/> free-text staff notes on jobs; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<JobNote> JobNotes =>
|
||||
@@ -350,9 +367,9 @@ public class UnitOfWork : IUnitOfWork
|
||||
_catalogPriceCheckReports ??= new Repository<CatalogPriceCheckReport>(_context);
|
||||
|
||||
// Notifications
|
||||
/// <summary>Repository for <see cref="NotificationLog"/> outbound notification audit records; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<NotificationLog> NotificationLogs =>
|
||||
_notificationLogs ??= new Repository<NotificationLog>(_context);
|
||||
/// <summary>Repository for <see cref="NotificationLog"/> outbound notification audit records; provides IgnoreQueryFilters lookups by InvoiceId, QuoteId, and JobId for notification history panels.</summary>
|
||||
public INotificationLogRepository NotificationLogs =>
|
||||
_notificationLogs ??= new NotificationLogRepository(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="NotificationTemplate"/> per-company channel template overrides; unique on (CompanyId, Type, Channel).</summary>
|
||||
public IRepository<NotificationTemplate> NotificationTemplates =>
|
||||
@@ -363,11 +380,35 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<SubscriptionPlanConfig> SubscriptionPlanConfigs =>
|
||||
_subscriptionPlanConfigs ??= new Repository<SubscriptionPlanConfig>(_context);
|
||||
|
||||
// Platform content
|
||||
/// <summary>Repository for <see cref="Announcement"/> platform-wide announcements; no tenant filter, no soft delete.</summary>
|
||||
public IPlainRepository<Announcement> Announcements =>
|
||||
_announcements ??= new PlainRepository<Announcement>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="BannedIp"/> IP ban records; no tenant filter, no soft delete.</summary>
|
||||
public IPlainRepository<BannedIp> BannedIps =>
|
||||
_bannedIps ??= new PlainRepository<BannedIp>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="DashboardTip"/> rotating tip-of-the-day entries; no tenant filter, no soft delete.</summary>
|
||||
public IPlainRepository<DashboardTip> DashboardTips =>
|
||||
_dashboardTips ??= new PlainRepository<DashboardTip>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="InAppNotification"/> bell-notification records; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<InAppNotification> InAppNotifications =>
|
||||
_inAppNotifications ??= new Repository<InAppNotification>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="ReleaseNote"/> platform changelog entries; no tenant filter, no soft delete.</summary>
|
||||
public IPlainRepository<ReleaseNote> ReleaseNotes =>
|
||||
_releaseNotes ??= new PlainRepository<ReleaseNote>(_context);
|
||||
|
||||
// Bug Reports
|
||||
/// <summary>Repository for <see cref="BugReport"/> user-submitted bug reports; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<BugReport> BugReports =>
|
||||
_bugReports ??= new Repository<BugReport>(_context);
|
||||
|
||||
public IRepository<BugReportAttachment> BugReportAttachments =>
|
||||
_bugReportAttachments ??= new Repository<BugReportAttachment>(_context);
|
||||
|
||||
// Contact Us
|
||||
/// <summary>Repository for <see cref="ContactSubmission"/> contact form submissions; platform admins see all, company users see their own.</summary>
|
||||
public IRepository<ContactSubmission> ContactSubmissions =>
|
||||
@@ -388,8 +429,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
|
||||
// Job Templates
|
||||
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<JobTemplate> JobTemplates =>
|
||||
_jobTemplates ??= new Repository<JobTemplate>(_context);
|
||||
public IJobTemplateRepository JobTemplates =>
|
||||
_jobTemplates ??= new JobTemplateRepository(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="JobTemplateItem"/> item definitions within a job template.</summary>
|
||||
public IRepository<JobTemplateItem> JobTemplateItems =>
|
||||
@@ -405,8 +446,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
|
||||
// Purchase Orders
|
||||
/// <summary>Repository for <see cref="PurchaseOrder"/> vendor purchase orders; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<PurchaseOrder> PurchaseOrders =>
|
||||
_purchaseOrders ??= new Repository<PurchaseOrder>(_context);
|
||||
public IPurchaseOrderRepository PurchaseOrders =>
|
||||
_purchaseOrders ??= new PurchaseOrderRepository(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="PurchaseOrderItem"/> line-items on a purchase order; cascade-deleted with the PO.</summary>
|
||||
public IRepository<PurchaseOrderItem> PurchaseOrderItems =>
|
||||
@@ -423,8 +464,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
|
||||
// Invoices, Payments & Deposits
|
||||
/// <summary>Repository for <see cref="Invoice"/> customer invoices (1:1 with Job); tenant-filtered with soft delete.</summary>
|
||||
public IRepository<Invoice> Invoices =>
|
||||
_invoices ??= new Repository<Invoice>(_context);
|
||||
public IInvoiceRepository Invoices =>
|
||||
_invoices ??= new InvoiceRepository(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="InvoiceItem"/> line-items on an invoice; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<InvoiceItem> InvoiceItems =>
|
||||
@@ -447,8 +488,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
_accounts ??= new Repository<Account>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="Bill"/> vendor bills (accounts payable); tenant-filtered with soft delete.</summary>
|
||||
public IRepository<Bill> Bills =>
|
||||
_bills ??= new Repository<Bill>(_context);
|
||||
public IBillRepository Bills =>
|
||||
_bills ??= new BillRepository(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="BillLineItem"/> expense line-items on a vendor bill; each assigned to a chart-of-accounts entry.</summary>
|
||||
public IRepository<BillLineItem> BillLineItems =>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IAiUsageReportService"/> by querying <c>AiUsageLogs</c> and
|
||||
/// <c>QuotePhotos</c> directly via <c>ApplicationDbContext</c>. Both tables either are
|
||||
/// not BaseEntity (AiUsageLog) or require cross-tenant GROUP BY aggregations that must
|
||||
/// execute in SQL; this service encapsulates those queries so the controller stays clean.
|
||||
/// </summary>
|
||||
public class AiUsageReportService : IAiUsageReportService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public AiUsageReportService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AiUsageReportData> GetReportDataAsync()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var todayStart = now.Date;
|
||||
var last7Start = todayStart.AddDays(-7);
|
||||
var last30Start = todayStart.AddDays(-30);
|
||||
|
||||
var usageByCompany = await _context.AiUsageLogs
|
||||
.GroupBy(l => l.CompanyId)
|
||||
.Select(g => new AiCompanyUsage(
|
||||
g.Key,
|
||||
g.Count(l => l.CalledAt >= todayStart),
|
||||
g.Count(l => l.CalledAt >= last7Start),
|
||||
g.Count(l => l.CalledAt >= last30Start),
|
||||
g.Count()))
|
||||
.ToListAsync();
|
||||
|
||||
var featureStats = await _context.AiUsageLogs
|
||||
.Where(l => l.CalledAt >= last30Start)
|
||||
.GroupBy(l => new { l.CompanyId, l.Feature })
|
||||
.Select(g => new AiFeatureStat(g.Key.CompanyId, g.Key.Feature, g.Count()))
|
||||
.ToListAsync();
|
||||
|
||||
var photoCounts = await _context.QuotePhotos
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.IsAiAnalysisPhoto && !p.IsDeleted)
|
||||
.GroupBy(p => p.CompanyId)
|
||||
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
|
||||
|
||||
return new AiUsageReportData(usageByCompany, featureStats, photoCounts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete implementation of <see cref="IAuditLogService"/> that writes <see cref="AuditLog"/>
|
||||
/// entries via <see cref="ApplicationDbContext"/> directly. <c>AuditLog</c> does not inherit
|
||||
/// from <c>BaseEntity</c> so it cannot be managed through the generic repository; this service
|
||||
/// owns that write path and keeps <c>ApplicationDbContext</c> out of controller constructors.
|
||||
/// </summary>
|
||||
public class AuditLogService : IAuditLogService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public AuditLogService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task LogAsync(AuditLog entry)
|
||||
{
|
||||
_context.AuditLogs.Add(entry);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<AuditLog>> GetUserActivityAsync(string userId, int limit = 50)
|
||||
{
|
||||
return await _context.AuditLogs
|
||||
.AsNoTracking()
|
||||
.Where(l => l.UserId == userId && l.EntityType == "ApplicationUser")
|
||||
.OrderByDescending(l => l.Timestamp)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="ICompanyDataPurgeService"/> via bulk <c>ExecuteDeleteAsync</c> against
|
||||
/// <see cref="ApplicationDbContext"/> directly. This is an intentional exception to the
|
||||
/// IUnitOfWork pattern — identical to the rationale for <c>DataPurgeController</c> in the
|
||||
/// documented permanent exceptions list. Each <c>ExecuteDeleteAsync</c> call commits immediately
|
||||
/// at the database level so no <c>SaveChangesAsync</c> is needed for the bulk tiers.
|
||||
/// </summary>
|
||||
public class CompanyDataPurgeService : ICompanyDataPurgeService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public CompanyDataPurgeService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task DeleteAllBusinessDataAsync(int companyId, IReadOnlyList<string> companyUserIds)
|
||||
{
|
||||
// ── Tier 1: Leaf children ─────────────────────────────────────────────
|
||||
await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
|
||||
// Announcement dismissals referencing the company's users or company-targeted announcements
|
||||
var announcementIds = await _context.Announcements
|
||||
.Where(a => a.TargetCompanyId == companyId).Select(a => a.Id).ToListAsync();
|
||||
await _context.AnnouncementDismissals.IgnoreQueryFilters()
|
||||
.Where(x => companyUserIds.Contains(x.UserId) || announcementIds.Contains(x.AnnouncementId))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 2: Mid-level children ────────────────────────────────────────
|
||||
await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 3: Top-level company entities ───────────────────────────────
|
||||
await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 4: Company configs and lookup tables ─────────────────────────
|
||||
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobPriorityLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.QuoteStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.AppointmentStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.AppointmentTypeLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.PricingTiers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.CompanyOperatingCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.CompanyPreferences.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
// Note: company record and users are left for the caller to handle via UserManager and UnitOfWork
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ResetBusinessDataAsync(int companyId)
|
||||
{
|
||||
// ── Tier 0: Grandchildren ─────────────────────────────────────────────
|
||||
await _context.JobTemplateItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobTemplateItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.GiftCertificateRedemptions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.CreditMemoApplications.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.OvenBatchItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
|
||||
var announcementIds = await _context.Announcements
|
||||
.Where(a => a.TargetCompanyId == companyId).Select(a => a.Id).ToListAsync();
|
||||
if (announcementIds.Count > 0)
|
||||
await _context.AnnouncementDismissals.IgnoreQueryFilters()
|
||||
.Where(x => announcementIds.Contains(x.AnnouncementId))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 1: Children ──────────────────────────────────────────────────
|
||||
await _context.JobTemplateItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobTimeEntries.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.ReworkRecords.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.QuotePhotos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Deposits.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkerRoleCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.GiftCertificates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 2: Top-level business entities ──────────────────────────────
|
||||
await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.JobTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.PurchaseOrders.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
|
||||
|
||||
// Clear QuickBooks migration wizard progress (tracked update, not bulk delete)
|
||||
var prefs = await _context.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId);
|
||||
if (prefs?.QbMigrationStateJson != null)
|
||||
{
|
||||
prefs.QbMigrationStateJson = null;
|
||||
prefs.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="ICompanyListService"/> using <see cref="ApplicationDbContext"/> directly.
|
||||
/// Queries require <c>IgnoreQueryFilters()</c> (to bypass the tenant filter and see all companies),
|
||||
/// dynamic sort expressions, and cross-entity GROUP BY aggregations — all of which are beyond the
|
||||
/// generic <see cref="PowderCoating.Core.Interfaces.IRepository{T}"/>.
|
||||
/// </summary>
|
||||
public class CompanyListService : ICompanyListService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public CompanyListService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize)
|
||||
{
|
||||
var query = _context.Companies
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var s = searchTerm.ToLower();
|
||||
query = query.Where(c =>
|
||||
c.CompanyName.ToLower().Contains(s) ||
|
||||
(c.CompanyCode != null && c.CompanyCode.ToLower().Contains(s)) ||
|
||||
(c.PrimaryContactEmail != null && c.PrimaryContactEmail.ToLower().Contains(s)) ||
|
||||
(c.Phone != null && c.Phone.ToLower().Contains(s)));
|
||||
}
|
||||
|
||||
query = (sortColumn, sortDirection == "asc") switch
|
||||
{
|
||||
("CompanyName", true) => query.OrderBy(c => c.CompanyName),
|
||||
("CompanyName", false) => query.OrderByDescending(c => c.CompanyName),
|
||||
("Plan", true) => query.OrderBy(c => c.SubscriptionPlan),
|
||||
("Plan", false) => query.OrderByDescending(c => c.SubscriptionPlan),
|
||||
("Status", true) => query.OrderBy(c => c.IsActive),
|
||||
("Status", false) => query.OrderByDescending(c => c.IsActive),
|
||||
("Created", true) => query.OrderBy(c => c.CreatedAt),
|
||||
("Created", false) => query.OrderByDescending(c => c.CreatedAt),
|
||||
_ => query.OrderBy(c => c.CompanyName)
|
||||
};
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
var companies = await query
|
||||
.Include(c => c.Users)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return (companies, totalCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds)
|
||||
{
|
||||
var jobCounts = await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
|
||||
.GroupBy(j => j.CompanyId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
var quoteCounts = await _context.Quotes
|
||||
.IgnoreQueryFilters()
|
||||
.Where(q => companyIds.Contains(q.CompanyId) && !q.IsDeleted)
|
||||
.GroupBy(q => q.CompanyId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
var customerCounts = await _context.Customers
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => companyIds.Contains(c.CompanyId) && !c.IsDeleted)
|
||||
.GroupBy(c => c.CompanyId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
var wizardRaw = await _context.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => companyIds.Contains(p.CompanyId) && p.SetupWizardCompleted)
|
||||
.Select(p => new { p.CompanyId, p.SetupWizardCompletedAt, p.SetupWizardCompletedByName })
|
||||
.ToListAsync();
|
||||
|
||||
var wizardInfo = wizardRaw.ToDictionary(
|
||||
x => x.CompanyId,
|
||||
x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName));
|
||||
|
||||
return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IDashboardReadService"/> using <see cref="ApplicationDbContext"/> directly.
|
||||
/// All queries require ThenInclude chains or navigation-property predicates (e.g. JobStatus.StatusCode)
|
||||
/// that cannot be expressed through the generic <see cref="PowderCoating.Core.Interfaces.IRepository{T}"/>.
|
||||
/// </summary>
|
||||
public class DashboardReadService : IDashboardReadService
|
||||
{
|
||||
private static readonly string[] CompletedStatusCodes =
|
||||
[
|
||||
"COMPLETED",
|
||||
"READY_FOR_PICKUP",
|
||||
"DELIVERED",
|
||||
"CANCELLED"
|
||||
];
|
||||
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public DashboardReadService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<DashboardIndexData> GetIndexDataAsync(DateTime today)
|
||||
{
|
||||
var startOfMonth = new DateTime(today.Year, today.Month, 1);
|
||||
var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
|
||||
var tomorrow = today.AddDays(1);
|
||||
var lookAheadDate = today.AddDays(7);
|
||||
var last30Days = today.AddDays(-30);
|
||||
|
||||
var openInvoiceStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue };
|
||||
|
||||
// All active jobs (for today/overdue/in-progress panels)
|
||||
var activeJobs = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.AssignedUser)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode))
|
||||
.ToListAsync();
|
||||
|
||||
// Monthly revenue — sum completed jobs updated in current month
|
||||
var monthlyRevenue = await _context.Jobs
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
|
||||
&& j.UpdatedAt >= startOfMonth
|
||||
&& j.UpdatedAt <= endOfMonth)
|
||||
.SumAsync(j => j.FinalPrice);
|
||||
|
||||
// Today's appointments (non-cancelled)
|
||||
var todaysAppointments = await _context.Appointments
|
||||
.AsNoTracking()
|
||||
.Include(a => a.Customer)
|
||||
.Include(a => a.AppointmentType)
|
||||
.Include(a => a.AppointmentStatus)
|
||||
.Include(a => a.AssignedUser)
|
||||
.Where(a => a.ScheduledStartTime >= today && a.ScheduledStartTime < tomorrow
|
||||
&& a.AppointmentStatus.StatusCode != "CANCELLED")
|
||||
.OrderBy(a => a.ScheduledStartTime)
|
||||
.ToListAsync();
|
||||
|
||||
// Upcoming/overdue maintenance
|
||||
var upcomingMaintenance = await _context.MaintenanceRecords
|
||||
.AsNoTracking()
|
||||
.Include(m => m.Equipment)
|
||||
.Include(m => m.AssignedUser)
|
||||
.Where(m => (m.Status == MaintenanceStatus.Scheduled
|
||||
|| m.Status == MaintenanceStatus.InProgress
|
||||
|| m.Status == MaintenanceStatus.Overdue)
|
||||
&& (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate))
|
||||
.OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1)
|
||||
.ThenByDescending(m => m.Priority)
|
||||
.ThenBy(m => m.ScheduledDate)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
// Pending quotes (SENT status)
|
||||
var pendingQuotes = await _context.Quotes
|
||||
.AsNoTracking()
|
||||
.Include(q => q.Customer)
|
||||
.Include(q => q.QuoteStatus)
|
||||
.Where(q => q.QuoteStatus.StatusCode == "SENT")
|
||||
.ToListAsync();
|
||||
|
||||
// Open invoices (for AR aging + overdue list)
|
||||
var openInvoices = await _context.Invoices
|
||||
.AsNoTracking()
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => openInvoiceStatuses.Contains(i.Status))
|
||||
.ToListAsync();
|
||||
|
||||
// Invoiced this month
|
||||
var invoicedThisMonth = await _context.Invoices
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.WrittenOff
|
||||
&& i.InvoiceDate >= startOfMonth
|
||||
&& i.InvoiceDate <= endOfMonth)
|
||||
.SumAsync(i => i.Total);
|
||||
|
||||
// Collected this month
|
||||
var collectedThisMonth = await _context.Payments
|
||||
.Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth)
|
||||
.SumAsync(p => p.Amount);
|
||||
|
||||
// Recent payments with Invoice → Customer
|
||||
var recentPayments = await _context.Payments
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Invoice).ThenInclude(i => i!.Customer)
|
||||
.OrderByDescending(p => p.PaymentDate)
|
||||
.Take(6)
|
||||
.ToListAsync();
|
||||
|
||||
// Recent quotes (last 30 days)
|
||||
var recentQuotes = await _context.Quotes
|
||||
.AsNoTracking()
|
||||
.Include(q => q.Customer)
|
||||
.Include(q => q.QuoteStatus)
|
||||
.Where(q => q.CreatedAt >= last30Days)
|
||||
.OrderByDescending(q => q.CreatedAt)
|
||||
.Take(5)
|
||||
.ToListAsync();
|
||||
|
||||
// Recent jobs (last 30 days)
|
||||
var recentJobs = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => j.CreatedAt >= last30Days)
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(5)
|
||||
.ToListAsync();
|
||||
|
||||
// Jobs needing powder (not yet ordered, insufficient stock)
|
||||
var jobsNeedingPowder = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobItems)
|
||||
.ThenInclude(i => i.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.ThenInclude(inv => inv!.PrimaryVendor)
|
||||
.Include(j => j.JobItems)
|
||||
.ThenInclude(i => i.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Where(j => !j.IsDeleted
|
||||
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
|
||||
&& j.JobItems.Any(i => i.Coats.Any(c =>
|
||||
!c.IsDeleted &&
|
||||
!c.PowderOrdered &&
|
||||
c.PowderToOrder > 0 &&
|
||||
(c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))))
|
||||
.ToListAsync();
|
||||
|
||||
// Jobs with powder already ordered but not yet received
|
||||
var jobsWithOrderedPowder = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobItems)
|
||||
.ThenInclude(i => i.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.ThenInclude(inv => inv!.PrimaryVendor)
|
||||
.Include(j => j.JobItems)
|
||||
.ThenInclude(i => i.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Where(j => !j.IsDeleted
|
||||
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
|
||||
&& j.JobItems.Any(i => i.Coats.Any(c =>
|
||||
!c.IsDeleted &&
|
||||
c.PowderOrdered &&
|
||||
!c.PowderReceived)))
|
||||
.ToListAsync();
|
||||
|
||||
// Bills due (open/partial, balance remaining)
|
||||
var billsDue = await _context.Bills
|
||||
.AsNoTracking()
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid)
|
||||
&& b.Total > b.AmountPaid)
|
||||
.OrderBy(b => b.DueDate)
|
||||
.Take(15)
|
||||
.ToListAsync();
|
||||
|
||||
// Random tip of the day
|
||||
var tips = await _context.DashboardTips.Where(t => t.IsActive).ToListAsync();
|
||||
var tipOfTheDay = tips.Count > 0 ? tips[Random.Shared.Next(tips.Count)].TipText : null;
|
||||
|
||||
return new DashboardIndexData(
|
||||
ActiveJobs: activeJobs,
|
||||
MonthlyRevenue: monthlyRevenue,
|
||||
TodaysAppointments: todaysAppointments,
|
||||
UpcomingMaintenance: upcomingMaintenance,
|
||||
PendingQuotes: pendingQuotes,
|
||||
OpenInvoices: openInvoices,
|
||||
InvoicedThisMonth: invoicedThisMonth,
|
||||
CollectedThisMonth: collectedThisMonth,
|
||||
RecentPayments: recentPayments,
|
||||
RecentQuotes: recentQuotes,
|
||||
RecentJobs: recentJobs,
|
||||
JobsNeedingPowder: jobsNeedingPowder,
|
||||
JobsWithOrderedPowder: jobsWithOrderedPowder,
|
||||
BillsDue: billsDue,
|
||||
TipOfTheDay: tipOfTheDay
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<int> GetTotalUserCountAsync()
|
||||
{
|
||||
return await _context.Users
|
||||
.Where(u => u.CompanyId > 0)
|
||||
.CountAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements financial aggregate reports (P&L, Balance Sheet, AR Aging, Sales & Income)
|
||||
/// using direct DbContext access with AsNoTracking. Migrated from inline queries in
|
||||
/// ReportsController as part of Phase 2 of the data-access architecture migration.
|
||||
/// The four report types each have matching PDF export paths in the controller that
|
||||
/// share the same data by calling these methods, eliminating the previous duplication.
|
||||
/// See <c>docs/DATA_ACCESS_ARCHITECTURE.md</c> for the full migration plan.
|
||||
/// </summary>
|
||||
public class FinancialReportService : IFinancialReportService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public FinancialReportService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to)
|
||||
{
|
||||
var toEnd = to.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
// Revenue: InvoiceItems posted to revenue accounts
|
||||
var revenueByAccount = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId != null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.GroupBy(ii => ii.RevenueAccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
|
||||
.ToListAsync();
|
||||
|
||||
var unlinkedRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId == null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
|
||||
var revenueAccounts = await _context.Accounts
|
||||
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
||||
.ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var revenueLines = revenueByAccount
|
||||
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
|
||||
.Select(r => new FinancialReportLine
|
||||
{
|
||||
AccountId = r.AccountId,
|
||||
AccountNumber = revenueAccounts[r.AccountId].AccountNumber,
|
||||
AccountName = revenueAccounts[r.AccountId].Name,
|
||||
Amount = r.Amount
|
||||
})
|
||||
.OrderBy(l => l.AccountNumber)
|
||||
.ToList();
|
||||
|
||||
if (unlinkedRevenue > 0)
|
||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
||||
|
||||
// COGS & Expenses: direct Expenses + BillLineItems merged per account
|
||||
var directByAccount = await _context.Expenses
|
||||
.Where(e => e.Date >= from && e.Date <= toEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToListAsync();
|
||||
|
||||
var billLinesByAccount = await _context.BillLineItems
|
||||
.Where(bli => bli.AccountId != null
|
||||
&& bli.Bill.Status != BillStatus.Draft
|
||||
&& bli.Bill.Status != BillStatus.Voided
|
||||
&& bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd)
|
||||
.GroupBy(bli => bli.AccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) })
|
||||
.ToListAsync();
|
||||
|
||||
var expenseAmounts = new Dictionary<int, decimal>();
|
||||
foreach (var e in directByAccount)
|
||||
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
|
||||
foreach (var b in billLinesByAccount)
|
||||
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
|
||||
|
||||
var expAccounts = await _context.Accounts
|
||||
.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
|
||||
.ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var cogsLines = new List<FinancialReportLine>();
|
||||
var expenseLines = new List<FinancialReportLine>();
|
||||
|
||||
foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999"))
|
||||
{
|
||||
if (!expAccounts.TryGetValue(accountId, out var acct)) continue;
|
||||
var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount };
|
||||
if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line);
|
||||
else expenseLines.Add(line);
|
||||
}
|
||||
|
||||
return new ProfitAndLossDto
|
||||
{
|
||||
From = from,
|
||||
To = to,
|
||||
CompanyName = companyName,
|
||||
RevenueLines = revenueLines,
|
||||
TotalRevenue = revenueLines.Sum(l => l.Amount),
|
||||
CogsLines = cogsLines,
|
||||
TotalCogs = cogsLines.Sum(l => l.Amount),
|
||||
ExpenseLines = expenseLines,
|
||||
TotalExpenses = expenseLines.Sum(l => l.Amount),
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf)
|
||||
{
|
||||
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
// Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1)
|
||||
|
||||
var depositsByAcct = await _context.Payments
|
||||
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||
.GroupBy(p => p.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var expFromByAcct = await _context.Expenses
|
||||
.Where(e => e.Date <= asOfEnd)
|
||||
.GroupBy(e => e.PaymentAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var bpFromByAcct = await _context.BillPayments
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.BankAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var billsByApAcct = await _context.Bills
|
||||
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||
.GroupBy(b => b.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var bpByApAcct = await _context.BillPayments
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.Bill.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var taxByAcct = await _context.Invoices
|
||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate <= asOfEnd)
|
||||
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var arDebits = await _context.Invoices
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||
var arCredits = await _context.Payments
|
||||
.Where(p => p.PaymentDate <= asOfEnd
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
// Retained earnings = net P&L from inception through asOf
|
||||
var lifetimeRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
var lifetimeCogs = await _context.Expenses
|
||||
.Where(e => e.Date <= asOfEnd)
|
||||
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||
var lifetimeBillCosts = await _context.BillLineItems
|
||||
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
||||
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
|
||||
|
||||
var accounts = await _context.Accounts
|
||||
.Where(a => a.IsActive)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.ToListAsync();
|
||||
|
||||
// Standard double-entry: assets have normal debit balance; liabilities+equity have normal credit balance.
|
||||
decimal ComputeBalance(Account a)
|
||||
{
|
||||
bool normalDebit = a.AccountType == AccountType.Asset;
|
||||
decimal debits = 0, credits = 0;
|
||||
|
||||
if (a.AccountSubType == AccountSubType.AccountsReceivable)
|
||||
{
|
||||
debits = arDebits; credits = arCredits;
|
||||
}
|
||||
else if (a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
{
|
||||
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
debits += depositsByAcct.GetValueOrDefault(a.Id);
|
||||
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
||||
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||
}
|
||||
|
||||
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
|
||||
? a.OpeningBalance : 0;
|
||||
decimal net = normalDebit ? debits - credits : credits - debits;
|
||||
return opening + net;
|
||||
}
|
||||
|
||||
FinancialReportLine ToLine(Account a) => new()
|
||||
{
|
||||
AccountId = a.Id,
|
||||
AccountNumber = a.AccountNumber,
|
||||
AccountName = a.Name,
|
||||
Amount = ComputeBalance(a)
|
||||
};
|
||||
|
||||
var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList();
|
||||
var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList();
|
||||
var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList();
|
||||
|
||||
var currentAssets = assetAccts.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset).Select(ToLine).ToList();
|
||||
var fixedAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.FixedAsset).Select(ToLine).ToList();
|
||||
var otherAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.OtherAsset).Select(ToLine).ToList();
|
||||
var currentLiabilities = liabilityAccts.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability).Select(ToLine).ToList();
|
||||
var longTermLiabilities = liabilityAccts.Where(a => a.AccountSubType == AccountSubType.LongTermLiability).Select(ToLine).ToList();
|
||||
var equityLines = equityAccts.Select(ToLine).ToList();
|
||||
|
||||
var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount);
|
||||
var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount);
|
||||
var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings;
|
||||
|
||||
return new BalanceSheetDto
|
||||
{
|
||||
AsOf = asOf,
|
||||
CompanyName = companyName,
|
||||
CurrentAssets = currentAssets,
|
||||
FixedAssets = fixedAssets,
|
||||
OtherAssets = otherAssets,
|
||||
TotalAssets = totalAssets,
|
||||
CurrentLiabilities = currentLiabilities,
|
||||
LongTermLiabilities = longTermLiabilities,
|
||||
TotalLiabilities = totalLiabilities,
|
||||
EquityLines = equityLines,
|
||||
RetainedEarnings = retainedEarnings,
|
||||
TotalEquity = totalEquity,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf)
|
||||
{
|
||||
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
var openInvoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.Paid
|
||||
&& i.InvoiceDate <= asOfEnd
|
||||
&& (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0)
|
||||
.OrderBy(i => i.Customer!.CompanyName)
|
||||
.ThenBy(i => i.DueDate)
|
||||
.ToListAsync();
|
||||
|
||||
static string AgingBucket(int d) => d switch
|
||||
{
|
||||
<= 0 => "current",
|
||||
<= 30 => "1-30",
|
||||
<= 60 => "31-60",
|
||||
<= 90 => "61-90",
|
||||
_ => "90+"
|
||||
};
|
||||
|
||||
var customerDtos = new List<ArAgingCustomerDto>();
|
||||
|
||||
foreach (var grp in openInvoices.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial }))
|
||||
{
|
||||
var customerName = grp.Key.IsCommercial
|
||||
? grp.Key.CompanyName
|
||||
: $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim();
|
||||
|
||||
var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName };
|
||||
|
||||
foreach (var inv in grp)
|
||||
{
|
||||
var balance = inv.BalanceDue;
|
||||
var daysOverdue = inv.DueDate.HasValue ? (int)(asOf - inv.DueDate.Value.Date).TotalDays : 0;
|
||||
|
||||
custDto.Invoices.Add(new ArAgingInvoiceDto
|
||||
{
|
||||
InvoiceId = inv.Id,
|
||||
InvoiceNumber = inv.InvoiceNumber,
|
||||
InvoiceDate = inv.InvoiceDate,
|
||||
DueDate = inv.DueDate,
|
||||
BalanceDue = balance,
|
||||
DaysOverdue = daysOverdue
|
||||
});
|
||||
|
||||
switch (AgingBucket(daysOverdue))
|
||||
{
|
||||
case "current": custDto.TotalCurrent += balance; break;
|
||||
case "1-30": custDto.Total1to30 += balance; break;
|
||||
case "31-60": custDto.Total31to60 += balance; break;
|
||||
case "61-90": custDto.Total61to90 += balance; break;
|
||||
default: custDto.TotalOver90 += balance; break;
|
||||
}
|
||||
}
|
||||
|
||||
customerDtos.Add(custDto);
|
||||
}
|
||||
|
||||
var sorted = customerDtos.OrderByDescending(c => c.TotalBalance).ToList();
|
||||
|
||||
return new ArAgingReportDto
|
||||
{
|
||||
AsOf = asOf,
|
||||
CompanyName = companyName,
|
||||
Customers = sorted,
|
||||
TotalCurrent = sorted.Sum(c => c.TotalCurrent),
|
||||
Total1to30 = sorted.Sum(c => c.Total1to30),
|
||||
Total31to60 = sorted.Sum(c => c.Total31to60),
|
||||
Total61to90 = sorted.Sum(c => c.Total61to90),
|
||||
TotalOver90 = sorted.Sum(c => c.TotalOver90),
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to)
|
||||
{
|
||||
var toEnd = to.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
var invoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Include(i => i.Payments)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||
.OrderBy(i => i.InvoiceDate)
|
||||
.ToListAsync();
|
||||
|
||||
var collectedInPeriod = await _context.Payments
|
||||
.Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
var byCustomer = invoices
|
||||
.GroupBy(i => new
|
||||
{
|
||||
i.CustomerId,
|
||||
Name = i.Customer!.IsCommercial
|
||||
? i.Customer.CompanyName
|
||||
: $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim()
|
||||
})
|
||||
.Select(g => new SalesByCustomerDto
|
||||
{
|
||||
CustomerId = g.Key.CustomerId,
|
||||
CustomerName = g.Key.Name,
|
||||
InvoiceCount = g.Count(),
|
||||
TotalInvoiced = g.Sum(i => i.Total),
|
||||
TotalPaid = g.Sum(i => i.AmountPaid),
|
||||
BalanceDue = g.Sum(i => i.BalanceDue),
|
||||
})
|
||||
.OrderByDescending(c => c.TotalInvoiced)
|
||||
.ToList();
|
||||
|
||||
var byMonth = invoices
|
||||
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
|
||||
.Select(g => new SalesByMonthDto
|
||||
{
|
||||
Year = g.Key.Year,
|
||||
Month = g.Key.Month,
|
||||
Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"),
|
||||
TotalInvoiced = g.Sum(i => i.Total),
|
||||
TotalCollected = g.Sum(i => i.AmountPaid),
|
||||
InvoiceCount = g.Count(),
|
||||
})
|
||||
.OrderBy(m => m.Year).ThenBy(m => m.Month)
|
||||
.ToList();
|
||||
|
||||
var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto
|
||||
{
|
||||
InvoiceId = i.Id,
|
||||
InvoiceNumber = i.InvoiceNumber,
|
||||
CustomerName = i.Customer!.IsCommercial
|
||||
? i.Customer.CompanyName
|
||||
: $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
|
||||
InvoiceDate = i.InvoiceDate,
|
||||
DueDate = i.DueDate,
|
||||
Status = i.Status.ToString(),
|
||||
SubTotal = i.SubTotal,
|
||||
TaxAmount = i.TaxAmount,
|
||||
Total = i.Total,
|
||||
AmountPaid = i.AmountPaid,
|
||||
BalanceDue = i.BalanceDue,
|
||||
}).ToList();
|
||||
|
||||
return new SalesIncomeReportDto
|
||||
{
|
||||
From = from,
|
||||
To = to,
|
||||
CompanyName = companyName,
|
||||
TotalInvoiced = invoices.Sum(i => i.Total),
|
||||
TotalCollected = collectedInPeriod,
|
||||
TotalTax = invoices.Sum(i => i.TaxAmount),
|
||||
TotalDiscount = invoices.Sum(i => i.DiscountAmount),
|
||||
InvoiceCount = invoices.Count,
|
||||
CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(),
|
||||
ByCustomer = byCustomer,
|
||||
ByMonth = byMonth,
|
||||
Invoices = invoiceLines,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the company name by ID for report headers and AI prompt injection.
|
||||
/// Falls back to "Your Company" if the record is not found.
|
||||
/// </summary>
|
||||
private async Task<string> GetCompanyNameAsync(int companyId)
|
||||
{
|
||||
if (companyId <= 0) return "Your Company";
|
||||
var company = await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId);
|
||||
return company?.CompanyName ?? "Your Company";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements operational aggregate reports using direct DbContext access with AsNoTracking.
|
||||
/// Query logic is migrated here from <c>ReportsController</c> as each report action is
|
||||
/// converted during Phase 2/3 of the data-access architecture migration.
|
||||
/// See <c>docs/DATA_ACCESS_ARCHITECTURE.md</c> for the full migration plan.
|
||||
/// </summary>
|
||||
public class OperationalReportService : IOperationalReportService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public OperationalReportService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<JobCycleTimeReport> GetJobCycleTimeAsync(int companyId, int months)
|
||||
{
|
||||
var completedCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||
|
||||
var completedJobs = await _context.Jobs
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => !j.IsDeleted && completedCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
var history = await _context.JobStatusHistory
|
||||
.Include(h => h.FromStatus)
|
||||
.Include(h => h.ToStatus)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
var historyByJob = history
|
||||
.GroupBy(h => h.JobId)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
|
||||
|
||||
var statusTimings = new Dictionary<string, (string DisplayName, List<double> Days)>();
|
||||
|
||||
foreach (var job in completedJobs)
|
||||
{
|
||||
if (!historyByJob.TryGetValue(job.Id, out var jobHistory) || !jobHistory.Any()) continue;
|
||||
|
||||
var prevDate = job.CreatedAt;
|
||||
foreach (var entry in jobHistory)
|
||||
{
|
||||
var fc = entry.FromStatus?.StatusCode;
|
||||
var fn = entry.FromStatus?.DisplayName;
|
||||
if (fc == null) { prevDate = entry.ChangedDate; continue; }
|
||||
var d = (entry.ChangedDate - prevDate).TotalDays;
|
||||
if (d >= 0 && d <= 365)
|
||||
{
|
||||
if (!statusTimings.ContainsKey(fc)) statusTimings[fc] = (fn ?? fc, new List<double>());
|
||||
statusTimings[fc].Days.Add(d);
|
||||
}
|
||||
prevDate = entry.ChangedDate;
|
||||
}
|
||||
|
||||
var last = jobHistory.Last();
|
||||
var tc = last.ToStatus?.StatusCode;
|
||||
var tn = last.ToStatus?.DisplayName;
|
||||
if (tc != null)
|
||||
{
|
||||
var d = (job.CompletedDate!.Value - last.ChangedDate).TotalDays;
|
||||
if (d >= 0 && d <= 365)
|
||||
{
|
||||
if (!statusTimings.ContainsKey(tc)) statusTimings[tc] = (tn ?? tc, new List<double>());
|
||||
statusTimings[tc].Days.Add(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var rows = statusTimings
|
||||
.Where(kv => kv.Value.Days.Any())
|
||||
.Select(kv => new JobCycleTimeRow(kv.Value.DisplayName, Math.Round(kv.Value.Days.Average(), 1), kv.Value.Days.Count))
|
||||
.ToList();
|
||||
|
||||
return new JobCycleTimeReport(rows, months);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<PowderUsageReport> GetPowderUsageAsync(int companyId, int months)
|
||||
{
|
||||
var startDate = DateTime.UtcNow.AddMonths(-months);
|
||||
|
||||
var transactions = await _context.InventoryTransactions
|
||||
.Include(t => t.InventoryItem)
|
||||
.Where(t => !t.IsDeleted
|
||||
&& t.TransactionType == InventoryTransactionType.JobUsage
|
||||
&& t.TransactionDate >= startDate)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
var rows = transactions
|
||||
.Where(t => t.InventoryItem != null)
|
||||
.GroupBy(t => t.InventoryItemId)
|
||||
.Select(g => new PowderUsageRow(
|
||||
ColorName: g.First().InventoryItem!.ColorName ?? g.First().InventoryItem!.Name,
|
||||
VendorName: g.First().InventoryItem!.Manufacturer ?? string.Empty,
|
||||
TotalLbs: g.Sum(t => Math.Abs(t.Quantity)),
|
||||
TotalCost: g.Sum(t => Math.Abs(t.TotalCost))))
|
||||
.OrderByDescending(r => r.TotalLbs)
|
||||
.ToList();
|
||||
|
||||
return new PowderUsageReport(rows, months);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Bill>> GetActiveBillsAsync()
|
||||
{
|
||||
return await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Include(b => b.Payments.Where(p => !p.IsDeleted))
|
||||
.Where(b => !b.IsDeleted && b.Status != BillStatus.Voided)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Expense>> GetAllExpensesAsync()
|
||||
{
|
||||
return await _context.Expenses
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Where(e => !e.IsDeleted)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<JobStatusHistory>> GetAllJobStatusHistoryAsync()
|
||||
{
|
||||
return await _context.JobStatusHistory
|
||||
.Include(h => h.FromStatus)
|
||||
.Include(h => h.ToStatus)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -109,10 +109,23 @@ public static class AppConstants
|
||||
public const string CurrentTosVersion = "2026-04-09";
|
||||
}
|
||||
|
||||
public static class GuidedActivation
|
||||
{
|
||||
public const string QuoteFirstPath = "quote_first";
|
||||
public const string JobFirstPath = "job_first";
|
||||
|
||||
public const string QueryKey = "guidedActivation";
|
||||
public const string HighlightJobIdKey = "highlightJobId";
|
||||
public const string QuoteCreatedStep = "quote_created";
|
||||
public const string JobCreatedStep = "job_created";
|
||||
public const string BoardIntroStep = "board_intro";
|
||||
public const string BoardReadyForInvoiceStep = "board_ready_for_invoice";
|
||||
public const string InvoiceCreatedStep = "invoice_created";
|
||||
}
|
||||
|
||||
public static class PowderInsights
|
||||
{
|
||||
public const int Layer3MinJobs = 150; // Minimum jobs with actual powder data before Layer 3 predictive features unlock
|
||||
public const int Layer2MinJobs = 10; // Minimum for efficiency trending to be meaningful
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
|
||||
@@ -12,14 +10,14 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
public class AccountingExportController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly PowderCoating.Application.Interfaces.IAuditService _auditService;
|
||||
|
||||
public AccountingExportController(ApplicationDbContext context, ITenantContext tenantContext,
|
||||
public AccountingExportController(IUnitOfWork unitOfWork, ITenantContext tenantContext,
|
||||
PowderCoating.Application.Interfaces.IAuditService auditService)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_auditService = auditService;
|
||||
}
|
||||
@@ -60,42 +58,33 @@ public class AccountingExportController : Controller
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Export(DateTime startDate, DateTime endDate, string format)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var start = startDate.Date;
|
||||
var end = endDate.Date.AddDays(1).AddTicks(-1);
|
||||
|
||||
// ── Load data ─────────────────────────────────────────────────────────
|
||||
var invoices = await _context.Invoices
|
||||
.Include(i => i.InvoiceItems)
|
||||
.Include(i => i.Payments)
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => !i.IsDeleted && i.CompanyId == companyId
|
||||
&& i.InvoiceDate >= start && i.InvoiceDate <= end)
|
||||
var invoices = (await _unitOfWork.Invoices.FindAsync(
|
||||
i => i.InvoiceDate >= start && i.InvoiceDate <= end,
|
||||
false,
|
||||
i => i.InvoiceItems,
|
||||
i => i.Payments,
|
||||
i => i.Customer))
|
||||
.OrderBy(i => i.InvoiceDate)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var expenses = await _context.Set<PowderCoating.Core.Entities.Expense>()
|
||||
.Include(e => e.Vendor)
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Include(e => e.PaymentAccount)
|
||||
.Where(e => !e.IsDeleted && e.CompanyId == companyId
|
||||
&& e.Date >= start && e.Date <= end)
|
||||
var expenses = (await _unitOfWork.Expenses.FindAsync(
|
||||
e => e.Date >= start && e.Date <= end,
|
||||
false,
|
||||
e => e.Vendor,
|
||||
e => e.ExpenseAccount,
|
||||
e => e.PaymentAccount))
|
||||
.OrderBy(e => e.Date)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var bills = await _context.Set<PowderCoating.Core.Entities.Bill>()
|
||||
.Include(b => b.Vendor)
|
||||
.Include(b => b.LineItems).ThenInclude(l => l.Account)
|
||||
.Include(b => b.Payments)
|
||||
.Where(b => !b.IsDeleted && b.CompanyId == companyId
|
||||
&& b.BillDate >= start && b.BillDate <= end)
|
||||
.OrderBy(b => b.BillDate)
|
||||
.ToListAsync();
|
||||
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
|
||||
|
||||
var customers = await _context.Customers
|
||||
.Where(c => !c.IsDeleted && c.CompanyId == companyId)
|
||||
var customers = (await _unitOfWork.Customers.GetAllAsync())
|
||||
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
// ── Build ZIP ─────────────────────────────────────────────────────────
|
||||
using var ms = new MemoryStream();
|
||||
|
||||
@@ -8,7 +8,6 @@ using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -19,7 +18,6 @@ public class AiQuickQuoteController : Controller
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IAiQuickQuoteService _aiService;
|
||||
private readonly IPricingCalculationService _pricingService;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<AiQuickQuoteController> _logger;
|
||||
|
||||
@@ -27,14 +25,12 @@ public class AiQuickQuoteController : Controller
|
||||
IUnitOfWork unitOfWork,
|
||||
IAiQuickQuoteService aiService,
|
||||
IPricingCalculationService pricingService,
|
||||
ApplicationDbContext context,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<AiQuickQuoteController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_aiService = aiService;
|
||||
_pricingService = pricingService;
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -106,9 +102,7 @@ public class AiQuickQuoteController : Controller
|
||||
var walkIn = await GetOrCreateWalkInCustomerAsync(companyId);
|
||||
|
||||
// Draft status — nullable FK, gracefully absent if lookup not seeded
|
||||
var draftStatus = await _context.QuoteStatusLookups
|
||||
.Where(s => s.StatusCode == "DRAFT")
|
||||
.FirstOrDefaultAsync();
|
||||
var draftStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == "DRAFT");
|
||||
|
||||
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
|
||||
var now = DateTime.UtcNow;
|
||||
@@ -300,21 +294,13 @@ public class AiQuickQuoteController : Controller
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var prefs = await _context.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.CompanyId == companyId && !p.IsDeleted)
|
||||
.Select(p => new { p.QuoteNumberPrefix })
|
||||
.FirstOrDefaultAsync();
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT";
|
||||
var prefix = $"{quotePrefix}-{now:yy}{now:MM}";
|
||||
|
||||
var lastQuoteNumber = await _context.Quotes
|
||||
.IgnoreQueryFilters()
|
||||
.Where(q => q.CompanyId == companyId && q.QuoteNumber.StartsWith(prefix))
|
||||
.OrderByDescending(q => q.QuoteNumber)
|
||||
.Select(q => q.QuoteNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix);
|
||||
|
||||
int nextNumber = 1;
|
||||
if (lastQuoteNumber != null)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -9,108 +9,74 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class AiUsageReportController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IAiUsageReportService _aiUsageReport;
|
||||
private readonly ILogger<AiUsageReportController> _logger;
|
||||
|
||||
public AiUsageReportController(ApplicationDbContext context, ILogger<AiUsageReportController> logger)
|
||||
public AiUsageReportController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IAiUsageReportService aiUsageReport,
|
||||
ILogger<AiUsageReportController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
_aiUsageReport = aiUsageReport;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform-wide AI usage report. Shows per-company call counts, photo upload totals, top
|
||||
/// feature used, and a usage tier so SuperAdmins can identify abusive or unusually heavy tenants.
|
||||
/// All queries use IgnoreQueryFilters() where needed to cross tenant boundaries.
|
||||
/// Companies and plan configs come from IUnitOfWork; AiUsageLogs aggregations and photo counts
|
||||
/// come from IAiUsageReportService (which runs SQL GROUP BY queries via ApplicationDbContext).
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var todayStart = now.Date;
|
||||
var last7Start = todayStart.AddDays(-7);
|
||||
var last30Start = todayStart.AddDays(-30);
|
||||
|
||||
// Companies (non-deleted only)
|
||||
var companies = await _context.Companies
|
||||
.IgnoreQueryFilters()
|
||||
var companies = (await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true))
|
||||
.Where(c => !c.IsDeleted)
|
||||
.Select(c => new { c.Id, c.CompanyName, c.SubscriptionPlan, c.IsActive })
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
// Plan display names from SubscriptionPlanConfig
|
||||
var planConfigs = await _context.Set<PowderCoating.Core.Entities.SubscriptionPlanConfig>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => !p.IsDeleted)
|
||||
.Select(p => new { p.Plan, p.DisplayName })
|
||||
.ToListAsync();
|
||||
var planConfigs = await _unitOfWork.SubscriptionPlanConfigs.GetAllAsync();
|
||||
var planNames = planConfigs.ToDictionary(p => p.Plan, p => p.DisplayName);
|
||||
|
||||
// All-time usage grouped by company — the four count windows are computed in SQL
|
||||
var usageByCompany = await _context.AiUsageLogs
|
||||
.GroupBy(l => l.CompanyId)
|
||||
.Select(g => new
|
||||
{
|
||||
CompanyId = g.Key,
|
||||
Today = g.Count(l => l.CalledAt >= todayStart),
|
||||
Last7Days = g.Count(l => l.CalledAt >= last7Start),
|
||||
Last30Days = g.Count(l => l.CalledAt >= last30Start),
|
||||
AllTime = g.Count()
|
||||
})
|
||||
.ToListAsync();
|
||||
var data = await _aiUsageReport.GetReportDataAsync();
|
||||
|
||||
// Top feature per company over the last 30 days
|
||||
var featureStats = await _context.AiUsageLogs
|
||||
.Where(l => l.CalledAt >= last30Start)
|
||||
.GroupBy(l => new { l.CompanyId, l.Feature })
|
||||
.Select(g => new { g.Key.CompanyId, g.Key.Feature, Count = g.Count() })
|
||||
.ToListAsync();
|
||||
|
||||
var topFeatureByCompany = featureStats
|
||||
var topFeatureByCompany = data.FeatureStats
|
||||
.GroupBy(f => f.CompanyId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.OrderByDescending(f => f.Count).First().Feature);
|
||||
|
||||
// Feature breakdown per company (last 30 days)
|
||||
var featureBreakdownByCompany = featureStats
|
||||
var featureBreakdownByCompany = data.FeatureStats
|
||||
.GroupBy(f => f.CompanyId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.ToDictionary(f => f.Feature, f => f.Count));
|
||||
|
||||
// Total AI photos per company (all time, including deleted photos)
|
||||
var photoCounts = await _context.QuotePhotos
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.IsAiAnalysisPhoto && !p.IsDeleted)
|
||||
.GroupBy(p => p.CompanyId)
|
||||
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
|
||||
|
||||
// Build report rows
|
||||
var usageDict = usageByCompany.ToDictionary(u => u.CompanyId);
|
||||
var usageDict = data.UsageByCompany.ToDictionary(u => u.CompanyId);
|
||||
|
||||
var rows = companies.Select(c =>
|
||||
{
|
||||
usageDict.TryGetValue(c.Id, out var u);
|
||||
photoCounts.TryGetValue(c.Id, out var photos);
|
||||
data.PhotoCountsByCompany.TryGetValue(c.Id, out var photos);
|
||||
topFeatureByCompany.TryGetValue(c.Id, out var topFeature);
|
||||
featureBreakdownByCompany.TryGetValue(c.Id, out var breakdown);
|
||||
planNames.TryGetValue(c.SubscriptionPlan, out var planName);
|
||||
|
||||
return new AiUsageReportRow
|
||||
{
|
||||
CompanyId = c.Id,
|
||||
CompanyName = c.CompanyName,
|
||||
Plan = planName ?? $"Plan {c.SubscriptionPlan}",
|
||||
IsActive = c.IsActive,
|
||||
Today = u?.Today ?? 0,
|
||||
Last7Days = u?.Last7Days ?? 0,
|
||||
Last30Days = u?.Last30Days ?? 0,
|
||||
AllTime = u?.AllTime ?? 0,
|
||||
PhotoCount = photos,
|
||||
TopFeature = topFeature,
|
||||
CompanyId = c.Id,
|
||||
CompanyName = c.CompanyName,
|
||||
Plan = planName ?? $"Plan {c.SubscriptionPlan}",
|
||||
IsActive = c.IsActive,
|
||||
Today = u?.Today ?? 0,
|
||||
Last7Days = u?.Last7Days ?? 0,
|
||||
Last30Days = u?.Last30Days ?? 0,
|
||||
AllTime = u?.AllTime ?? 0,
|
||||
PhotoCount = photos,
|
||||
TopFeature = topFeature,
|
||||
FeatureBreakdown = breakdown ?? []
|
||||
};
|
||||
})
|
||||
@@ -118,15 +84,14 @@ public class AiUsageReportController : Controller
|
||||
.ThenByDescending(r => r.AllTime)
|
||||
.ToList();
|
||||
|
||||
// Platform totals for summary cards
|
||||
var vm = new AiUsageReportViewModel
|
||||
{
|
||||
Rows = rows,
|
||||
TotalCallsLast30Days = rows.Sum(r => r.Last30Days),
|
||||
TotalCallsToday = rows.Sum(r => r.Today),
|
||||
CompaniesActiveToday = rows.Count(r => r.Today > 0),
|
||||
TotalPhotosUploaded = rows.Sum(r => r.PhotoCount),
|
||||
MostActiveCompany = rows.FirstOrDefault(r => r.Last30Days > 0)?.CompanyName ?? "—"
|
||||
Rows = rows,
|
||||
TotalCallsLast30Days = rows.Sum(r => r.Last30Days),
|
||||
TotalCallsToday = rows.Sum(r => r.Today),
|
||||
CompaniesActiveToday = rows.Count(r => r.Today > 0),
|
||||
TotalPhotosUploaded = rows.Sum(r => r.PhotoCount),
|
||||
MostActiveCompany = rows.FirstOrDefault(r => r.Last30Days > 0)?.CompanyName ?? "—"
|
||||
};
|
||||
|
||||
return View(vm);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -11,12 +10,12 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class AnnouncementsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
|
||||
public AnnouncementsController(ApplicationDbContext db, IInAppNotificationService inApp)
|
||||
public AnnouncementsController(IUnitOfWork unitOfWork, IInAppNotificationService inApp)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
_inApp = inApp;
|
||||
}
|
||||
|
||||
@@ -25,18 +24,18 @@ public class AnnouncementsController : Controller
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var announcements = await _db.Announcements
|
||||
var announcements = (await _unitOfWork.Announcements.GetAllAsync())
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
return View(announcements);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the announcement creation form with sensible defaults: starts now, dismissible, and active.
|
||||
/// </summary>
|
||||
public IActionResult Create()
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
PopulateDropdowns();
|
||||
await PopulateDropdownsAsync();
|
||||
return View(new Announcement { StartsAt = DateTime.Now, IsDismissible = true, IsActive = true });
|
||||
}
|
||||
|
||||
@@ -46,7 +45,7 @@ public class AnnouncementsController : Controller
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(Announcement model)
|
||||
{
|
||||
if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
|
||||
if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(model); }
|
||||
|
||||
model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||
model.CreatedByUserName = User.Identity?.Name ?? "SuperAdmin";
|
||||
@@ -54,10 +53,9 @@ public class AnnouncementsController : Controller
|
||||
model.StartsAt = model.StartsAt.ToUniversalTime();
|
||||
if (model.ExpiresAt.HasValue) model.ExpiresAt = model.ExpiresAt.Value.ToUniversalTime();
|
||||
|
||||
_db.Announcements.Add(model);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.Announcements.AddAsync(model);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Dispatch as in-app notifications to targeted companies
|
||||
await DispatchNotificationsAsync(model);
|
||||
|
||||
TempData["Success"] = "Announcement created and sent as notifications.";
|
||||
@@ -69,9 +67,9 @@ public class AnnouncementsController : Controller
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var announcement = await _db.Announcements.FindAsync(id);
|
||||
var announcement = await _unitOfWork.Announcements.GetByIdAsync(id);
|
||||
if (announcement == null) return NotFound();
|
||||
PopulateDropdowns();
|
||||
await PopulateDropdownsAsync();
|
||||
return View(announcement);
|
||||
}
|
||||
|
||||
@@ -81,9 +79,9 @@ public class AnnouncementsController : Controller
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, Announcement model)
|
||||
{
|
||||
if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
|
||||
if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(model); }
|
||||
|
||||
var existing = await _db.Announcements.FindAsync(id);
|
||||
var existing = await _unitOfWork.Announcements.GetByIdAsync(id);
|
||||
if (existing == null) return NotFound();
|
||||
|
||||
existing.Title = model.Title;
|
||||
@@ -98,7 +96,7 @@ public class AnnouncementsController : Controller
|
||||
existing.IsActive = model.IsActive;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Announcement updated.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
@@ -109,49 +107,42 @@ public class AnnouncementsController : Controller
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var announcement = await _db.Announcements.FindAsync(id);
|
||||
var announcement = await _unitOfWork.Announcements.GetByIdAsync(id);
|
||||
if (announcement == null) return NotFound();
|
||||
_db.Announcements.Remove(announcement);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.Announcements.DeleteAsync(announcement);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Announcement deleted.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context (this runs as SuperAdmin). Filtering by Target/Plan/Company happens before the foreach so only relevant tenants receive the notification.
|
||||
/// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context. Filtering by Target/Plan/Company happens after the fetch so only relevant tenants receive the notification.
|
||||
/// </summary>
|
||||
private async Task DispatchNotificationsAsync(Announcement model)
|
||||
{
|
||||
IQueryable<PowderCoating.Core.Entities.Company> companyQuery = _db.Companies
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted && c.IsActive);
|
||||
var companies = (await _unitOfWork.Companies.FindAsync(
|
||||
c => !c.IsDeleted && c.IsActive, ignoreQueryFilters: true)).ToList();
|
||||
|
||||
if (model.Target == "Plan" && model.TargetPlan.HasValue)
|
||||
companyQuery = companyQuery.Where(c => c.SubscriptionPlan == model.TargetPlan.Value);
|
||||
companies = companies.Where(c => c.SubscriptionPlan == model.TargetPlan.Value).ToList();
|
||||
else if (model.Target == "Company" && model.TargetCompanyId.HasValue)
|
||||
companyQuery = companyQuery.Where(c => c.Id == model.TargetCompanyId.Value);
|
||||
companies = companies.Where(c => c.Id == model.TargetCompanyId.Value).ToList();
|
||||
|
||||
var companyIds = await companyQuery.Select(c => c.Id).ToListAsync();
|
||||
|
||||
foreach (var companyId in companyIds)
|
||||
{
|
||||
await _inApp.CreateAsync(
|
||||
companyId,
|
||||
model.Title,
|
||||
model.Message,
|
||||
"Announcement");
|
||||
}
|
||||
foreach (var company in companies)
|
||||
await _inApp.CreateAsync(company.Id, model.Title, model.Message, "Announcement");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses AsNoTracking and IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting.
|
||||
/// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting.
|
||||
/// </summary>
|
||||
private void PopulateDropdowns()
|
||||
private async Task PopulateDropdownsAsync()
|
||||
{
|
||||
ViewBag.Companies = _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted).OrderBy(c => c.CompanyName)
|
||||
.Select(c => new { c.Id, c.CompanyName }).ToList();
|
||||
ViewBag.PlanConfigs = _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToList();
|
||||
ViewBag.Companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true))
|
||||
.OrderBy(c => c.CompanyName)
|
||||
.Select(c => new { c.Id, c.CompanyName })
|
||||
.ToList();
|
||||
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(p => p.IsActive, ignoreQueryFilters: true))
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// the application — they are append-only by design to maintain an unambiguous
|
||||
/// trail of changes across all tenants.
|
||||
/// </summary>
|
||||
// Intentional exception: platform audit log with a long PK; append-only infrastructure table outside the business entity graph; same reasoning as SystemLogsController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class AuditLogController : Controller
|
||||
{
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -15,28 +14,26 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class BannedIpsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<BannedIpsController> _logger;
|
||||
|
||||
public BannedIpsController(
|
||||
ApplicationDbContext db,
|
||||
IUnitOfWork unitOfWork,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<BannedIpsController> logger)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Lists all banned IPs, showing active and expired separately.</summary>
|
||||
// GET: BannedIps
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var bans = await _db.BannedIps
|
||||
var bans = (await _unitOfWork.BannedIps.GetAllAsync())
|
||||
.OrderByDescending(b => b.BannedAt)
|
||||
.ToListAsync();
|
||||
|
||||
.ToList();
|
||||
return View(bans);
|
||||
}
|
||||
|
||||
@@ -44,9 +41,7 @@ public class BannedIpsController : Controller
|
||||
/// Adds a new IP ban. Rejects obviously invalid formats but doesn't require
|
||||
/// a perfect regex — admins are trusted to enter valid IPs.
|
||||
/// </summary>
|
||||
// POST: BannedIps/Add
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Add(string ipAddress, string? reason, DateTime? expiresAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ipAddress))
|
||||
@@ -57,15 +52,13 @@ public class BannedIpsController : Controller
|
||||
|
||||
ipAddress = ipAddress.Trim();
|
||||
|
||||
// Basic sanity check — must look like an IPv4 or IPv6 address
|
||||
if (!System.Net.IPAddress.TryParse(ipAddress, out _))
|
||||
{
|
||||
TempData["Error"] = $"'{ipAddress}' is not a valid IP address.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Don't duplicate an active ban for the same IP
|
||||
var existing = await _db.BannedIps.FirstOrDefaultAsync(b => b.IpAddress == ipAddress && b.IsActive);
|
||||
var existing = await _unitOfWork.BannedIps.FirstOrDefaultAsync(b => b.IpAddress == ipAddress && b.IsActive);
|
||||
if (existing != null)
|
||||
{
|
||||
TempData["Error"] = $"{ipAddress} already has an active ban (added {existing.BannedAt:MMM dd, yyyy}).";
|
||||
@@ -74,7 +67,7 @@ public class BannedIpsController : Controller
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
_db.BannedIps.Add(new BannedIp
|
||||
await _unitOfWork.BannedIps.AddAsync(new BannedIp
|
||||
{
|
||||
IpAddress = ipAddress,
|
||||
Reason = reason?.Trim(),
|
||||
@@ -83,8 +76,7 @@ public class BannedIpsController : Controller
|
||||
ExpiresAt = expiresAt,
|
||||
IsActive = true
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogWarning("IP {IP} banned by {Admin}. Reason: {Reason}", ipAddress, User.Identity?.Name, reason);
|
||||
TempData["Success"] = $"{ipAddress} has been banned.";
|
||||
@@ -92,12 +84,10 @@ public class BannedIpsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>Lifts a ban immediately by marking IsActive = false.</summary>
|
||||
// POST: BannedIps/Lift/5
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Lift(int id)
|
||||
{
|
||||
var ban = await _db.BannedIps.FindAsync(id);
|
||||
var ban = await _unitOfWork.BannedIps.GetByIdAsync(id);
|
||||
if (ban == null)
|
||||
{
|
||||
TempData["Error"] = "Ban not found.";
|
||||
@@ -105,7 +95,7 @@ public class BannedIpsController : Controller
|
||||
}
|
||||
|
||||
ban.IsActive = false;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("IP ban lifted for {IP} by {Admin}", ban.IpAddress, User.Identity?.Name);
|
||||
TempData["Success"] = $"Ban on {ban.IpAddress} has been lifted.";
|
||||
@@ -113,25 +103,21 @@ public class BannedIpsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>Permanently deletes a ban record.</summary>
|
||||
// POST: BannedIps/Delete/5
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var ban = await _db.BannedIps.FindAsync(id);
|
||||
var ban = await _unitOfWork.BannedIps.GetByIdAsync(id);
|
||||
if (ban != null)
|
||||
{
|
||||
_db.BannedIps.Remove(ban);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.BannedIps.DeleteAsync(ban);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
_logger.LogInformation("IP ban record deleted for {IP} by {Admin}", ban.IpAddress, User.Identity?.Name);
|
||||
TempData["Success"] = $"Ban record for {ban.IpAddress} deleted.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>Returns the requesting client's IP so the admin can pre-fill it quickly.</summary>
|
||||
// GET: BannedIps/MyIp
|
||||
public IActionResult MyIp()
|
||||
{
|
||||
return Json(new { ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown" });
|
||||
|
||||
@@ -14,7 +14,6 @@ using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Application.DTOs.PurchaseOrder;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -29,7 +28,6 @@ public class BillsController : Controller
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<BillsController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
private readonly IAccountingAiService _accountingAi;
|
||||
private readonly IAzureBlobStorageService _blobStorage;
|
||||
@@ -41,7 +39,6 @@ public class BillsController : Controller
|
||||
IMapper mapper,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<BillsController> logger,
|
||||
ApplicationDbContext context,
|
||||
IAccountBalanceService accountBalanceService,
|
||||
IAccountingAiService accountingAi,
|
||||
IAzureBlobStorageService blobStorage,
|
||||
@@ -52,7 +49,6 @@ public class BillsController : Controller
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
_accountingAi = accountingAi;
|
||||
_blobStorage = blobStorage;
|
||||
@@ -86,23 +82,7 @@ public class BillsController : Controller
|
||||
// Bills
|
||||
if (type == null || type == "Bill")
|
||||
{
|
||||
var bills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => !b.IsDeleted)
|
||||
.Where(b => status != "Unpaid" ||
|
||||
(b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid))
|
||||
.Where(b => status != "Overdue" ||
|
||||
(b.Status != BillStatus.Paid && b.Status != BillStatus.Voided &&
|
||||
b.DueDate.HasValue && b.DueDate.Value.Date < DateTime.Today))
|
||||
.Where(b => string.IsNullOrEmpty(search) ||
|
||||
b.BillNumber.Contains(search) ||
|
||||
b.Vendor.CompanyName.Contains(search) ||
|
||||
(b.VendorInvoiceNumber != null && b.VendorInvoiceNumber.Contains(search)) ||
|
||||
(b.Memo != null && b.Memo.Contains(search)) ||
|
||||
b.LineItems.Any(li => li.Description.Contains(search)) ||
|
||||
(searchAmount.HasValue && (b.Total == searchAmount.Value || b.AmountPaid == searchAmount.Value)))
|
||||
.OrderByDescending(b => b.BillDate)
|
||||
.ToListAsync();
|
||||
var bills = await _unitOfWork.Bills.GetForIndexAsync(status, search, searchAmount);
|
||||
|
||||
entries.AddRange(bills.Select(b => new BillExpenseListDto
|
||||
{
|
||||
@@ -133,17 +113,17 @@ public class BillsController : Controller
|
||||
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
|
||||
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
|
||||
{
|
||||
var expenses = await _context.Set<Core.Entities.Expense>()
|
||||
.Include(e => e.Vendor)
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Where(e => !e.IsDeleted)
|
||||
.Where(e => string.IsNullOrEmpty(search) ||
|
||||
e.ExpenseNumber.Contains(search) ||
|
||||
(e.Vendor != null && e.Vendor.CompanyName.Contains(search)) ||
|
||||
(e.Memo != null && e.Memo.Contains(search)) ||
|
||||
(searchAmount.HasValue && e.Amount == searchAmount.Value))
|
||||
.OrderByDescending(e => e.Date)
|
||||
.ToListAsync();
|
||||
var expSearch = search;
|
||||
var expAmount = searchAmount;
|
||||
var expenseList = await _unitOfWork.Expenses.FindAsync(
|
||||
e => string.IsNullOrEmpty(expSearch) ||
|
||||
e.ExpenseNumber.Contains(expSearch) ||
|
||||
(e.Vendor != null && e.Vendor.CompanyName.Contains(expSearch)) ||
|
||||
(e.Memo != null && e.Memo.Contains(expSearch)) ||
|
||||
(expAmount.HasValue && e.Amount == expAmount.Value),
|
||||
false,
|
||||
e => e.Vendor!, e => e.ExpenseAccount!);
|
||||
var expenses = expenseList.OrderByDescending(e => e.Date).ToList();
|
||||
|
||||
entries.AddRange(expenses.Select(e => new BillExpenseListDto
|
||||
{
|
||||
@@ -198,11 +178,7 @@ public class BillsController : Controller
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var po = await _context.Set<PurchaseOrder>()
|
||||
.Include(p => p.Vendor)
|
||||
.Include(p => p.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.InventoryItem)
|
||||
.FirstOrDefaultAsync(p => p.Id == purchaseOrderId && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
|
||||
var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(purchaseOrderId, currentUser.CompanyId);
|
||||
|
||||
if (po == null) return NotFound();
|
||||
|
||||
@@ -218,20 +194,16 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id = po.BillId });
|
||||
}
|
||||
|
||||
var apAccount = await _context.Accounts
|
||||
.Where(a => !a.IsDeleted && a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountSubType == AccountSubType.AccountsPayable);
|
||||
|
||||
// Vendor default expense account, fall back to first expense/COGS account
|
||||
int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId;
|
||||
if (!defaultExpenseAccountId.HasValue)
|
||||
{
|
||||
defaultExpenseAccountId = (await _context.Accounts
|
||||
.Where(a => !a.IsDeleted && a.IsActive &&
|
||||
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods))
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.FirstOrDefaultAsync())?.Id;
|
||||
var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
|
||||
defaultExpenseAccountId = fallbackAccount?.Id;
|
||||
}
|
||||
|
||||
var lineItems = po.Items
|
||||
@@ -293,10 +265,8 @@ public class BillsController : Controller
|
||||
};
|
||||
|
||||
// Pre-fill AP account
|
||||
var apAccount = await _context.Accounts
|
||||
.Where(a => !a.IsDeleted && a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountSubType == AccountSubType.AccountsPayable);
|
||||
dto.APAccountId = apAccount?.Id ?? 0;
|
||||
|
||||
// Pre-fill default expense account for vendor
|
||||
@@ -385,13 +355,12 @@ public class BillsController : Controller
|
||||
// Link bill back to source PO if created from one
|
||||
if (dto.PurchaseOrderId > 0)
|
||||
{
|
||||
var po = await _context.Set<PurchaseOrder>()
|
||||
.FirstOrDefaultAsync(p => p.Id == dto.PurchaseOrderId && !p.IsDeleted);
|
||||
var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value);
|
||||
if (po != null)
|
||||
{
|
||||
po.BillId = bill.Id;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,8 +385,8 @@ public class BillsController : Controller
|
||||
bill.AmountPaid = payment.Amount;
|
||||
bill.Status = bill.AmountPaid >= bill.Total ? BillStatus.Paid : BillStatus.PartiallyPaid;
|
||||
|
||||
await _context.BillPayments.AddAsync(payment);
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.BillPayments.AddAsync(payment);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Bill {bill.BillNumber} saved and marked as paid.";
|
||||
}
|
||||
@@ -448,28 +417,18 @@ public class BillsController : Controller
|
||||
{
|
||||
if (id == null) return NotFound();
|
||||
|
||||
var bill = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Include(b => b.APAccount)
|
||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||
.ThenInclude(li => li.Account)
|
||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||
.ThenInclude(li => li.Job)
|
||||
.Include(b => b.Payments.Where(p => !p.IsDeleted))
|
||||
.ThenInclude(p => p.BankAccount)
|
||||
.FirstOrDefaultAsync(b => b.Id == id && !b.IsDeleted);
|
||||
|
||||
var bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value);
|
||||
if (bill == null) return NotFound();
|
||||
|
||||
var dto = _mapper.Map<BillDto>(bill);
|
||||
|
||||
// Payment form defaults
|
||||
var bankAccounts = await _context.Accounts
|
||||
.Where(a => !a.IsDeleted && (a.AccountSubType == AccountSubType.Checking ||
|
||||
a.AccountSubType == AccountSubType.Savings ||
|
||||
a.AccountSubType == AccountSubType.CreditCard))
|
||||
var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.AccountSubType == AccountSubType.Checking ||
|
||||
a.AccountSubType == AccountSubType.Savings ||
|
||||
a.AccountSubType == AccountSubType.CreditCard))
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
ViewBag.BankAccounts = bankAccounts
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
@@ -495,10 +454,7 @@ public class BillsController : Controller
|
||||
{
|
||||
if (id == null) return NotFound();
|
||||
|
||||
var bill = await _context.Bills
|
||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||
.FirstOrDefaultAsync(b => b.Id == id && !b.IsDeleted);
|
||||
|
||||
var bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value);
|
||||
if (bill == null) return NotFound();
|
||||
|
||||
if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided)
|
||||
@@ -561,9 +517,7 @@ public class BillsController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
var bill = await _context.Bills
|
||||
.Include(b => b.LineItems)
|
||||
.FirstOrDefaultAsync(b => b.Id == id && !b.IsDeleted);
|
||||
var bill = await _unitOfWork.Bills.LoadForEditAsync(id);
|
||||
|
||||
if (bill == null) return NotFound();
|
||||
|
||||
@@ -611,7 +565,7 @@ public class BillsController : Controller
|
||||
bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
|
||||
bill.Total = bill.SubTotal + bill.TaxAmount;
|
||||
|
||||
await _context.BillLineItems.AddRangeAsync(newLineItems);
|
||||
await _unitOfWork.BillLineItems.AddRangeAsync(newLineItems);
|
||||
|
||||
// Handle receipt file replacement
|
||||
if (receiptFile != null && receiptFile.Length > 0)
|
||||
@@ -628,7 +582,7 @@ public class BillsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Bill {bill.BillNumber} updated.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
@@ -1024,12 +978,7 @@ public class BillsController : Controller
|
||||
private async Task<string> GenerateBillNumberAsync()
|
||||
{
|
||||
var prefix = $"BILL-{DateTime.Now:yyMM}-";
|
||||
var last = await _context.Bills
|
||||
.IgnoreQueryFilters()
|
||||
.Where(b => b.BillNumber.StartsWith(prefix))
|
||||
.OrderByDescending(b => b.BillNumber)
|
||||
.Select(b => b.BillNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
var last = await _unitOfWork.Bills.GetLastBillNumberAsync(prefix);
|
||||
|
||||
int next = 1;
|
||||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||||
@@ -1046,12 +995,7 @@ public class BillsController : Controller
|
||||
private async Task<string> GeneratePaymentNumberAsync()
|
||||
{
|
||||
var prefix = $"BPMT-{DateTime.Now:yyMM}-";
|
||||
var last = await _context.BillPayments
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.PaymentNumber.StartsWith(prefix))
|
||||
.OrderByDescending(p => p.PaymentNumber)
|
||||
.Select(p => p.PaymentNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
var last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(prefix);
|
||||
|
||||
int next = 1;
|
||||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||||
|
||||
@@ -10,7 +10,6 @@ using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -22,7 +21,6 @@ public class BugReportController : Controller
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IAdminNotificationService _adminNotification;
|
||||
private readonly IAzureBlobStorageService _blobService;
|
||||
@@ -40,7 +38,6 @@ public class BugReportController : Controller
|
||||
IMapper mapper,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ApplicationDbContext context,
|
||||
IEmailService emailService,
|
||||
IAdminNotificationService adminNotification,
|
||||
IAzureBlobStorageService blobService,
|
||||
@@ -51,7 +48,6 @@ public class BugReportController : Controller
|
||||
_mapper = mapper;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_context = context;
|
||||
_emailService = emailService;
|
||||
_adminNotification = adminNotification;
|
||||
_blobService = blobService;
|
||||
@@ -153,7 +149,7 @@ public class BugReportController : Controller
|
||||
ContentType = file.ContentType,
|
||||
FileSizeBytes = file.Length
|
||||
};
|
||||
_context.BugReportAttachments.Add(attachment);
|
||||
await _unitOfWork.BugReportAttachments.AddAsync(attachment);
|
||||
uploadedCount++;
|
||||
}
|
||||
else
|
||||
@@ -164,7 +160,7 @@ public class BugReportController : Controller
|
||||
}
|
||||
|
||||
if (uploadedCount > 0)
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Bug report #{Id} submitted by {UserName} ({Company}): {Title} with {AttachmentCount} attachment(s)",
|
||||
@@ -211,16 +207,13 @@ public class BugReportController : Controller
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||
|
||||
var query = _context.BugReports
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(r => !r.IsDeleted)
|
||||
.AsQueryable();
|
||||
var allReports = (await _unitOfWork.BugReports.GetAllAsync(ignoreQueryFilters: true))
|
||||
.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var search = searchTerm.ToLower();
|
||||
query = query.Where(r =>
|
||||
allReports = allReports.Where(r =>
|
||||
r.Title.ToLower().Contains(search) ||
|
||||
r.Description.ToLower().Contains(search) ||
|
||||
r.SubmittedByUserName.ToLower().Contains(search));
|
||||
@@ -228,31 +221,31 @@ public class BugReportController : Controller
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(statusFilter) &&
|
||||
Enum.TryParse<BugReportStatus>(statusFilter, out var status))
|
||||
query = query.Where(r => r.Status == status);
|
||||
allReports = allReports.Where(r => r.Status == status);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(priorityFilter) &&
|
||||
Enum.TryParse<BugReportPriority>(priorityFilter, out var priority))
|
||||
query = query.Where(r => r.Priority == priority);
|
||||
allReports = allReports.Where(r => r.Priority == priority);
|
||||
|
||||
query = (sortColumn, sortDirection == "asc") switch
|
||||
allReports = (sortColumn, sortDirection == "asc") switch
|
||||
{
|
||||
("Title", true) => query.OrderBy(r => r.Title),
|
||||
("Title", false) => query.OrderByDescending(r => r.Title),
|
||||
("Status", true) => query.OrderBy(r => r.Status),
|
||||
("Status", false) => query.OrderByDescending(r => r.Status),
|
||||
("Priority", true) => query.OrderBy(r => r.Priority),
|
||||
("Priority", false) => query.OrderByDescending(r => r.Priority),
|
||||
("Submitted", true) => query.OrderBy(r => r.SubmittedByUserName),
|
||||
("Submitted", false) => query.OrderByDescending(r => r.SubmittedByUserName),
|
||||
(_, true) => query.OrderBy(r => r.CreatedAt),
|
||||
_ => query.OrderByDescending(r => r.CreatedAt)
|
||||
("Title", true) => allReports.OrderBy(r => r.Title),
|
||||
("Title", false) => allReports.OrderByDescending(r => r.Title),
|
||||
("Status", true) => allReports.OrderBy(r => r.Status),
|
||||
("Status", false) => allReports.OrderByDescending(r => r.Status),
|
||||
("Priority", true) => allReports.OrderBy(r => r.Priority),
|
||||
("Priority", false) => allReports.OrderByDescending(r => r.Priority),
|
||||
("Submitted", true) => allReports.OrderBy(r => r.SubmittedByUserName),
|
||||
("Submitted", false) => allReports.OrderByDescending(r => r.SubmittedByUserName),
|
||||
(_, true) => allReports.OrderBy(r => r.CreatedAt),
|
||||
_ => allReports.OrderByDescending(r => r.CreatedAt)
|
||||
};
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var items = await query
|
||||
var totalCount = allReports.Count();
|
||||
var items = allReports
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var dtos = _mapper.Map<List<BugReportDto>>(items);
|
||||
|
||||
@@ -291,12 +284,10 @@ public class BugReportController : Controller
|
||||
|
||||
var dto = _mapper.Map<EditBugReportDto>(bugReport);
|
||||
|
||||
var attachments = await _context.BugReportAttachments
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(a => a.BugReportId == id && !a.IsDeleted)
|
||||
var attachments = (await _unitOfWork.BugReportAttachments.FindAsync(
|
||||
a => a.BugReportId == id && !a.IsDeleted, ignoreQueryFilters: true))
|
||||
.OrderBy(a => a.CreatedAt)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
dto.Attachments = _mapper.Map<List<BugReportAttachmentDto>>(attachments);
|
||||
|
||||
@@ -319,10 +310,7 @@ public class BugReportController : Controller
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Attachment(int id)
|
||||
{
|
||||
var attachment = await _context.BugReportAttachments
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(a => a.Id == id && !a.IsDeleted);
|
||||
var attachment = await _unitOfWork.BugReportAttachments.GetByIdAsync(id, ignoreQueryFilters: true);
|
||||
|
||||
if (attachment == null)
|
||||
return NotFound();
|
||||
|
||||
@@ -7,7 +7,7 @@ using PowderCoating.Application.DTOs.Company;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Extensions;
|
||||
using System.Security.Claims;
|
||||
@@ -24,7 +24,9 @@ public class CompaniesController : Controller
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ISeedDataService _seedDataService;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ICompanyListService _companyList;
|
||||
private readonly ICompanyDataPurgeService _companyPurge;
|
||||
private readonly IAuditLogService _auditLog;
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
private readonly ILogger<CompaniesController> _logger;
|
||||
|
||||
@@ -33,7 +35,9 @@ public class CompaniesController : Controller
|
||||
IMapper mapper,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ISeedDataService seedDataService,
|
||||
ApplicationDbContext context,
|
||||
ICompanyListService companyList,
|
||||
ICompanyDataPurgeService companyPurge,
|
||||
IAuditLogService auditLog,
|
||||
IInAppNotificationService inApp,
|
||||
ILogger<CompaniesController> logger)
|
||||
{
|
||||
@@ -41,7 +45,9 @@ public class CompaniesController : Controller
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
_seedDataService = seedDataService;
|
||||
_context = context;
|
||||
_companyList = companyList;
|
||||
_companyPurge = companyPurge;
|
||||
_auditLog = auditLog;
|
||||
_inApp = inApp;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -67,88 +73,27 @@ public class CompaniesController : Controller
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||
|
||||
var query = _context.Companies
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var s = searchTerm.ToLower();
|
||||
query = query.Where(c =>
|
||||
c.CompanyName.ToLower().Contains(s) ||
|
||||
(c.CompanyCode != null && c.CompanyCode.ToLower().Contains(s)) ||
|
||||
(c.PrimaryContactEmail != null && c.PrimaryContactEmail.ToLower().Contains(s)) ||
|
||||
(c.Phone != null && c.Phone.ToLower().Contains(s)));
|
||||
}
|
||||
|
||||
query = (sortColumn, sortDirection == "asc") switch
|
||||
{
|
||||
("CompanyName", true) => query.OrderBy(c => c.CompanyName),
|
||||
("CompanyName", false) => query.OrderByDescending(c => c.CompanyName),
|
||||
("Plan", true) => query.OrderBy(c => c.SubscriptionPlan),
|
||||
("Plan", false) => query.OrderByDescending(c => c.SubscriptionPlan),
|
||||
("Status", true) => query.OrderBy(c => c.IsActive),
|
||||
("Status", false) => query.OrderByDescending(c => c.IsActive),
|
||||
("Created", true) => query.OrderBy(c => c.CreatedAt),
|
||||
("Created", false) => query.OrderByDescending(c => c.CreatedAt),
|
||||
_ => query.OrderBy(c => c.CompanyName)
|
||||
};
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var companies = await query
|
||||
.Include(c => c.Users)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
var (companies, totalCount) = await _companyList.GetPagedAsync(
|
||||
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
|
||||
|
||||
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
||||
|
||||
// Populate job/quote/customer counts efficiently via group queries
|
||||
if (companyDtos.Any())
|
||||
if (companyDtos.Count > 0)
|
||||
{
|
||||
var ids = companyDtos.Select(c => c.Id).ToList();
|
||||
|
||||
var jobCounts = await _context.Jobs.IgnoreQueryFilters()
|
||||
.Where(j => ids.Contains(j.CompanyId) && !j.IsDeleted)
|
||||
.GroupBy(j => j.CompanyId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
var quoteCounts = await _context.Quotes.IgnoreQueryFilters()
|
||||
.Where(q => ids.Contains(q.CompanyId) && !q.IsDeleted)
|
||||
.GroupBy(q => q.CompanyId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
var customerCounts = await _context.Customers.IgnoreQueryFilters()
|
||||
.Where(c => ids.Contains(c.CompanyId) && !c.IsDeleted)
|
||||
.GroupBy(c => c.CompanyId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
var wizardData = await _context.CompanyPreferences.IgnoreQueryFilters()
|
||||
.Where(p => ids.Contains(p.CompanyId) && p.SetupWizardCompleted)
|
||||
.Select(p => new
|
||||
{
|
||||
p.CompanyId,
|
||||
p.SetupWizardCompletedAt,
|
||||
p.SetupWizardCompletedByName
|
||||
})
|
||||
.ToDictionaryAsync(x => x.CompanyId);
|
||||
var summary = await _companyList.GetCountSummaryAsync(ids);
|
||||
|
||||
foreach (var dto in companyDtos)
|
||||
{
|
||||
dto.JobCount = jobCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.QuoteCount = quoteCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.CustomerCount = customerCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.JobCount = summary.JobCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.QuoteCount = summary.QuoteCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.CustomerCount = summary.CustomerCounts.GetValueOrDefault(dto.Id, 0);
|
||||
|
||||
if (wizardData.TryGetValue(dto.Id, out var w))
|
||||
if (summary.WizardInfo.TryGetValue(dto.Id, out var w))
|
||||
{
|
||||
dto.WizardCompleted = true;
|
||||
dto.WizardCompletedAt = w.SetupWizardCompletedAt;
|
||||
dto.WizardCompletedByName = w.SetupWizardCompletedByName;
|
||||
dto.WizardCompletedAt = w.CompletedAt;
|
||||
dto.WizardCompletedByName = w.CompletedByName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,8 +325,6 @@ public class CompaniesController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
// If user creation failed, we should consider rolling back company creation
|
||||
// For now, log the error and inform the user
|
||||
_logger.LogError("Failed to create admin user for company {CompanyName}: {Errors}",
|
||||
company.CompanyName, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
|
||||
@@ -441,14 +384,10 @@ public class CompaniesController : Controller
|
||||
public async Task<IActionResult> Edit(int id, UpdateCompanyDto model)
|
||||
{
|
||||
if (id != model.Id)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -473,9 +412,7 @@ public class CompaniesController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Update company properties
|
||||
_mapper.Map(model, company);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Company {CompanyName} updated successfully by {User}",
|
||||
@@ -560,7 +497,6 @@ public class CompaniesController : Controller
|
||||
var companyName = company.CompanyName;
|
||||
var userCount = company.Users.Count;
|
||||
|
||||
// Soft-delete the company and deactivate all users
|
||||
company.IsDeleted = true;
|
||||
company.IsActive = false;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -573,8 +509,7 @@ public class CompaniesController : Controller
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Write audit log
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
await _auditLog.LogAsync(new AuditLog
|
||||
{
|
||||
UserId = adminUserId,
|
||||
UserName = adminName,
|
||||
@@ -588,7 +523,6 @@ public class CompaniesController : Controller
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) soft-deleted by {User}",
|
||||
companyName, id, adminName);
|
||||
@@ -625,8 +559,7 @@ public class CompaniesController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var company = await _context.Companies.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
|
||||
|
||||
if (company == null)
|
||||
{
|
||||
@@ -640,91 +573,25 @@ public class CompaniesController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
// ── Tier 1: Leaf children (must go before their parents) ─────────────
|
||||
// JobItem children
|
||||
await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
|
||||
// QuoteItem children
|
||||
await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
|
||||
// AnnouncementDismissals (no CompanyId — delete by user or company-targeted announcement)
|
||||
// Load user IDs first — needed for announcement-dismissal cleanup in the purge service
|
||||
var userIds = await _userManager.Users.IgnoreQueryFilters()
|
||||
.Where(u => u.CompanyId == id).Select(u => u.Id).ToListAsync();
|
||||
var announcementIds = await _context.Announcements
|
||||
.Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync();
|
||||
await _context.AnnouncementDismissals.IgnoreQueryFilters()
|
||||
.Where(x => userIds.Contains(x.UserId) || announcementIds.Contains(x.AnnouncementId))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 2: Mid-level children ────────────────────────────────────────
|
||||
await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
// Tiers 1-4: bulk delete all business data (service mirrors the original tier ordering)
|
||||
await _companyPurge.DeleteAllBusinessDataAsync(id, userIds);
|
||||
|
||||
// ── Tier 3: Top-level company entities ───────────────────────────────
|
||||
// Order matters: child-side of FK must be deleted before parent-side.
|
||||
// Invoices/Appointments → Customers; Bills/Expenses → Vendors
|
||||
await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
// Announcements are platform-wide; only delete company-targeted ones (TargetCompanyId)
|
||||
await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 4: Company configs and lookup tables ─────────────────────────
|
||||
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobPriorityLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.AppointmentStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.AppointmentTypeLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.PricingTiers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CompanyOperatingCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CompanyPreferences.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 5: Users (via Identity to cascade AspNetUser* tables) ────────
|
||||
// Tier 5: delete Identity users so AspNetUser* tables cascade correctly
|
||||
var users = await _userManager.Users.IgnoreQueryFilters()
|
||||
.Where(u => u.CompanyId == id).ToListAsync();
|
||||
var userCount = users.Count;
|
||||
foreach (var user in users)
|
||||
await _userManager.DeleteAsync(user);
|
||||
|
||||
// ── Tier 6: Company record ────────────────────────────────────────────
|
||||
await _context.Companies.IgnoreQueryFilters().Where(c => c.Id == id).ExecuteDeleteAsync();
|
||||
// Tier 6: delete company record
|
||||
await _unitOfWork.Companies.DeleteAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Write audit log (use platform default company context — no companyId since it's gone)
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
await _auditLog.LogAsync(new AuditLog
|
||||
{
|
||||
UserId = adminUserId,
|
||||
UserName = adminName,
|
||||
@@ -738,7 +605,6 @@ public class CompaniesController : Controller
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) HARD DELETED by {User}. {UserCount} users removed.",
|
||||
companyName, id, adminName, userCount);
|
||||
@@ -764,8 +630,6 @@ public class CompaniesController : Controller
|
||||
/// to record the action. This operation is irreversible.
|
||||
/// </summary>
|
||||
// POST: Companies/ResetData/5
|
||||
// Permanently hard-deletes all business data for a company while keeping the company record,
|
||||
// its users, operating costs, preferences, and lookup tables intact.
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ResetData(int id, string confirmation)
|
||||
@@ -776,8 +640,7 @@ public class CompaniesController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var company = await _context.Companies.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
|
||||
|
||||
if (company == null)
|
||||
{
|
||||
@@ -791,94 +654,9 @@ public class CompaniesController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
// ── Tier 0: Grandchildren ─────────────────────────────────────────────
|
||||
await _context.JobTemplateItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobTemplateItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.GiftCertificateRedemptions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CreditMemoApplications .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.OvenBatchItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _companyPurge.ResetBusinessDataAsync(id);
|
||||
|
||||
// AnnouncementDismissals for company-targeted announcements
|
||||
var announcementIds = await _context.Announcements
|
||||
.Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync();
|
||||
if (announcementIds.Any())
|
||||
await _context.AnnouncementDismissals.IgnoreQueryFilters()
|
||||
.Where(x => announcementIds.Contains(x.AnnouncementId))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 1: Children ──────────────────────────────────────────────────
|
||||
await _context.JobTemplateItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobStatusHistory .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobChangeHistories .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobPhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobDailyPriorities .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobTimeEntries .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.ReworkRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuotePrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.QuotePhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CustomerNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.MaintenanceRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.BillLineItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.BillPayments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Payments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Deposits .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InvoiceItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.PurchaseOrderItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.AiItemPredictions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.PowderUsageLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkerRoleCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.OvenBatches .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Refunds .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CreditMemos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.GiftCertificates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 2: Top-level business entities ──────────────────────────────
|
||||
// Order matters: child-side of FK must be deleted before parent-side.
|
||||
// Invoices/Appointments → Customers; Bills/PurchaseOrders/Expenses → Vendors
|
||||
await _context.Invoices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Appointments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Jobs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.JobTemplates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Quotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Customers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Bills .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.PurchaseOrders .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Expenses .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Vendors .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CatalogItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InventoryItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Equipment .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.OvenCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.Accounts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.NotificationLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.PrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
|
||||
// Company-targeted announcements only (platform-wide announcements are left alone)
|
||||
await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync();
|
||||
|
||||
// Reset QB migration wizard progress
|
||||
var prefs = await _context.CompanyPreferences.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == id);
|
||||
if (prefs?.QbMigrationStateJson != null)
|
||||
{
|
||||
prefs.QbMigrationStateJson = null;
|
||||
prefs.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Audit log
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
await _auditLog.LogAsync(new AuditLog
|
||||
{
|
||||
UserId = adminUserId,
|
||||
UserName = adminName,
|
||||
@@ -892,7 +670,6 @@ public class CompaniesController : Controller
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning(
|
||||
"Company {CompanyName} (ID:{CompanyId}) ALL BUSINESS DATA RESET by {User}.",
|
||||
@@ -960,9 +737,7 @@ public class CompaniesController : Controller
|
||||
{
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(model.CompanyId, ignoreQueryFilters: true);
|
||||
if (company != null)
|
||||
{
|
||||
model.CompanyName = company.CompanyName;
|
||||
}
|
||||
return View(model);
|
||||
}
|
||||
|
||||
@@ -976,7 +751,6 @@ public class CompaniesController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
var existingUser = await _userManager.FindByEmailAsync(model.Email);
|
||||
if (existingUser != null)
|
||||
{
|
||||
@@ -985,7 +759,6 @@ public class CompaniesController : Controller
|
||||
return View(model);
|
||||
}
|
||||
|
||||
// Create admin user for the company
|
||||
var adminUser = new ApplicationUser
|
||||
{
|
||||
UserName = model.Email,
|
||||
@@ -1018,9 +791,7 @@ public class CompaniesController : Controller
|
||||
else
|
||||
{
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
ModelState.AddModelError("", error.Description);
|
||||
}
|
||||
model.CompanyName = company.CompanyName;
|
||||
return View(model);
|
||||
}
|
||||
@@ -1054,21 +825,13 @@ public class CompaniesController : Controller
|
||||
return NotFound(new { error = "User not found." });
|
||||
|
||||
// Use the viewed company's timezone so timestamps match the tenant's local time
|
||||
var tz = await _context.Companies
|
||||
.Where(c => c.Id == companyId)
|
||||
.Select(c => c.TimeZone)
|
||||
.FirstOrDefaultAsync();
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
||||
var tz = company?.TimeZone;
|
||||
|
||||
var logs = new List<dynamic>();
|
||||
try
|
||||
{
|
||||
var rawLogs = await _context.AuditLogs
|
||||
.AsNoTracking()
|
||||
.Where(l => l.UserId == userId && l.EntityType == "ApplicationUser")
|
||||
.OrderByDescending(l => l.Timestamp)
|
||||
.Take(50)
|
||||
.Select(l => new { l.Action, l.IpAddress, l.Timestamp, l.NewValues })
|
||||
.ToListAsync();
|
||||
var rawLogs = await _auditLog.GetUserActivityAsync(userId);
|
||||
|
||||
logs = rawLogs.Select(l => (dynamic)new
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.Security.Claims;
|
||||
@@ -29,7 +30,7 @@ public class CompanySettingsController : Controller
|
||||
private readonly ILookupCacheService _lookupCache;
|
||||
private readonly IStripeConnectService _stripeConnect;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IAuditLogService _auditLog;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||
|
||||
@@ -42,7 +43,7 @@ public class CompanySettingsController : Controller
|
||||
ILookupCacheService lookupCache,
|
||||
IStripeConnectService stripeConnect,
|
||||
IConfiguration configuration,
|
||||
ApplicationDbContext context,
|
||||
IAuditLogService auditLog,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
SignInManager<ApplicationUser> signInManager)
|
||||
{
|
||||
@@ -54,7 +55,7 @@ public class CompanySettingsController : Controller
|
||||
_lookupCache = lookupCache;
|
||||
_stripeConnect = stripeConnect;
|
||||
_configuration = configuration;
|
||||
_context = context;
|
||||
_auditLog = auditLog;
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
}
|
||||
@@ -126,9 +127,7 @@ public class CompanySettingsController : Controller
|
||||
var dto = _mapper.Map<CompanySettingsDto>(company);
|
||||
|
||||
// Populate AllowOnlinePayments from subscription plan config
|
||||
var planConfig = await _context.Set<SubscriptionPlanConfig>()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
||||
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
||||
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
|
||||
|
||||
// Flag whether Stripe Connect is configured (non-placeholder client ID)
|
||||
@@ -2805,10 +2804,10 @@ public class CompanySettingsController : Controller
|
||||
if (company == null) return NotFound();
|
||||
|
||||
var userCount = await _userManager.Users.CountAsync(u => u.CompanyId == companyId.Value);
|
||||
var jobCount = await _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
|
||||
var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
|
||||
var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value);
|
||||
var invCount = await _context.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
|
||||
var jobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
|
||||
var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
|
||||
var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value);
|
||||
var invCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
|
||||
|
||||
ViewBag.CompanyName = company.CompanyName;
|
||||
ViewBag.UserCount = userCount;
|
||||
@@ -2859,10 +2858,10 @@ public class CompanySettingsController : Controller
|
||||
|
||||
// ── Gather counts for the audit snapshot ─────────────────────────
|
||||
var userCount = company.Users.Count;
|
||||
var jobCount = await _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
|
||||
var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
|
||||
var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value);
|
||||
var invCount = await _context.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
|
||||
var jobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
|
||||
var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
|
||||
var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value);
|
||||
var invCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
|
||||
|
||||
// ── Soft-delete the company ───────────────────────────────────────
|
||||
var now = DateTime.UtcNow;
|
||||
@@ -2881,7 +2880,7 @@ public class CompanySettingsController : Controller
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// ── Write audit log ───────────────────────────────────────────────
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
await _auditLog.LogAsync(new AuditLog
|
||||
{
|
||||
UserId = requestingUserId,
|
||||
UserName = requestingUserName,
|
||||
@@ -2899,7 +2898,6 @@ public class CompanySettingsController : Controller
|
||||
Timestamp = now,
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning(
|
||||
"Self-service account deletion: company {CompanyName} (ID:{CompanyId}) deleted by {User}. " +
|
||||
|
||||
@@ -9,7 +9,6 @@ using PowderCoating.Application.DTOs.User;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -25,7 +24,6 @@ public class CompanyUsersController : Controller
|
||||
private readonly ILogger<CompanyUsersController> _logger;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IEmailService _emailService;
|
||||
|
||||
public CompanyUsersController(
|
||||
@@ -34,7 +32,6 @@ public class CompanyUsersController : Controller
|
||||
ILogger<CompanyUsersController> logger,
|
||||
IUnitOfWork unitOfWork,
|
||||
ISubscriptionService subscriptionService,
|
||||
ApplicationDbContext context,
|
||||
IEmailService emailService)
|
||||
{
|
||||
_userManager = userManager;
|
||||
@@ -42,7 +39,6 @@ public class CompanyUsersController : Controller
|
||||
_logger = logger;
|
||||
_unitOfWork = unitOfWork;
|
||||
_subscriptionService = subscriptionService;
|
||||
_context = context;
|
||||
_emailService = emailService;
|
||||
}
|
||||
|
||||
@@ -372,8 +368,8 @@ public class CompanyUsersController : Controller
|
||||
CompanyId = companyId!.Value
|
||||
};
|
||||
|
||||
await _context.ShopWorkers.AddAsync(shopWorker);
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
|
||||
}
|
||||
@@ -639,9 +635,8 @@ public class CompanyUsersController : Controller
|
||||
{
|
||||
// Search by oldEmail so we find the record even when the email just changed
|
||||
var lookupEmail = emailChanged ? oldEmail : user.Email;
|
||||
var existingShopWorker = await _context.ShopWorkers
|
||||
.Where(sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)
|
||||
.ToListAsync();
|
||||
var existingShopWorker = (await _unitOfWork.ShopWorkers.FindAsync(
|
||||
sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)).ToList();
|
||||
|
||||
if (!existingShopWorker.Any())
|
||||
{
|
||||
@@ -656,8 +651,8 @@ public class CompanyUsersController : Controller
|
||||
CompanyId = user.CompanyId
|
||||
};
|
||||
|
||||
await _context.ShopWorkers.AddAsync(shopWorker);
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
|
||||
}
|
||||
@@ -684,7 +679,7 @@ public class CompanyUsersController : Controller
|
||||
shopWorker.Phone = user.PhoneNumber;
|
||||
|
||||
if (shopWorkerDirty)
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ public class CustomersController : Controller
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public CustomersController(
|
||||
@@ -35,7 +34,6 @@ public class CustomersController : Controller
|
||||
INotificationService notificationService,
|
||||
ISubscriptionService subscriptionService,
|
||||
ITenantContext tenantContext,
|
||||
ApplicationDbContext context,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
@@ -44,7 +42,6 @@ public class CustomersController : Controller
|
||||
_notificationService = notificationService;
|
||||
_subscriptionService = subscriptionService;
|
||||
_tenantContext = tenantContext;
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
@@ -555,11 +552,9 @@ public class CustomersController : Controller
|
||||
try { await _notificationService.NotifySmsConsentGrantedAsync(customer); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); }
|
||||
|
||||
var smsLog = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.CustomerId == customer.Id)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
var logs = await _unitOfWork.NotificationLogs.FindAsync(
|
||||
n => n.CustomerId == customer.Id, ignoreQueryFilters: true);
|
||||
var smsLog = logs.OrderByDescending(n => n.SentAt).FirstOrDefault();
|
||||
this.SetNotificationResultToast(smsLog);
|
||||
}
|
||||
|
||||
@@ -679,11 +674,9 @@ public class CustomersController : Controller
|
||||
try { await _notificationService.NotifySmsConsentGrantedAsync(customer); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); }
|
||||
|
||||
var smsLog = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.CustomerId == customer.Id)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
var logs = await _unitOfWork.NotificationLogs.FindAsync(
|
||||
n => n.CustomerId == customer.Id, ignoreQueryFilters: true);
|
||||
var smsLog = logs.OrderByDescending(n => n.SentAt).FirstOrDefault();
|
||||
this.SetNotificationResultToast(smsLog);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Dashboard;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus
|
||||
using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus, EquipmentStatus, PaymentMethod
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.ViewModels.Dashboard;
|
||||
using PowderCoating.Web.ViewModels.GuidedActivation;
|
||||
using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -17,7 +20,11 @@ public class DashboardController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<DashboardController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IDashboardReadService _dashboardRead;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ICompanyConfigHealthService _configHealth;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
|
||||
private static readonly string[] CompletedStatusCodes =
|
||||
[
|
||||
@@ -39,16 +46,22 @@ public class DashboardController : Controller
|
||||
"QUALITY_CHECK"
|
||||
];
|
||||
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ICompanyConfigHealthService _configHealth;
|
||||
|
||||
public DashboardController(IUnitOfWork unitOfWork, ILogger<DashboardController> logger, ApplicationDbContext context, ITenantContext tenantContext, ICompanyConfigHealthService configHealth)
|
||||
public DashboardController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ILogger<DashboardController> logger,
|
||||
IDashboardReadService dashboardRead,
|
||||
ITenantContext tenantContext,
|
||||
ICompanyConfigHealthService configHealth,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ISubscriptionService subscriptionService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
_dashboardRead = dashboardRead;
|
||||
_tenantContext = tenantContext;
|
||||
_configHealth = configHealth;
|
||||
_userManager = userManager;
|
||||
_subscriptionService = subscriptionService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -66,23 +79,14 @@ public class DashboardController : Controller
|
||||
try
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var startOfMonth = new DateTime(today.Year, today.Month, 1);
|
||||
var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
|
||||
var lookAheadDate = today.AddDays(7); // Changed to 7 days for expiring quotes
|
||||
var lookAheadDate = today.AddDays(7);
|
||||
|
||||
// Active jobs — filter completed/cancelled statuses at database level
|
||||
var activeJobs = await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.AssignedUser)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode))
|
||||
.ToListAsync();
|
||||
var data = await _dashboardRead.GetIndexDataAsync(today);
|
||||
|
||||
var tomorrow = today.AddDays(1);
|
||||
|
||||
// Today's Jobs
|
||||
var todaysJobsFiltered = activeJobs
|
||||
// ---------------------------------------------------------------
|
||||
// Job panels — in-memory split of the pre-fetched activeJobs list
|
||||
// ---------------------------------------------------------------
|
||||
var todaysJobsFiltered = data.ActiveJobs
|
||||
.Where(j => (j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today) ||
|
||||
(j.DueDate.HasValue && j.DueDate.Value.Date == today));
|
||||
var todaysJobsCount = todaysJobsFiltered.Count();
|
||||
@@ -93,8 +97,7 @@ public class DashboardController : Controller
|
||||
.Select(MapJobDto)
|
||||
.ToList();
|
||||
|
||||
// Overdue Jobs
|
||||
var overdueJobsFiltered = activeJobs
|
||||
var overdueJobsFiltered = data.ActiveJobs
|
||||
.Where(j => j.DueDate.HasValue && j.DueDate.Value.Date < today);
|
||||
var overdueJobsCount = overdueJobsFiltered.Count();
|
||||
var overdueJobs = overdueJobsFiltered
|
||||
@@ -104,8 +107,7 @@ public class DashboardController : Controller
|
||||
.Select(MapJobDto)
|
||||
.ToList();
|
||||
|
||||
// In-Progress Jobs
|
||||
var inProgressJobs = activeJobs
|
||||
var inProgressJobs = data.ActiveJobs
|
||||
.Where(j => InProgressStatusCodes.Contains(j.JobStatus.StatusCode))
|
||||
.OrderBy(j => j.JobPriority.DisplayOrder)
|
||||
.ThenBy(j => j.ScheduledDate)
|
||||
@@ -113,26 +115,11 @@ public class DashboardController : Controller
|
||||
.Select(MapJobDto)
|
||||
.ToList();
|
||||
|
||||
// Monthly Revenue — aggregate at database level (no need to load all jobs)
|
||||
var monthlyRevenue = await _context.Jobs
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
|
||||
&& j.UpdatedAt >= startOfMonth
|
||||
&& j.UpdatedAt <= endOfMonth)
|
||||
.SumAsync(j => j.FinalPrice);
|
||||
|
||||
// Today's Appointments — filter at database level
|
||||
var todaysAppointmentsRaw = await _context.Appointments
|
||||
.Include(a => a.Customer)
|
||||
.Include(a => a.AppointmentType)
|
||||
.Include(a => a.AppointmentStatus)
|
||||
.Include(a => a.AssignedUser)
|
||||
.Where(a => a.ScheduledStartTime >= today && a.ScheduledStartTime < tomorrow
|
||||
&& a.AppointmentStatus.StatusCode != "CANCELLED")
|
||||
.OrderBy(a => a.ScheduledStartTime)
|
||||
.ToListAsync();
|
||||
var todaysAppointmentsCount = todaysAppointmentsRaw.Count;
|
||||
var todaysAppointments = todaysAppointmentsRaw
|
||||
// ---------------------------------------------------------------
|
||||
// Appointments
|
||||
// ---------------------------------------------------------------
|
||||
var todaysAppointmentsCount = data.TodaysAppointments.Count;
|
||||
var todaysAppointments = data.TodaysAppointments
|
||||
.Take(10)
|
||||
.Select(a => new DashboardAppointmentDto
|
||||
{
|
||||
@@ -150,9 +137,11 @@ public class DashboardController : Controller
|
||||
AssignedWorkerName = a.AssignedUser?.FullName
|
||||
}).ToList();
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Low stock items
|
||||
// ---------------------------------------------------------------
|
||||
var lowStockAll = await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||
var lowStockCount = lowStockAll.Count();
|
||||
var lowStockItems = lowStockAll
|
||||
.OrderBy(i => i.QuantityOnHand)
|
||||
@@ -168,21 +157,10 @@ public class DashboardController : Controller
|
||||
UnitOfMeasure = i.UnitOfMeasure
|
||||
}).ToList();
|
||||
|
||||
// Maintenance records — filter to pending/overdue at database level
|
||||
var upcomingMaintenance = await _context.MaintenanceRecords
|
||||
.Include(m => m.Equipment)
|
||||
.Include(m => m.AssignedUser)
|
||||
.Where(m => (m.Status == MaintenanceStatus.Scheduled
|
||||
|| m.Status == MaintenanceStatus.InProgress
|
||||
|| m.Status == MaintenanceStatus.Overdue)
|
||||
&& (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate))
|
||||
.OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1)
|
||||
.ThenByDescending(m => m.Priority)
|
||||
.ThenBy(m => m.ScheduledDate)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
var upcomingMaintenanceDtos = upcomingMaintenance
|
||||
// ---------------------------------------------------------------
|
||||
// Maintenance
|
||||
// ---------------------------------------------------------------
|
||||
var upcomingMaintenanceDtos = data.UpcomingMaintenance
|
||||
.Select(m => new DashboardMaintenanceDto
|
||||
{
|
||||
Id = m.Id,
|
||||
@@ -195,14 +173,10 @@ public class DashboardController : Controller
|
||||
AssignedWorkerName = m.AssignedUser?.FullName
|
||||
}).ToList();
|
||||
|
||||
// Pending Quotes — filter to SENT status at database level
|
||||
var pendingQuotesData = await _context.Quotes
|
||||
.Include(q => q.Customer)
|
||||
.Include(q => q.QuoteStatus)
|
||||
.Where(q => q.QuoteStatus.StatusCode == "SENT")
|
||||
.ToListAsync();
|
||||
|
||||
var pendingQuotes = pendingQuotesData
|
||||
// ---------------------------------------------------------------
|
||||
// Quotes
|
||||
// ---------------------------------------------------------------
|
||||
var pendingQuotes = data.PendingQuotes
|
||||
.OrderBy(q => q.ExpirationDate)
|
||||
.Take(10)
|
||||
.Select(q => new DashboardQuoteDto
|
||||
@@ -221,10 +195,9 @@ public class DashboardController : Controller
|
||||
StatusDisplayName = q.QuoteStatus.DisplayName
|
||||
}).ToList();
|
||||
|
||||
var pendingQuoteValue = pendingQuotesData.Sum(q => q.Total);
|
||||
var pendingQuoteValue = data.PendingQuotes.Sum(q => q.Total);
|
||||
|
||||
// Expiring Quotes (next 7 days) - filter at database level
|
||||
var expiringQuotes = pendingQuotesData
|
||||
var expiringQuotes = data.PendingQuotes
|
||||
.Where(q => q.ExpirationDate.HasValue
|
||||
&& q.ExpirationDate.Value.Date >= today
|
||||
&& q.ExpirationDate.Value.Date <= lookAheadDate)
|
||||
@@ -246,33 +219,17 @@ public class DashboardController : Controller
|
||||
StatusDisplayName = q.QuoteStatus.DisplayName
|
||||
}).ToList();
|
||||
|
||||
// Active Customers
|
||||
// ---------------------------------------------------------------
|
||||
// Active customers
|
||||
// ---------------------------------------------------------------
|
||||
var activeCustomersCount = await _unitOfWork.Customers.CountAsync(c => c.IsActive);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Financial data — Invoices & Payments
|
||||
// Invoices & AR aging
|
||||
// ---------------------------------------------------------------
|
||||
var openStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue };
|
||||
var outstandingAr = data.OpenInvoices.Sum(i => i.BalanceDue);
|
||||
|
||||
// Open invoices only — filter at database level
|
||||
var openInvoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => openStatuses.Contains(i.Status))
|
||||
.ToListAsync();
|
||||
|
||||
var outstandingAr = openInvoices.Sum(i => i.BalanceDue);
|
||||
|
||||
// Invoiced this month — aggregate at database level
|
||||
var invoicedThisMonth = await _context.Invoices
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.WrittenOff
|
||||
&& i.InvoiceDate >= startOfMonth
|
||||
&& i.InvoiceDate <= endOfMonth)
|
||||
.SumAsync(i => i.Total);
|
||||
|
||||
// Overdue invoices: open and past due date
|
||||
var overdueInvoicesList = openInvoices
|
||||
var overdueInvoicesList = data.OpenInvoices
|
||||
.Where(i => i.DueDate.HasValue && i.DueDate.Value.Date < today)
|
||||
.OrderBy(i => i.DueDate)
|
||||
.ToList();
|
||||
@@ -295,9 +252,9 @@ public class DashboardController : Controller
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// AR Aging — bucket open invoices by days past due
|
||||
// AR Aging buckets
|
||||
decimal agingCurrent = 0, aging1To30 = 0, aging31To60 = 0, aging61To90 = 0, agingOver90 = 0;
|
||||
foreach (var inv in openInvoices)
|
||||
foreach (var inv in data.OpenInvoices)
|
||||
{
|
||||
if (!inv.DueDate.HasValue || inv.DueDate.Value.Date >= today)
|
||||
{
|
||||
@@ -313,17 +270,10 @@ public class DashboardController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Payments this month — aggregate at database level
|
||||
var collectedThisMonth = await _context.Payments
|
||||
.Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth)
|
||||
.SumAsync(p => p.Amount);
|
||||
|
||||
// Recent payments — load only the 6 most recent
|
||||
var recentPayments = (await _context.Payments
|
||||
.Include(p => p.Invoice).ThenInclude(i => i!.Customer)
|
||||
.OrderByDescending(p => p.PaymentDate)
|
||||
.Take(6)
|
||||
.ToListAsync())
|
||||
// ---------------------------------------------------------------
|
||||
// Payments
|
||||
// ---------------------------------------------------------------
|
||||
var recentPayments = data.RecentPayments
|
||||
.Select(p => new DashboardPaymentDto
|
||||
{
|
||||
Id = p.Id,
|
||||
@@ -335,44 +285,39 @@ public class DashboardController : Controller
|
||||
PaymentDate = p.PaymentDate,
|
||||
PaymentMethodDisplay = p.PaymentMethod switch
|
||||
{
|
||||
PowderCoating.Core.Enums.PaymentMethod.Cash => "Cash",
|
||||
PowderCoating.Core.Enums.PaymentMethod.Check => "Check",
|
||||
PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard => "Card",
|
||||
PowderCoating.Core.Enums.PaymentMethod.BankTransferACH => "ACH",
|
||||
PowderCoating.Core.Enums.PaymentMethod.DigitalPayment => "Digital",
|
||||
PaymentMethod.Cash => "Cash",
|
||||
PaymentMethod.Check => "Check",
|
||||
PaymentMethod.CreditDebitCard => "Card",
|
||||
PaymentMethod.BankTransferACH => "ACH",
|
||||
PaymentMethod.DigitalPayment => "Digital",
|
||||
_ => "Other"
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Equipment Alerts - filter at database level
|
||||
// ---------------------------------------------------------------
|
||||
// Equipment alerts
|
||||
// ---------------------------------------------------------------
|
||||
var equipmentAlerts = (await _unitOfWork.Equipment.FindAsync(
|
||||
e => e.Status == Core.Enums.EquipmentStatus.NeedsMaintenance ||
|
||||
e.Status == Core.Enums.EquipmentStatus.OutOfService))
|
||||
.OrderByDescending(e => e.Status == Core.Enums.EquipmentStatus.OutOfService ? 1 : 0)
|
||||
e => e.Status == EquipmentStatus.NeedsMaintenance ||
|
||||
e.Status == EquipmentStatus.OutOfService))
|
||||
.OrderByDescending(e => e.Status == EquipmentStatus.OutOfService ? 1 : 0)
|
||||
.Take(5)
|
||||
.Select(e => new DashboardEquipmentAlertDto
|
||||
{
|
||||
Id = e.Id,
|
||||
EquipmentName = e.EquipmentName,
|
||||
EquipmentType = e.EquipmentType,
|
||||
Issue = e.Status == Core.Enums.EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
|
||||
Severity = e.Status == Core.Enums.EquipmentStatus.OutOfService ? "Critical" : "Warning",
|
||||
Issue = e.Status == EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
|
||||
Severity = e.Status == EquipmentStatus.OutOfService ? "Critical" : "Warning",
|
||||
LastMaintenanceDate = e.LastMaintenanceDate,
|
||||
NextMaintenanceDue = null // Equipment doesn't track next maintenance due date
|
||||
NextMaintenanceDue = null
|
||||
}).ToList();
|
||||
|
||||
// Recent Activity (last 10 quotes or jobs created in last 30 days)
|
||||
var last30Days = today.AddDays(-30);
|
||||
|
||||
// Recent quotes — filter to last 30 days at database level
|
||||
var recentQuotes = (await _context.Quotes
|
||||
.Include(q => q.Customer)
|
||||
.Include(q => q.QuoteStatus)
|
||||
.Where(q => q.CreatedAt >= last30Days)
|
||||
.OrderByDescending(q => q.CreatedAt)
|
||||
.Take(5)
|
||||
.ToListAsync())
|
||||
// ---------------------------------------------------------------
|
||||
// Recent activity
|
||||
// ---------------------------------------------------------------
|
||||
var recentQuoteDtos = data.RecentQuotes
|
||||
.Select(q => new DashboardRecentActivityDto
|
||||
{
|
||||
Id = q.Id,
|
||||
@@ -390,14 +335,7 @@ public class DashboardController : Controller
|
||||
Amount = q.Total
|
||||
});
|
||||
|
||||
// Recent jobs — filter to last 30 days at database level
|
||||
var recentJobs = (await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => j.CreatedAt >= last30Days)
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(5)
|
||||
.ToListAsync())
|
||||
var recentJobDtos = data.RecentJobs
|
||||
.Select(j => new DashboardRecentActivityDto
|
||||
{
|
||||
Id = j.Id,
|
||||
@@ -413,33 +351,15 @@ public class DashboardController : Controller
|
||||
Amount = j.FinalPrice
|
||||
});
|
||||
|
||||
var recentActivity = recentQuotes.Concat(recentJobs)
|
||||
var recentActivity = recentQuoteDtos.Concat(recentJobDtos)
|
||||
.OrderByDescending(a => a.ActivityDate)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
// === POWDER ORDERS NEEDED ===
|
||||
var jobsNeedingPowder = await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobItems)
|
||||
.ThenInclude(i => i.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.ThenInclude(inv => inv!.PrimaryVendor)
|
||||
.Include(j => j.JobItems)
|
||||
.ThenInclude(i => i.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Where(j => !j.IsDeleted
|
||||
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
|
||||
&& j.JobItems.Any(i => i.Coats.Any(c =>
|
||||
!c.IsDeleted &&
|
||||
!c.PowderOrdered &&
|
||||
c.PowderToOrder > 0 &&
|
||||
(c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))))
|
||||
.ToListAsync();
|
||||
|
||||
// Flatten to individual coat lines that need ordering (with vendor info for grouping)
|
||||
var powderFlat = jobsNeedingPowder
|
||||
// ---------------------------------------------------------------
|
||||
// Powder orders needed
|
||||
// ---------------------------------------------------------------
|
||||
var powderFlat = data.JobsNeedingPowder
|
||||
.SelectMany(j => j.JobItems
|
||||
.SelectMany(i => i.Coats
|
||||
.Where(c => !c.IsDeleted && !c.PowderOrdered && c.PowderToOrder > 0
|
||||
@@ -500,26 +420,10 @@ public class DashboardController : Controller
|
||||
.OrderBy(g => g.VendorName)
|
||||
.ToList();
|
||||
|
||||
// === POWDER ORDERS PLACED (ordered, awaiting receipt) ===
|
||||
var jobsWithOrderedPowder = await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobItems)
|
||||
.ThenInclude(i => i.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.ThenInclude(inv => inv!.PrimaryVendor)
|
||||
.Include(j => j.JobItems)
|
||||
.ThenInclude(i => i.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Where(j => !j.IsDeleted
|
||||
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
|
||||
&& j.JobItems.Any(i => i.Coats.Any(c =>
|
||||
!c.IsDeleted &&
|
||||
c.PowderOrdered &&
|
||||
!c.PowderReceived)))
|
||||
.ToListAsync();
|
||||
|
||||
var placedFlat = jobsWithOrderedPowder
|
||||
// ---------------------------------------------------------------
|
||||
// Powder orders placed
|
||||
// ---------------------------------------------------------------
|
||||
var placedFlat = data.JobsWithOrderedPowder
|
||||
.SelectMany(j => j.JobItems
|
||||
.SelectMany(i => i.Coats
|
||||
.Where(c => !c.IsDeleted && c.PowderOrdered && !c.PowderReceived)
|
||||
@@ -584,16 +488,10 @@ public class DashboardController : Controller
|
||||
.OrderBy(g => g.VendorName)
|
||||
.ToList();
|
||||
|
||||
// === BILLS DUE ===
|
||||
var billsDueRaw = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => !b.IsDeleted &&
|
||||
(b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid) &&
|
||||
b.Total > b.AmountPaid)
|
||||
.OrderBy(b => b.DueDate)
|
||||
.Take(15)
|
||||
.ToListAsync();
|
||||
var billsDue = billsDueRaw.Select(b => new DashboardBillDto
|
||||
// ---------------------------------------------------------------
|
||||
// Bills due
|
||||
// ---------------------------------------------------------------
|
||||
var billsDue = data.BillsDue.Select(b => new DashboardBillDto
|
||||
{
|
||||
Id = b.Id,
|
||||
BillNumber = b.BillNumber,
|
||||
@@ -608,21 +506,21 @@ public class DashboardController : Controller
|
||||
var vm = new DashboardViewModel
|
||||
{
|
||||
// Counts
|
||||
ActiveJobsCount = activeJobs.Count(),
|
||||
ActiveJobsCount = data.ActiveJobs.Count,
|
||||
TodaysJobsCount = todaysJobsCount,
|
||||
OverdueJobsCount = overdueJobsCount,
|
||||
TodaysAppointmentsCount = todaysAppointmentsCount,
|
||||
LowStockCount = lowStockCount,
|
||||
PendingMaintenanceCount = upcomingMaintenance.Count,
|
||||
PendingQuotesCount = pendingQuotesData.Count(),
|
||||
PendingMaintenanceCount = data.UpcomingMaintenance.Count,
|
||||
PendingQuotesCount = data.PendingQuotes.Count,
|
||||
PendingQuoteValue = pendingQuoteValue,
|
||||
MonthlyRevenue = monthlyRevenue,
|
||||
MonthlyRevenue = data.MonthlyRevenue,
|
||||
ActiveCustomersCount = activeCustomersCount,
|
||||
|
||||
// Financial KPIs
|
||||
OutstandingAr = outstandingAr,
|
||||
CollectedThisMonth = collectedThisMonth,
|
||||
InvoicedThisMonth = invoicedThisMonth,
|
||||
CollectedThisMonth = data.CollectedThisMonth,
|
||||
InvoicedThisMonth = data.InvoicedThisMonth,
|
||||
OverdueInvoicesCount = overdueInvoicesCount,
|
||||
OverdueInvoicesAmount = overdueInvoicesAmount,
|
||||
AgingCurrent = agingCurrent,
|
||||
@@ -654,7 +552,9 @@ public class DashboardController : Controller
|
||||
PowderOrdersNeeded = powderOrderGroups,
|
||||
PowderOrdersNeededCount = powderFlat.Count,
|
||||
PowderOrdersPlaced = powderPlacedGroups,
|
||||
PowderOrdersPlacedCount = placedFlat.Count
|
||||
PowderOrdersPlacedCount = placedFlat.Count,
|
||||
|
||||
TipOfTheDay = data.TipOfTheDay
|
||||
};
|
||||
|
||||
// Dropdowns for the "Add Custom Powder to Inventory" modal
|
||||
@@ -671,18 +571,20 @@ public class DashboardController : Controller
|
||||
ViewBag.InventoryCategories = inventoryCategories;
|
||||
ViewBag.VendorList = vendors;
|
||||
|
||||
// Random tip of the day
|
||||
var tips = await _context.DashboardTips
|
||||
.Where(t => t.IsActive)
|
||||
.ToListAsync();
|
||||
if (tips.Count > 0)
|
||||
vm.TipOfTheDay = tips[Random.Shared.Next(tips.Count)].TipText;
|
||||
|
||||
// Config health check — surface setup gaps to company admins
|
||||
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (currentCompanyId.HasValue)
|
||||
{
|
||||
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
|
||||
|
||||
// Load prefs once and share between both banner and progress widget builders
|
||||
var companyPrefs = await _unitOfWork.CompanyPreferences
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == currentCompanyId.Value && !p.IsDeleted);
|
||||
|
||||
ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs);
|
||||
ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, companyPrefs);
|
||||
}
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -705,11 +607,7 @@ public class DashboardController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var coat = await _context.JobItemCoats
|
||||
.Include(c => c.JobItem).ThenInclude(i => i.Job).ThenInclude(j => j.Customer)
|
||||
.Include(c => c.Vendor)
|
||||
.Include(c => c.InventoryItem).ThenInclude(i => i!.PrimaryVendor)
|
||||
.FirstOrDefaultAsync(c => c.Id == coatId);
|
||||
var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId);
|
||||
|
||||
if (coat == null)
|
||||
return Json(new { success = false, message = "Coat not found." });
|
||||
@@ -722,7 +620,7 @@ public class DashboardController : Controller
|
||||
coat.PowderOrdered = true;
|
||||
coat.PowderOrderedAt = DateTime.UtcNow;
|
||||
coat.PowderOrderedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
|
||||
var job = coat.JobItem?.Job;
|
||||
@@ -757,13 +655,132 @@ public class DashboardController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
private GuidedActivationBannerViewModel? BuildGuidedActivationBanner(CompanyPreferences? prefs)
|
||||
{
|
||||
var companyRole = User.FindFirst("CompanyRole")?.Value;
|
||||
if (companyRole != AppConstants.CompanyRoles.CompanyAdmin)
|
||||
return null;
|
||||
|
||||
if (prefs == null || !prefs.SetupWizardCompleted || prefs.FirstWorkflowCompleted)
|
||||
return null;
|
||||
|
||||
return new GuidedActivationBannerViewModel
|
||||
{
|
||||
Show = true,
|
||||
IsDismissed = prefs.GuidedActivationDismissedAt.HasValue,
|
||||
Title = prefs.GuidedActivationDismissedAt.HasValue
|
||||
? "Start your first workflow when you're ready"
|
||||
: "Create your first job or quote",
|
||||
Message = prefs.GuidedActivationDismissedAt.HasValue
|
||||
? "You can come back anytime to run a short walkthrough using real quotes, jobs, and invoices."
|
||||
: "Run a quick 2-minute workflow to see how the system works.",
|
||||
ActionText = "Start first workflow"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the "Get the most out of your shop" activation checklist for CompanyAdmins.
|
||||
/// Returns null when the wizard is not yet complete, the viewer is not a CompanyAdmin,
|
||||
/// or all six tasks are already done (so the widget disappears naturally at 100%).
|
||||
/// Three DB checks are fired in parallel to keep the overhead to a minimum.
|
||||
/// </summary>
|
||||
private async Task<ShopProgressWidgetViewModel?> BuildShopProgressWidgetAsync(int companyId, CompanyPreferences? prefs)
|
||||
{
|
||||
var companyRole = User.FindFirst("CompanyRole")?.Value;
|
||||
if (companyRole != AppConstants.CompanyRoles.CompanyAdmin)
|
||||
return null;
|
||||
|
||||
if (prefs == null || !prefs.SetupWizardCompleted)
|
||||
return null;
|
||||
|
||||
// These share the same scoped DbContext so must run sequentially
|
||||
var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(_ => true);
|
||||
// ignoreQueryFilters so soft-deleted lookups (UpdatedAt set on delete) are also visible
|
||||
var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync(
|
||||
j => j.CompanyId == companyId && j.UpdatedAt != null,
|
||||
ignoreQueryFilters: true);
|
||||
var teamCount = await _userManager.Users
|
||||
.CountAsync(u => u.CompanyId == companyId && u.IsActive && !u.IsBanned);
|
||||
var (_, maxUsers) = await _subscriptionService.GetUserCountAsync(companyId);
|
||||
var planAllowsMultipleUsers = maxUsers != 1;
|
||||
|
||||
var items = new List<ShopProgressItem?>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Done = prefs.FirstJobCreatedAt.HasValue || prefs.FirstQuoteCreatedAt.HasValue,
|
||||
Label = "Create your first job or quote",
|
||||
SubLabel = "Get customer sign-off before you start — takes about 2 minutes.",
|
||||
DoneSubLabel = "Your first job is now being tracked.",
|
||||
Icon = "bi-file-earmark-plus",
|
||||
CtaText = "Create a quote",
|
||||
CtaUrl = Url.Action("Start", "GuidedActivation")!
|
||||
},
|
||||
new()
|
||||
{
|
||||
Done = hasStatusHistory,
|
||||
Label = "Move a job through your workflow",
|
||||
SubLabel = "Move a job through your board so your crew always knows what's next.",
|
||||
DoneSubLabel = "You've started tracking work through your shop.",
|
||||
Icon = "bi-arrow-right-circle",
|
||||
CtaText = "Go to jobs board",
|
||||
CtaUrl = Url.Action("Board", "Jobs")!
|
||||
},
|
||||
new()
|
||||
{
|
||||
Done = prefs.FirstInvoiceCreatedAt.HasValue,
|
||||
Label = "Send your first invoice",
|
||||
SubLabel = "When the work is done, turn it into an invoice and send it in seconds.",
|
||||
DoneSubLabel = "You're ready to get paid.",
|
||||
Icon = "bi-receipt",
|
||||
CtaText = "Create invoice",
|
||||
CtaUrl = Url.Action("Create", "Invoices")!
|
||||
},
|
||||
planAllowsMultipleUsers ? new()
|
||||
{
|
||||
Done = teamCount > 1,
|
||||
Label = "Bring your crew in",
|
||||
SubLabel = "Add your crew so everyone stays on the same page in real time.",
|
||||
DoneSubLabel = "Your team is in the system.",
|
||||
Icon = "bi-people",
|
||||
CtaText = "Invite team",
|
||||
CtaUrl = Url.Action("Index", "CompanyUsers")!
|
||||
} : null!,
|
||||
new()
|
||||
{
|
||||
Done = hasCustomizedLookups,
|
||||
Label = "Customize your workflow",
|
||||
SubLabel = "Adjust stages and services to match how your shop runs.",
|
||||
DoneSubLabel = "Your workflow speaks your shop's language.",
|
||||
Icon = "bi-list-ul",
|
||||
CtaText = "Customize workflow",
|
||||
CtaUrl = Url.Action("Index", "CompanySettings") + "#data-lookups"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Done = prefs.DefaultPaymentTerms != "Net 30"
|
||||
|| prefs.DefaultQuoteValidityDays != 30
|
||||
|| prefs.DefaultTurnaroundDays != 7
|
||||
|| prefs.QtDefaultTerms != null,
|
||||
Label = "Set how you get paid",
|
||||
SubLabel = "Set your payment terms and timing so every job goes out right.",
|
||||
DoneSubLabel = "Your payment defaults are locked in.",
|
||||
Icon = "bi-file-earmark-text",
|
||||
CtaText = "Set payment terms",
|
||||
CtaUrl = Url.Action("Index", "CompanySettings") + "#general"
|
||||
}
|
||||
};
|
||||
|
||||
return new ShopProgressWidgetViewModel { Items = items.Where(i => i != null).Select(i => i!).ToList() };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records receipt of a powder shipment against an existing powder order. Sets
|
||||
/// <c>PowderReceived</c>, <c>PowderReceivedLbs</c>, and <c>PowderReceivedAt</c> on the coat,
|
||||
/// and — when the coat is linked to an inventory item — increases <c>QuantityOnHand</c> and
|
||||
/// writes a <c>Purchase</c> <see cref="PowderCoating.Core.Entities.InventoryTransaction"/> so
|
||||
/// the stock movement is fully traceable. Company ownership is verified through the parent job
|
||||
/// because <c>JobItemCoat</c> carries no <c>CompanyId</c> of its own.
|
||||
/// writes a <c>Purchase</c> <see cref="InventoryTransaction"/> so the stock movement is fully
|
||||
/// traceable. Company ownership is verified through the parent job because <c>JobItemCoat</c>
|
||||
/// carries no <c>CompanyId</c> of its own.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
@@ -771,9 +788,8 @@ public class DashboardController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var coat = await _context.JobItemCoats
|
||||
.Include(c => c.InventoryItem)
|
||||
.FirstOrDefaultAsync(c => c.Id == coatId);
|
||||
// Load coat with inventory item for the stock update
|
||||
var coat = await _unitOfWork.JobItemCoats.LoadWithInventoryAsync(coatId);
|
||||
|
||||
if (coat == null)
|
||||
return Json(new { success = false, message = "Coat record not found." });
|
||||
@@ -781,29 +797,25 @@ public class DashboardController : Controller
|
||||
if (lbsReceived <= 0)
|
||||
return Json(new { success = false, message = "Quantity received must be greater than zero." });
|
||||
|
||||
// Verify ownership — JobItemCoat has no CompanyId, check via parent job
|
||||
// (We need the job for company check; load it if not already included)
|
||||
// Verify ownership — JobItemCoat has no CompanyId, check via parent job.
|
||||
// If JobItem/Job wasn't populated by the initial load, bring in the chain via a second
|
||||
// query; EF Core identity-map fixup will propagate the navigation to the tracked coat.
|
||||
var coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
|
||||
if (coatJobCompanyId == null)
|
||||
{
|
||||
// Reload with parent chain if not included
|
||||
var coatWithJob = await _context.JobItemCoats
|
||||
.Include(c => c.JobItem).ThenInclude(i => i.Job)
|
||||
.FirstOrDefaultAsync(c => c.Id == coatId);
|
||||
coatJobCompanyId = coatWithJob?.JobItem?.Job?.CompanyId;
|
||||
await _unitOfWork.JobItemCoats.LoadWithJobChainAsync(coatId);
|
||||
coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
|
||||
}
|
||||
if (!_tenantContext.IsSuperAdmin() && coatJobCompanyId != _tenantContext.GetCurrentCompanyId())
|
||||
return Json(new { success = false, message = "Access denied." });
|
||||
|
||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
// Mark coat as received
|
||||
coat.PowderReceived = true;
|
||||
coat.PowderReceivedAt = DateTime.UtcNow;
|
||||
coat.PowderReceivedByUserId = userId;
|
||||
coat.PowderReceivedLbs = lbsReceived;
|
||||
|
||||
// Update inventory if this coat is linked to an inventory item
|
||||
if (coat.InventoryItemId.HasValue && coat.InventoryItem != null)
|
||||
{
|
||||
var item = coat.InventoryItem;
|
||||
@@ -813,26 +825,24 @@ public class DashboardController : Controller
|
||||
if (coat.PowderCostPerLb.HasValue)
|
||||
item.LastPurchasePrice = coat.PowderCostPerLb.Value;
|
||||
|
||||
// Record purchase transaction
|
||||
var transaction = new PowderCoating.Core.Entities.InventoryTransaction
|
||||
var transaction = new InventoryTransaction
|
||||
{
|
||||
CompanyId = item.CompanyId,
|
||||
InventoryItemId = item.Id,
|
||||
TransactionType = PowderCoating.Core.Enums.InventoryTransactionType.Purchase,
|
||||
TransactionType = InventoryTransactionType.Purchase,
|
||||
Quantity = lbsReceived,
|
||||
UnitCost = coat.PowderCostPerLb ?? item.UnitCost,
|
||||
TotalCost = lbsReceived * (coat.PowderCostPerLb ?? item.UnitCost),
|
||||
TransactionDate = DateTime.UtcNow,
|
||||
Reference = coat.JobItem != null ? null : null, // loaded below if needed
|
||||
Notes = $"Received {lbsReceived:N2} lbs for job order",
|
||||
BalanceAfter = previousBalance + lbsReceived,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_context.Set<PowderCoating.Core.Entities.InventoryTransaction>().Add(transaction);
|
||||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true, updatedInventory = coat.InventoryItemId.HasValue });
|
||||
}
|
||||
@@ -864,7 +874,7 @@ public class DashboardController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var coat = await _context.JobItemCoats.FindAsync(coatId);
|
||||
var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
|
||||
if (coat == null)
|
||||
return Json(new { success = false, message = "Coat record not found." });
|
||||
|
||||
@@ -877,16 +887,13 @@ public class DashboardController : Controller
|
||||
if (lbsReceived <= 0)
|
||||
return Json(new { success = false, message = "Quantity received must be greater than zero." });
|
||||
|
||||
// Resolve company id from tenant context
|
||||
var companyId = (await _unitOfWork.InventoryItems.GetAllAsync()).FirstOrDefault()?.CompanyId ?? 0;
|
||||
// More reliably get CompanyId from the job chain
|
||||
var jobItem = await _context.JobItems.Include(i => i.Job).FirstOrDefaultAsync(i => i.Coats.Any(c => c.Id == coatId));
|
||||
if (jobItem?.Job != null)
|
||||
companyId = jobItem.Job.CompanyId;
|
||||
// Resolve company id from the job chain; fall back to tenant context
|
||||
var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync(
|
||||
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
|
||||
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Check SKU uniqueness
|
||||
var existingSku = await _context.InventoryItems.AnyAsync(i => i.SKU == sku.Trim());
|
||||
if (existingSku)
|
||||
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim()))
|
||||
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
|
||||
|
||||
// Determine category display name for legacy field
|
||||
@@ -925,10 +932,10 @@ public class DashboardController : Controller
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_context.InventoryItems.Add(inventoryItem);
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
|
||||
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
|
||||
|
||||
// Record opening stock transaction
|
||||
// Opening stock transaction
|
||||
var transaction = new InventoryTransaction
|
||||
{
|
||||
CompanyId = companyId,
|
||||
@@ -943,7 +950,7 @@ public class DashboardController : Controller
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_context.Set<InventoryTransaction>().Add(transaction);
|
||||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
||||
|
||||
// Mark coat as received and link to the new inventory item
|
||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
@@ -953,19 +960,12 @@ public class DashboardController : Controller
|
||||
coat.PowderReceivedLbs = lbsReceived;
|
||||
coat.InventoryItemId = inventoryItem.Id;
|
||||
|
||||
// Scan for other active job coats using the same custom powder and link them
|
||||
var candidateCoats = await _context.JobItemCoats
|
||||
.Include(c => c.JobItem)
|
||||
.Where(c => !c.IsDeleted
|
||||
&& c.Id != coatId
|
||||
&& c.InventoryItemId == null
|
||||
&& c.JobItem.CompanyId == companyId)
|
||||
.ToListAsync();
|
||||
// Scan for sibling coats with the same custom powder and link them to the new item
|
||||
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coatId, companyId);
|
||||
|
||||
int linkedCount = 0;
|
||||
foreach (var other in candidateCoats)
|
||||
{
|
||||
// Match by color code first (most specific), then fall back to color name
|
||||
bool colorMatch = !string.IsNullOrWhiteSpace(colorCode)
|
||||
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||
: !string.IsNullOrWhiteSpace(colorName) &&
|
||||
@@ -973,7 +973,6 @@ public class DashboardController : Controller
|
||||
|
||||
if (!colorMatch) continue;
|
||||
|
||||
// If both coats have a vendor set, they must agree
|
||||
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId)
|
||||
continue;
|
||||
|
||||
@@ -985,7 +984,7 @@ public class DashboardController : Controller
|
||||
_logger.LogInformation("Linked {Count} additional coat(s) to new inventory item {ItemId} ({SKU})",
|
||||
linkedCount, inventoryItem.Id, inventoryItem.SKU);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
|
||||
}
|
||||
@@ -1014,13 +1013,10 @@ public class DashboardController : Controller
|
||||
var allCompanies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true);
|
||||
var companies = allCompanies.Where(c => !c.IsDeleted).ToList();
|
||||
|
||||
var totalUsers = await _context.Users
|
||||
.Where(u => u.CompanyId > 0)
|
||||
.CountAsync();
|
||||
var totalUsers = await _dashboardRead.GetTotalUserCountAsync();
|
||||
|
||||
var graceCutoff = today.AddDays(-AppConstants.SubscriptionConstants.GracePeriodDays);
|
||||
|
||||
// Load plan configs from DB so plan display names and distribution are DB-driven
|
||||
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
||||
c => c.IsActive, ignoreQueryFilters: true))
|
||||
.OrderBy(c => c.SortOrder)
|
||||
@@ -1029,7 +1025,6 @@ public class DashboardController : Controller
|
||||
var planLookup = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
|
||||
string PlanName(int plan) => planLookup.TryGetValue(plan, out var name) ? name : plan.ToString();
|
||||
|
||||
// Companies needing attention: expired (past grace) or in grace period
|
||||
var companyAlerts = companies
|
||||
.Where(c => c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value.Date < today)
|
||||
.OrderBy(c => c.SubscriptionEndDate)
|
||||
@@ -1066,7 +1061,6 @@ public class DashboardController : Controller
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Build plan distribution from DB config (sorted by SortOrder)
|
||||
var planDistribution = planConfigs.ToDictionary(
|
||||
c => c.Plan,
|
||||
c => (c.DisplayName, companies.Count(comp => comp.SubscriptionPlan == c.Plan)));
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -18,39 +17,37 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class DashboardTipsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public DashboardTipsController(ApplicationDbContext db)
|
||||
public DashboardTipsController(IUnitOfWork unitOfWork)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a paginated, optionally filtered list of all dashboard tips.
|
||||
/// Active tips are sorted first (then by newest Id) to make the currently
|
||||
/// live pool easy to review at a glance. ViewBag includes both the filtered
|
||||
/// count and the global active/total counts for the header summary cards.
|
||||
/// live pool easy to review at a glance.
|
||||
/// </summary>
|
||||
// GET: /DashboardTips
|
||||
public async Task<IActionResult> Index(string? search, bool? activeOnly, int page = 1)
|
||||
{
|
||||
const int pageSize = 25;
|
||||
|
||||
var query = _db.DashboardTips.AsQueryable();
|
||||
var all = (await _unitOfWork.DashboardTips.GetAllAsync()).ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
query = query.Where(t => t.TipText.Contains(search));
|
||||
all = all.Where(t => t.TipText.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (activeOnly == true)
|
||||
query = query.Where(t => t.IsActive);
|
||||
all = all.Where(t => t.IsActive).ToList();
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var tips = await query
|
||||
var total = all.Count;
|
||||
var tips = all
|
||||
.OrderByDescending(t => t.IsActive)
|
||||
.ThenByDescending(t => t.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
ViewBag.Search = search;
|
||||
ViewBag.ActiveOnly = activeOnly ?? false;
|
||||
@@ -58,23 +55,19 @@ public class DashboardTipsController : Controller
|
||||
ViewBag.PageSize = pageSize;
|
||||
ViewBag.Total = total;
|
||||
ViewBag.TotalPages = (int)Math.Ceiling(total / (double)pageSize);
|
||||
ViewBag.ActiveCount = await _db.DashboardTips.CountAsync(t => t.IsActive);
|
||||
ViewBag.TotalCount = await _db.DashboardTips.CountAsync();
|
||||
ViewBag.ActiveCount = await _unitOfWork.DashboardTips.CountAsync(t => t.IsActive);
|
||||
ViewBag.TotalCount = await _unitOfWork.DashboardTips.CountAsync();
|
||||
|
||||
return View(tips);
|
||||
}
|
||||
|
||||
/// <summary>Returns the Create form with an empty <see cref="DashboardTip"/> model.</summary>
|
||||
// GET: /DashboardTips/Create
|
||||
public IActionResult Create() => View(new DashboardTip());
|
||||
|
||||
/// <summary>
|
||||
/// Persists a new dashboard tip. Text is trimmed before saving to prevent
|
||||
/// whitespace-only entries from appearing as blank tiles on the dashboard.
|
||||
/// Model validation is done manually (rather than relying solely on
|
||||
/// <c>[Required]</c> attributes) to ensure a meaningful error message is shown.
|
||||
/// </summary>
|
||||
// POST: /DashboardTips/Create
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(DashboardTip model)
|
||||
{
|
||||
@@ -84,37 +77,33 @@ public class DashboardTipsController : Controller
|
||||
return View(model);
|
||||
}
|
||||
|
||||
_db.DashboardTips.Add(new DashboardTip
|
||||
await _unitOfWork.DashboardTips.AddAsync(new DashboardTip
|
||||
{
|
||||
TipText = model.TipText.Trim(),
|
||||
IsActive = model.IsActive,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = "Tip added successfully.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>Returns the Edit form for an existing tip, or 404 if not found.</summary>
|
||||
// GET: /DashboardTips/Edit/5
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var tip = await _db.DashboardTips.FindAsync(id);
|
||||
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
|
||||
if (tip == null) return NotFound();
|
||||
return View(tip);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates text and active flag for an existing tip. Returns the tracked entity
|
||||
/// (not the posted model) to the view on validation failure so the form shows
|
||||
/// the database version rather than potentially mangled posted data.
|
||||
/// Updates text and active flag for an existing tip.
|
||||
/// </summary>
|
||||
// POST: /DashboardTips/Edit/5
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, DashboardTip model)
|
||||
{
|
||||
var tip = await _db.DashboardTips.FindAsync(id);
|
||||
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
|
||||
if (tip == null) return NotFound();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.TipText))
|
||||
@@ -125,27 +114,25 @@ public class DashboardTipsController : Controller
|
||||
|
||||
tip.TipText = model.TipText.Trim();
|
||||
tip.IsActive = model.IsActive;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = "Tip updated.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permanently (hard) deletes a dashboard tip. Tips are platform metadata so
|
||||
/// Permanently (hard) deletes a dashboard tip. Tips are platform metadata so
|
||||
/// they do not use soft delete — they have no foreign-key relationships to
|
||||
/// tenant data and nothing references a deleted tip's Id. A missing Id is
|
||||
/// silently ignored to keep the action idempotent.
|
||||
/// tenant data and nothing references a deleted tip's Id.
|
||||
/// </summary>
|
||||
// POST: /DashboardTips/Delete/5
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var tip = await _db.DashboardTips.FindAsync(id);
|
||||
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
|
||||
if (tip != null)
|
||||
{
|
||||
_db.DashboardTips.Remove(tip);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.DashboardTips.DeleteAsync(tip);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Tip deleted.";
|
||||
}
|
||||
return RedirectToAction(nameof(Index));
|
||||
@@ -153,19 +140,15 @@ public class DashboardTipsController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// Flips the <c>IsActive</c> flag on a tip without a full edit round-trip.
|
||||
/// This lets operators quickly remove a tip from the rotation (deactivate)
|
||||
/// without deleting it, preserving the ability to reactivate it later.
|
||||
/// A missing Id is silently ignored to keep the action idempotent.
|
||||
/// </summary>
|
||||
// POST: /DashboardTips/ToggleActive/5
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ToggleActive(int id)
|
||||
{
|
||||
var tip = await _db.DashboardTips.FindAsync(id);
|
||||
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
|
||||
if (tip != null)
|
||||
{
|
||||
tip.IsActive = !tip.IsActive;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ using PowderCoating.Application.DTOs.Company;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
@@ -21,18 +20,15 @@ public class DepositsController : Controller
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<DepositsController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public DepositsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<DepositsController> logger,
|
||||
ApplicationDbContext context)
|
||||
ILogger<DepositsController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -220,11 +216,11 @@ public class DepositsController : Controller
|
||||
{
|
||||
var prefix = $"DEP-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
|
||||
|
||||
var existing = await _context.Set<Deposit>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(d => d.CompanyId == companyId && d.ReceiptNumber.StartsWith(prefix))
|
||||
var existing = (await _unitOfWork.Deposits.FindAsync(
|
||||
d => d.CompanyId == companyId && d.ReceiptNumber.StartsWith(prefix),
|
||||
ignoreQueryFilters: true))
|
||||
.Select(d => d.ReceiptNumber)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var maxNum = 0;
|
||||
foreach (var num in existing)
|
||||
|
||||
@@ -1,207 +1,598 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// SuperAdmin-only tool for sending platform-wide broadcast emails to tenant
|
||||
/// company contacts. Emails are sent one at a time via <see cref="IEmailService"/>
|
||||
/// rather than bulk API because each message requires a personalised unsubscribe link
|
||||
/// containing the company's unique <c>MarketingUnsubscribeToken</c>.
|
||||
/// </summary>
|
||||
// Intentional exception: cross-tenant fan-out querying ASP.NET Identity Users table with company joins; Identity entities live outside IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class EmailBroadcastController : Controller
|
||||
{
|
||||
private static readonly Regex ScriptRegex = new(
|
||||
@"<\s*(script|style|iframe|object|embed|form|input|button|textarea|select|meta|link)\b[^>]*>.*?<\s*/\s*\1\s*>",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
private static readonly Regex CommentRegex = new(
|
||||
@"<!--.*?-->",
|
||||
RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
private static readonly Regex TagRegex = new(
|
||||
@"<\s*(/?)\s*([a-z0-9]+)([^>]*)>",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex HrefRegex = new(
|
||||
@"href\s*=\s*(['""]?)([^'"">\s]+)\1",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly HashSet<string> AllowedTags =
|
||||
[
|
||||
"a", "p", "br", "strong", "b", "em", "i", "u",
|
||||
"ul", "ol", "li", "blockquote", "h1", "h2", "h3", "h4"
|
||||
];
|
||||
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IPlatformSettingsService _platformSettings;
|
||||
private readonly ILogger<EmailBroadcastController> _logger;
|
||||
|
||||
public EmailBroadcastController(
|
||||
ApplicationDbContext db,
|
||||
IEmailService emailService,
|
||||
IPlatformSettingsService platformSettings,
|
||||
ILogger<EmailBroadcastController> logger)
|
||||
{
|
||||
_db = db;
|
||||
_emailService = emailService;
|
||||
_platformSettings = platformSettings;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the broadcast compose form, pre-populating ViewBag with plan configs
|
||||
/// and the active company list so the targeting dropdowns are populated without
|
||||
/// a separate AJAX call.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
await PopulateViewBag();
|
||||
return View(new BroadcastForm());
|
||||
}
|
||||
|
||||
/// <summary>Returns JSON count of recipients for the current filter — used for the live preview.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> RecipientCount(string target, string? plan, int[]? companyIds)
|
||||
public IActionResult Index() => View(new AdminEmailComposeModel());
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SelectCompanies(AdminEmailComposeModel form)
|
||||
{
|
||||
var recipients = await BuildRecipientListAsync(target, plan, companyIds);
|
||||
return Json(new { count = recipients.Count });
|
||||
NormalizeComposeModel(form);
|
||||
|
||||
if (!ValidateComposeModel(form))
|
||||
return View("Index", form);
|
||||
|
||||
var viewModel = await BuildSelectionModelAsync(form);
|
||||
return View(viewModel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the composed broadcast email to all recipients matching the chosen
|
||||
/// targeting criteria. Aborts early (with a validation error) if no recipients
|
||||
/// are found, to prevent accidental empty sends.
|
||||
/// <para>
|
||||
/// Each email body is HTML-encoded (then line-breaks converted to
|
||||
/// <c><br></c>) and wrapped in a branded container that appends a
|
||||
/// per-company unsubscribe footer. Failures are counted and reported in the
|
||||
/// success banner rather than aborting the remainder of the batch, so a single
|
||||
/// bad address does not block delivery to every other recipient.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Send(BroadcastForm form)
|
||||
public IActionResult BackToCompose(AdminEmailComposeModel form)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
NormalizeComposeModel(form);
|
||||
return View("Index", form);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Preview(AdminEmailSelectionModel form)
|
||||
{
|
||||
NormalizeComposeModel(form);
|
||||
|
||||
if (!ValidateComposeModel(form))
|
||||
return View("Index", new AdminEmailComposeModel
|
||||
{
|
||||
Subject = form.Subject,
|
||||
BodyHtml = form.BodyHtml
|
||||
});
|
||||
|
||||
if (!ValidateCompanySelection(form))
|
||||
return View("SelectCompanies", await BuildSelectionModelAsync(form));
|
||||
|
||||
var preview = await BuildPreviewModelAsync(form);
|
||||
return View(preview);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> BackToSelectCompanies(AdminEmailSelectionModel form)
|
||||
{
|
||||
NormalizeComposeModel(form);
|
||||
return View("SelectCompanies", await BuildSelectionModelAsync(form));
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Send(AdminEmailSendRequest form)
|
||||
{
|
||||
NormalizeComposeModel(form);
|
||||
|
||||
if (!ValidateComposeModel(form))
|
||||
return View("Index", new AdminEmailComposeModel
|
||||
{
|
||||
Subject = form.Subject,
|
||||
BodyHtml = form.BodyHtml
|
||||
});
|
||||
|
||||
if (!ValidateCompanySelection(form))
|
||||
return View("SelectCompanies", await BuildSelectionModelAsync(form));
|
||||
|
||||
var recipients = await LoadRecipientContextsAsync(form.CompanyIds!);
|
||||
var replyToEmail = await GetAdminReplyToAsync();
|
||||
const string replyToName = "Powder Coating Logix Admin";
|
||||
|
||||
var sent = 0;
|
||||
var failed = 0;
|
||||
var skipped = 0;
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
await PopulateViewBag();
|
||||
return View("Index", form);
|
||||
}
|
||||
var renderedSubject = RenderPlainTemplate(form.Subject, recipient);
|
||||
var renderedBody = RenderHtmlTemplate(form.BodyHtml, recipient);
|
||||
var plainTextBody = ConvertHtmlToPlainText(renderedBody);
|
||||
|
||||
var recipients = await BuildRecipientListAsync(form.Target, form.PlanFilter, form.CompanyIds);
|
||||
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
TempData["Error"] = "No recipients matched the selected criteria.";
|
||||
await PopulateViewBag();
|
||||
return View("Index", form);
|
||||
}
|
||||
|
||||
int sent = 0, failed = 0;
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
var encodedBody = System.Net.WebUtility.HtmlEncode(form.Body).Replace("\n", "<br>");
|
||||
|
||||
foreach (var (email, name, unsubToken) in recipients)
|
||||
{
|
||||
var unsubUrl = $"{baseUrl}/Unsubscribe/BroadcastEmail/{unsubToken}";
|
||||
var htmlBody = $@"
|
||||
<div style=""font-family:sans-serif;max-width:600px;margin:0 auto"">
|
||||
<p>{encodedBody}</p>
|
||||
<hr style=""border:none;border-top:1px solid #eee;margin:24px 0"">
|
||||
<p style=""font-size:12px;color:#999"">
|
||||
This message was sent by the Powder Coating Logix platform team.<br>
|
||||
<a href=""{unsubUrl}"" style=""color:#999"">Unsubscribe from platform announcements</a>
|
||||
</p>
|
||||
</div>";
|
||||
if (string.IsNullOrWhiteSpace(recipient.RecipientEmail))
|
||||
{
|
||||
skipped++;
|
||||
await WriteLogAsync(new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannel.Email,
|
||||
NotificationType = NotificationType.AdminEmail,
|
||||
Status = NotificationStatus.Skipped,
|
||||
RecipientName = recipient.RecipientName,
|
||||
Recipient = string.Empty,
|
||||
Subject = renderedSubject,
|
||||
Message = plainTextBody,
|
||||
ErrorMessage = "Company primary contact email is not configured.",
|
||||
SentAt = DateTime.UtcNow,
|
||||
CompanyId = recipient.CompanyId
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var wrappedHtml = WrapRenderedHtml(renderedBody);
|
||||
var (success, error) = await _emailService.SendEmailAsync(
|
||||
email, name, form.Subject, form.Body, htmlBody);
|
||||
recipient.RecipientEmail,
|
||||
recipient.RecipientName,
|
||||
renderedSubject,
|
||||
plainTextBody,
|
||||
wrappedHtml,
|
||||
replyToEmail: replyToEmail,
|
||||
replyToName: replyToEmail is null ? null : replyToName);
|
||||
|
||||
if (success) sent++;
|
||||
else
|
||||
else failed++;
|
||||
|
||||
await WriteLogAsync(new NotificationLog
|
||||
{
|
||||
failed++;
|
||||
_logger.LogWarning("Broadcast email failed for {Email}: {Error}", email, error);
|
||||
}
|
||||
Channel = NotificationChannel.Email,
|
||||
NotificationType = NotificationType.AdminEmail,
|
||||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = recipient.RecipientName,
|
||||
Recipient = recipient.RecipientEmail,
|
||||
Subject = renderedSubject,
|
||||
Message = plainTextBody,
|
||||
ErrorMessage = error,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CompanyId = recipient.CompanyId
|
||||
});
|
||||
}
|
||||
|
||||
TempData["Success"] = $"Broadcast sent: {sent} delivered, {failed} failed. Total recipients: {recipients.Count}.";
|
||||
TempData["Success"] = $"Admin email processed for {recipients.Count} selected compan{(recipients.Count == 1 ? "y" : "ies")}: {sent} sent, {failed} failed, {skipped} skipped.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the list of (email, name, unsubscribe-token) tuples for the given
|
||||
/// targeting criteria. Companies are excluded when <c>MarketingEmailOptOut</c>
|
||||
/// is true — honouring prior unsubscribes — or when <c>PrimaryContactEmail</c>
|
||||
/// is missing. The "specific" target requires at least one <c>companyIds</c>
|
||||
/// entry and returns an empty list otherwise to prevent accidental all-company sends.
|
||||
/// <c>IgnoreQueryFilters()</c> is required because this query spans companies.
|
||||
/// </summary>
|
||||
private async Task<List<(string Email, string Name, string UnsubToken)>> BuildRecipientListAsync(
|
||||
string? target, string? planFilter, int[]? companyIds)
|
||||
private bool ValidateComposeModel(AdminEmailComposeModel form)
|
||||
{
|
||||
var companyQuery = _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted && c.IsActive && !string.IsNullOrEmpty(c.PrimaryContactEmail)
|
||||
&& !c.MarketingEmailOptOut);
|
||||
if (string.IsNullOrWhiteSpace(form.Subject))
|
||||
ModelState.AddModelError(nameof(form.Subject), "Subject is required.");
|
||||
|
||||
target ??= "active";
|
||||
if (string.IsNullOrWhiteSpace(ConvertHtmlToPlainText(form.BodyHtml)))
|
||||
ModelState.AddModelError(nameof(form.BodyHtml), "Message body is required.");
|
||||
|
||||
switch (target)
|
||||
return ModelState.IsValid;
|
||||
}
|
||||
|
||||
private bool ValidateCompanySelection(AdminEmailSelectionModel form)
|
||||
{
|
||||
if (form.CompanyIds == null || form.CompanyIds.Length == 0)
|
||||
ModelState.AddModelError(nameof(form.CompanyIds), "Select at least one company.");
|
||||
|
||||
return ModelState.IsValid;
|
||||
}
|
||||
|
||||
private static void NormalizeComposeModel(AdminEmailComposeModel form)
|
||||
{
|
||||
form.Subject = (form.Subject ?? string.Empty).Trim();
|
||||
form.BodyHtml = SanitizeHtml(form.BodyHtml);
|
||||
}
|
||||
|
||||
private async Task<AdminEmailSelectionModel> BuildSelectionModelAsync(AdminEmailComposeModel form)
|
||||
{
|
||||
var selectedIds = form is AdminEmailSelectionModel selection && selection.CompanyIds != null
|
||||
? selection.CompanyIds
|
||||
: Array.Empty<int>();
|
||||
|
||||
return new AdminEmailSelectionModel
|
||||
{
|
||||
case "active":
|
||||
companyQuery = companyQuery.Where(c =>
|
||||
c.SubscriptionStatus == SubscriptionStatus.Active ||
|
||||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod);
|
||||
break;
|
||||
case "plan":
|
||||
if (!string.IsNullOrWhiteSpace(planFilter) && int.TryParse(planFilter, out var planInt))
|
||||
companyQuery = companyQuery.Where(c => c.SubscriptionPlan == planInt);
|
||||
break;
|
||||
case "status_grace":
|
||||
companyQuery = companyQuery.Where(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod);
|
||||
break;
|
||||
case "status_expired":
|
||||
companyQuery = companyQuery.Where(c => c.SubscriptionStatus == SubscriptionStatus.Expired);
|
||||
break;
|
||||
case "specific":
|
||||
if (companyIds != null && companyIds.Length > 0)
|
||||
companyQuery = companyQuery.Where(c => companyIds.Contains(c.Id));
|
||||
else
|
||||
return new List<(string, string, string)>();
|
||||
break;
|
||||
case "all":
|
||||
default:
|
||||
break;
|
||||
Subject = form.Subject,
|
||||
BodyHtml = form.BodyHtml,
|
||||
CompanyIds = selectedIds,
|
||||
AvailableCompanies = await LoadCompanyOptionsAsync(selectedIds)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<AdminEmailPreviewModel> BuildPreviewModelAsync(AdminEmailSelectionModel form)
|
||||
{
|
||||
var recipients = await LoadRecipientContextsAsync(form.CompanyIds!);
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
return new AdminEmailPreviewModel
|
||||
{
|
||||
Subject = form.Subject,
|
||||
BodyHtml = form.BodyHtml,
|
||||
CompanyIds = form.CompanyIds,
|
||||
SelectedCompanies = [],
|
||||
EligibleCount = 0,
|
||||
SkippedCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
var companies = await companyQuery
|
||||
.Select(c => new { c.PrimaryContactEmail, c.CompanyName, c.MarketingUnsubscribeToken })
|
||||
.ToListAsync();
|
||||
var sampleRecipient = recipients.FirstOrDefault(r => !string.IsNullOrWhiteSpace(r.RecipientEmail))
|
||||
?? recipients.First();
|
||||
|
||||
return companies
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.PrimaryContactEmail))
|
||||
.Select(c => (c.PrimaryContactEmail!, c.CompanyName, c.MarketingUnsubscribeToken))
|
||||
.ToList();
|
||||
var sampleSubject = RenderPlainTemplate(form.Subject, sampleRecipient);
|
||||
var sampleBody = RenderHtmlTemplate(form.BodyHtml, sampleRecipient);
|
||||
|
||||
return new AdminEmailPreviewModel
|
||||
{
|
||||
Subject = form.Subject,
|
||||
BodyHtml = form.BodyHtml,
|
||||
CompanyIds = form.CompanyIds,
|
||||
SelectedCompanies = recipients.Select(r => new AdminEmailRecipientPreviewRow
|
||||
{
|
||||
CompanyId = r.CompanyId,
|
||||
CompanyName = r.CompanyName,
|
||||
RecipientName = r.RecipientName,
|
||||
RecipientEmail = r.RecipientEmail,
|
||||
CompanyAdminName = r.CompanyAdminName,
|
||||
CompanyAdminEmail = r.CompanyAdminEmail,
|
||||
CanSend = !string.IsNullOrWhiteSpace(r.RecipientEmail),
|
||||
SkipReason = string.IsNullOrWhiteSpace(r.RecipientEmail)
|
||||
? "Missing primary contact email"
|
||||
: null
|
||||
}).ToList(),
|
||||
EligibleCount = recipients.Count(r => !string.IsNullOrWhiteSpace(r.RecipientEmail)),
|
||||
SkippedCount = recipients.Count(r => string.IsNullOrWhiteSpace(r.RecipientEmail)),
|
||||
SamplePreview = new AdminEmailRenderedPreview
|
||||
{
|
||||
CompanyName = sampleRecipient.CompanyName,
|
||||
RecipientName = sampleRecipient.RecipientName,
|
||||
RecipientEmail = sampleRecipient.RecipientEmail,
|
||||
RenderedSubject = sampleSubject,
|
||||
RenderedHtmlBody = WrapRenderedHtml(sampleBody)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hydrates ViewBag with the data sets needed by the broadcast compose view:
|
||||
/// active subscription plan configs (for the plan-filter dropdown),
|
||||
/// all non-deleted active companies (for the specific-company picker),
|
||||
/// and a live count of active/grace-period companies shown in the UI summary.
|
||||
/// Centralised here so it can be called from both <see cref="Index"/> and the
|
||||
/// validation-failure branch of <see cref="Send"/>.
|
||||
/// </summary>
|
||||
private async Task PopulateViewBag()
|
||||
private async Task<List<AdminEmailCompanyOption>> LoadCompanyOptionsAsync(IReadOnlyCollection<int> selectedIds)
|
||||
{
|
||||
ViewBag.PlanConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToListAsync();
|
||||
|
||||
ViewBag.Companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted && c.IsActive)
|
||||
var companies = await _db.Companies
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted)
|
||||
.OrderBy(c => c.CompanyName)
|
||||
.Select(c => new { c.Id, c.CompanyName })
|
||||
.Select(c => new AdminEmailCompanyOption
|
||||
{
|
||||
CompanyId = c.Id,
|
||||
CompanyName = c.CompanyName,
|
||||
PrimaryContactName = c.PrimaryContactName,
|
||||
PrimaryContactEmail = c.PrimaryContactEmail,
|
||||
IsActive = c.IsActive
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
ViewBag.ActiveCount = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.CountAsync(c => !c.IsDeleted && c.IsActive &&
|
||||
(c.SubscriptionStatus == SubscriptionStatus.Active ||
|
||||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod));
|
||||
var adminLookup = await LoadCompanyAdminLookupAsync(companies.Select(c => c.CompanyId).ToArray());
|
||||
|
||||
foreach (var company in companies)
|
||||
{
|
||||
company.IsSelected = selectedIds.Contains(company.CompanyId);
|
||||
if (adminLookup.TryGetValue(company.CompanyId, out var admin))
|
||||
{
|
||||
company.CompanyAdminName = admin.FullName;
|
||||
company.CompanyAdminEmail = admin.Email;
|
||||
}
|
||||
}
|
||||
|
||||
return companies;
|
||||
}
|
||||
|
||||
private async Task<List<AdminEmailRecipientContext>> LoadRecipientContextsAsync(IReadOnlyCollection<int> companyIds)
|
||||
{
|
||||
var companies = await _db.Companies
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => companyIds.Contains(c.Id) && !c.IsDeleted)
|
||||
.OrderBy(c => c.CompanyName)
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.CompanyName,
|
||||
c.PrimaryContactName,
|
||||
c.PrimaryContactEmail
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var adminLookup = await LoadCompanyAdminLookupAsync(companies.Select(c => c.Id).ToArray());
|
||||
|
||||
return companies.Select(company =>
|
||||
{
|
||||
adminLookup.TryGetValue(company.Id, out var admin);
|
||||
|
||||
return new AdminEmailRecipientContext
|
||||
{
|
||||
CompanyId = company.Id,
|
||||
CompanyName = company.CompanyName,
|
||||
RecipientName = string.IsNullOrWhiteSpace(company.PrimaryContactName)
|
||||
? company.CompanyName
|
||||
: company.PrimaryContactName,
|
||||
RecipientEmail = company.PrimaryContactEmail,
|
||||
FirstName = ExtractFirstName(company.PrimaryContactName, company.CompanyName),
|
||||
PrimaryContactName = company.PrimaryContactName,
|
||||
CompanyAdminName = admin?.FullName,
|
||||
CompanyAdminEmail = admin?.Email,
|
||||
CompanyAdminFirstName = ExtractFirstName(admin?.FullName, company.CompanyName)
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<Dictionary<int, CompanyAdminLookup>> LoadCompanyAdminLookupAsync(IReadOnlyCollection<int> companyIds)
|
||||
{
|
||||
var admins = await _db.Users
|
||||
.AsNoTracking()
|
||||
.Where(u => companyIds.Contains(u.CompanyId)
|
||||
&& u.CompanyRole == AppConstants.CompanyRoles.CompanyAdmin
|
||||
&& u.IsActive)
|
||||
.OrderBy(u => u.CreatedAt)
|
||||
.Select(u => new
|
||||
{
|
||||
u.CompanyId,
|
||||
u.FirstName,
|
||||
u.LastName,
|
||||
u.Email
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return admins
|
||||
.GroupBy(u => u.CompanyId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g =>
|
||||
{
|
||||
var admin = g.First();
|
||||
return new CompanyAdminLookup
|
||||
{
|
||||
FullName = $"{admin.FirstName} {admin.LastName}".Trim(),
|
||||
Email = admin.Email ?? string.Empty
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string?> GetAdminReplyToAsync()
|
||||
{
|
||||
var raw = await _platformSettings.GetAsync(PlatformSettingKeys.AdminNotificationEmail);
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
|
||||
return raw
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.FirstOrDefault(email => email.Contains('@'));
|
||||
}
|
||||
|
||||
private async Task WriteLogAsync(NotificationLog log)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _db.NotificationLogs.AddAsync(log);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to write admin email notification log for company {CompanyId}", log.CompanyId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string RenderPlainTemplate(string template, AdminEmailRecipientContext recipient)
|
||||
{
|
||||
var rendered = template ?? string.Empty;
|
||||
foreach (var replacement in BuildReplacementDictionary(recipient))
|
||||
rendered = rendered.Replace(replacement.Key, replacement.Value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return rendered.Trim();
|
||||
}
|
||||
|
||||
private static string RenderHtmlTemplate(string templateHtml, AdminEmailRecipientContext recipient)
|
||||
{
|
||||
var rendered = templateHtml ?? string.Empty;
|
||||
foreach (var replacement in BuildReplacementDictionary(recipient))
|
||||
rendered = rendered.Replace(
|
||||
replacement.Key,
|
||||
WebUtility.HtmlEncode(replacement.Value),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildReplacementDictionary(AdminEmailRecipientContext recipient) => new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["{{FirstName}}"] = recipient.FirstName,
|
||||
["{{FullName}}"] = recipient.RecipientName,
|
||||
["{{CompanyName}}"] = recipient.CompanyName,
|
||||
["{{PrimaryContactName}}"] = recipient.PrimaryContactName ?? recipient.RecipientName,
|
||||
["{{PrimaryContactEmail}}"] = recipient.RecipientEmail ?? string.Empty,
|
||||
["{{CompanyAdminFirstName}}"] = recipient.CompanyAdminFirstName ?? string.Empty,
|
||||
["{{CompanyAdminName}}"] = recipient.CompanyAdminName ?? string.Empty,
|
||||
["{{CompanyAdminEmail}}"] = recipient.CompanyAdminEmail ?? string.Empty
|
||||
};
|
||||
|
||||
private static string WrapRenderedHtml(string renderedHtmlBody)
|
||||
{
|
||||
return $"""
|
||||
<div style="font-family:Arial,Helvetica,sans-serif;max-width:700px;margin:0 auto;color:#1f2937;line-height:1.6;">
|
||||
{renderedHtmlBody}
|
||||
</div>
|
||||
""";
|
||||
}
|
||||
|
||||
private static string SanitizeHtml(string? html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return string.Empty;
|
||||
|
||||
var sanitized = CommentRegex.Replace(html, string.Empty);
|
||||
sanitized = ScriptRegex.Replace(sanitized, string.Empty);
|
||||
sanitized = sanitized.Replace("<div", "<p", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("</div>", "</p>", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
sanitized = TagRegex.Replace(sanitized, match =>
|
||||
{
|
||||
var isClosingTag = match.Groups[1].Value == "/";
|
||||
var tagName = match.Groups[2].Value.ToLowerInvariant();
|
||||
var attributes = match.Groups[3].Value;
|
||||
|
||||
if (!AllowedTags.Contains(tagName))
|
||||
return string.Empty;
|
||||
|
||||
if (tagName == "br")
|
||||
return "<br>";
|
||||
|
||||
if (tagName == "a")
|
||||
{
|
||||
if (isClosingTag)
|
||||
return "</a>";
|
||||
|
||||
var href = HrefRegex.Match(attributes);
|
||||
var hrefValue = href.Success ? href.Groups[2].Value : string.Empty;
|
||||
if (!IsSafeHref(hrefValue))
|
||||
return string.Empty;
|
||||
|
||||
var encodedHref = WebUtility.HtmlEncode(hrefValue);
|
||||
return $"""<a href="{encodedHref}" target="_blank" rel="noopener noreferrer">""";
|
||||
}
|
||||
|
||||
return isClosingTag ? $"</{tagName}>" : $"<{tagName}>";
|
||||
});
|
||||
|
||||
return sanitized.Trim();
|
||||
}
|
||||
|
||||
private static bool IsSafeHref(string? href)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(href))
|
||||
return false;
|
||||
|
||||
return href.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||
|| href.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| href.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ConvertHtmlToPlainText(string? html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return string.Empty;
|
||||
|
||||
var plain = html;
|
||||
plain = Regex.Replace(plain, @"<\s*br\s*/?\s*>", "\n", RegexOptions.IgnoreCase);
|
||||
plain = Regex.Replace(plain, @"<\s*/\s*(p|h1|h2|h3|h4|blockquote)\s*>", "\n\n", RegexOptions.IgnoreCase);
|
||||
plain = Regex.Replace(plain, @"<\s*li\s*>", "- ", RegexOptions.IgnoreCase);
|
||||
plain = Regex.Replace(plain, @"<\s*/\s*li\s*>", "\n", RegexOptions.IgnoreCase);
|
||||
plain = Regex.Replace(plain, @"<[^>]+>", string.Empty);
|
||||
plain = WebUtility.HtmlDecode(plain);
|
||||
plain = Regex.Replace(plain, @"\n{3,}", "\n\n");
|
||||
return plain.Trim();
|
||||
}
|
||||
|
||||
private static string ExtractFirstName(string? fullName, string fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fullName))
|
||||
return fallback;
|
||||
|
||||
var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return parts.Length == 0 ? fallback : parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
public class BroadcastForm
|
||||
public class AdminEmailComposeModel
|
||||
{
|
||||
public string Target { get; set; } = "active";
|
||||
public string? PlanFilter { get; set; }
|
||||
public int[]? CompanyIds { get; set; }
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
[Required]
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
public string Body { get; set; } = string.Empty;
|
||||
[Required]
|
||||
public string BodyHtml { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class AdminEmailSelectionModel : AdminEmailComposeModel
|
||||
{
|
||||
public int[]? CompanyIds { get; set; }
|
||||
public List<AdminEmailCompanyOption> AvailableCompanies { get; set; } = [];
|
||||
}
|
||||
|
||||
public class AdminEmailSendRequest : AdminEmailSelectionModel;
|
||||
|
||||
public class AdminEmailPreviewModel : AdminEmailSendRequest
|
||||
{
|
||||
public List<AdminEmailRecipientPreviewRow> SelectedCompanies { get; set; } = [];
|
||||
public int EligibleCount { get; set; }
|
||||
public int SkippedCount { get; set; }
|
||||
public AdminEmailRenderedPreview SamplePreview { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AdminEmailCompanyOption
|
||||
{
|
||||
public int CompanyId { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string? PrimaryContactName { get; set; }
|
||||
public string? PrimaryContactEmail { get; set; }
|
||||
public string? CompanyAdminName { get; set; }
|
||||
public string? CompanyAdminEmail { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public bool IsSelected { get; set; }
|
||||
}
|
||||
|
||||
public class AdminEmailRecipientPreviewRow
|
||||
{
|
||||
public int CompanyId { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string RecipientName { get; set; } = string.Empty;
|
||||
public string? RecipientEmail { get; set; }
|
||||
public string? CompanyAdminName { get; set; }
|
||||
public string? CompanyAdminEmail { get; set; }
|
||||
public bool CanSend { get; set; }
|
||||
public string? SkipReason { get; set; }
|
||||
}
|
||||
|
||||
public class AdminEmailRenderedPreview
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string RecipientName { get; set; } = string.Empty;
|
||||
public string? RecipientEmail { get; set; }
|
||||
public string RenderedSubject { get; set; } = string.Empty;
|
||||
public string RenderedHtmlBody { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class AdminEmailRecipientContext
|
||||
{
|
||||
public int CompanyId { get; init; }
|
||||
public string CompanyName { get; init; } = string.Empty;
|
||||
public string RecipientName { get; init; } = string.Empty;
|
||||
public string? RecipientEmail { get; init; }
|
||||
public string FirstName { get; init; } = string.Empty;
|
||||
public string? PrimaryContactName { get; init; }
|
||||
public string? CompanyAdminFirstName { get; init; }
|
||||
public string? CompanyAdminName { get; init; }
|
||||
public string? CompanyAdminEmail { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class CompanyAdminLookup
|
||||
{
|
||||
public string FullName { get; init; } = string.Empty;
|
||||
public string Email { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -25,7 +24,6 @@ public class ExpensesController : Controller
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<ExpensesController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IAzureBlobStorageService _blobStorage;
|
||||
private readonly StorageSettings _storageSettings;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
@@ -40,7 +38,6 @@ public class ExpensesController : Controller
|
||||
IMapper mapper,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<ExpensesController> logger,
|
||||
ApplicationDbContext context,
|
||||
IAzureBlobStorageService blobStorage,
|
||||
IOptions<StorageSettings> storageSettings,
|
||||
IAccountBalanceService accountBalanceService,
|
||||
@@ -51,7 +48,6 @@ public class ExpensesController : Controller
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
_blobStorage = blobStorage;
|
||||
_storageSettings = storageSettings.Value;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
@@ -80,28 +76,25 @@ public class ExpensesController : Controller
|
||||
[NonAction]
|
||||
public async Task<IActionResult> IndexLegacy(string? search, int? accountId, DateTime? from, DateTime? to, int page = 1, int pageSize = 25)
|
||||
{
|
||||
var query = _context.Expenses
|
||||
.Include(e => e.Vendor)
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Include(e => e.PaymentAccount)
|
||||
.Include(e => e.Job)
|
||||
.Where(e => !e.IsDeleted);
|
||||
var allExpenses = (await _unitOfWork.Expenses.GetAllAsync(
|
||||
false, e => e.Vendor, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Job))
|
||||
.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
query = query.Where(e => e.ExpenseNumber.Contains(search) ||
|
||||
e.Memo!.Contains(search) ||
|
||||
allExpenses = allExpenses.Where(e => e.ExpenseNumber.Contains(search) ||
|
||||
(e.Memo != null && e.Memo.Contains(search)) ||
|
||||
(e.Vendor != null && e.Vendor.CompanyName.Contains(search)));
|
||||
|
||||
if (accountId.HasValue)
|
||||
query = query.Where(e => e.ExpenseAccountId == accountId.Value);
|
||||
allExpenses = allExpenses.Where(e => e.ExpenseAccountId == accountId.Value);
|
||||
|
||||
if (from.HasValue)
|
||||
query = query.Where(e => e.Date >= from.Value);
|
||||
allExpenses = allExpenses.Where(e => e.Date >= from.Value);
|
||||
|
||||
if (to.HasValue)
|
||||
query = query.Where(e => e.Date <= to.Value);
|
||||
allExpenses = allExpenses.Where(e => e.Date <= to.Value);
|
||||
|
||||
var expenses = await query.OrderByDescending(e => e.CreatedAt).ToListAsync();
|
||||
var expenses = allExpenses.OrderByDescending(e => e.CreatedAt).ToList();
|
||||
var dtos = _mapper.Map<List<ExpenseListDto>>(expenses);
|
||||
|
||||
ViewBag.Search = search;
|
||||
@@ -110,11 +103,11 @@ public class ExpensesController : Controller
|
||||
ViewBag.To = to?.ToString("yyyy-MM-dd");
|
||||
ViewBag.TotalAmount = dtos.Sum(e => e.Amount);
|
||||
|
||||
var expenseAccounts = await _context.Accounts
|
||||
.Where(a => !a.IsDeleted && a.IsActive &&
|
||||
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods))
|
||||
var expenseAccounts = (await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive &&
|
||||
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)))
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
ViewBag.AccountFilter = expenseAccounts
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
@@ -297,12 +290,8 @@ public class ExpensesController : Controller
|
||||
{
|
||||
if (id == null) return NotFound();
|
||||
|
||||
var expense = await _context.Expenses
|
||||
.Include(e => e.Vendor)
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Include(e => e.PaymentAccount)
|
||||
.Include(e => e.Job)
|
||||
.FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted);
|
||||
var expense = await _unitOfWork.Expenses.GetByIdAsync(
|
||||
id.Value, false, e => e.Vendor, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Job);
|
||||
|
||||
if (expense == null) return NotFound();
|
||||
return View(_mapper.Map<ExpenseDto>(expense));
|
||||
@@ -445,12 +434,11 @@ public class ExpensesController : Controller
|
||||
private async Task<string> GenerateExpenseNumberAsync()
|
||||
{
|
||||
var prefix = $"EXP-{DateTime.Now:yyMM}-";
|
||||
var last = await _context.Expenses
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.ExpenseNumber.StartsWith(prefix))
|
||||
var last = (await _unitOfWork.Expenses.FindAsync(
|
||||
e => e.ExpenseNumber.StartsWith(prefix), ignoreQueryFilters: true))
|
||||
.OrderByDescending(e => e.ExpenseNumber)
|
||||
.Select(e => e.ExpenseNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
.FirstOrDefault();
|
||||
|
||||
int next = 1;
|
||||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.ViewModels.GuidedActivation;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class GuidedActivationController : Controller
|
||||
{
|
||||
private const string SampleCustomerName = "Sample Customer";
|
||||
private const string SampleCustomerMarker = "Guided activation sample customer";
|
||||
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<GuidedActivationController> _logger;
|
||||
|
||||
public GuidedActivationController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<GuidedActivationController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Start()
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
if (!prefs.SetupWizardCompleted)
|
||||
return RedirectToAction("Step", "SetupWizard", new { step = 1 });
|
||||
|
||||
if (prefs.FirstWorkflowCompleted)
|
||||
return RedirectToAction("Index", "Dashboard");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(prefs.OnboardingPath))
|
||||
return RedirectToAction(nameof(Select));
|
||||
|
||||
return prefs.OnboardingPath switch
|
||||
{
|
||||
AppConstants.GuidedActivation.QuoteFirstPath => RedirectToAction(nameof(StartQuoteFlow)),
|
||||
AppConstants.GuidedActivation.JobFirstPath => RedirectToAction(nameof(StartJobFlow)),
|
||||
_ => RedirectToAction(nameof(Select))
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Select()
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
if (!prefs.SetupWizardCompleted)
|
||||
return RedirectToAction("Step", "SetupWizard", new { step = 1 });
|
||||
|
||||
if (prefs.FirstWorkflowCompleted)
|
||||
return RedirectToAction("Index", "Dashboard");
|
||||
|
||||
return View(new GuidedActivationSelectionViewModel
|
||||
{
|
||||
OnboardingPath = prefs.OnboardingPath
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Select(GuidedActivationSelectionViewModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return View(model);
|
||||
|
||||
if (model.OnboardingPath != AppConstants.GuidedActivation.QuoteFirstPath
|
||||
&& model.OnboardingPath != AppConstants.GuidedActivation.JobFirstPath)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.OnboardingPath), "Please choose a workflow path.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
prefs.OnboardingPath = model.OnboardingPath;
|
||||
prefs.GuidedActivationDismissedAt = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Guided activation path selected for company {CompanyId}: {Path}",
|
||||
prefs.CompanyId, prefs.OnboardingPath);
|
||||
|
||||
return RedirectToAction(nameof(Start));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Skip()
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
prefs.GuidedActivationDismissedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Guided activation dismissed for company {CompanyId}", prefs.CompanyId);
|
||||
return RedirectToAction("Index", "Dashboard");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CompleteFromJob(int id)
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
if (!prefs.FirstWorkflowCompleted)
|
||||
{
|
||||
prefs.FirstWorkflowCompleted = true;
|
||||
prefs.FirstWorkflowCompletedAt = DateTime.UtcNow;
|
||||
prefs.GuidedActivationDismissedAt = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Guided activation completed from job {JobId} for company {CompanyId}",
|
||||
id, prefs.CompanyId);
|
||||
}
|
||||
|
||||
TempData["Success"] = "Your first workflow is complete. You're ready to keep going.";
|
||||
return RedirectToAction("Details", "Jobs", new { id, guidedActivation = AppConstants.GuidedActivation.JobCreatedStep });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CompleteFromInvoice(int id)
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
if (!prefs.FirstWorkflowCompleted)
|
||||
{
|
||||
prefs.FirstWorkflowCompleted = true;
|
||||
prefs.FirstWorkflowCompletedAt = DateTime.UtcNow;
|
||||
prefs.GuidedActivationDismissedAt = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Guided activation completed from invoice {InvoiceId} for company {CompanyId}",
|
||||
id, prefs.CompanyId);
|
||||
}
|
||||
|
||||
TempData["Success"] = "Your first workflow is complete. You're ready to keep going.";
|
||||
return RedirectToAction("Details", "Invoices", new { id, guidedActivation = AppConstants.GuidedActivation.InvoiceCreatedStep });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> StartQuoteFlow()
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
prefs.OnboardingPath = AppConstants.GuidedActivation.QuoteFirstPath;
|
||||
prefs.GuidedActivationDismissedAt = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var customer = await GetOrCreateOnboardingCustomerAsync(prefs.CompanyId);
|
||||
|
||||
return RedirectToAction("Create", "Quotes", new
|
||||
{
|
||||
customerId = customer.Id,
|
||||
guidedActivation = AppConstants.GuidedActivation.QuoteFirstPath
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> StartJobFlow()
|
||||
{
|
||||
var prefs = await LoadPreferencesAsync();
|
||||
prefs.OnboardingPath = AppConstants.GuidedActivation.JobFirstPath;
|
||||
prefs.GuidedActivationDismissedAt = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var customer = await GetOrCreateOnboardingCustomerAsync(prefs.CompanyId);
|
||||
|
||||
return RedirectToAction("Create", "Jobs", new
|
||||
{
|
||||
customerId = customer.Id,
|
||||
guidedActivation = AppConstants.GuidedActivation.JobFirstPath
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<CompanyPreferences> LoadPreferencesAsync()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null)
|
||||
throw new InvalidOperationException("No company context available for guided activation.");
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.Preferences!)
|
||||
?? throw new InvalidOperationException("Company not found.");
|
||||
|
||||
if (company.Preferences != null)
|
||||
return company.Preferences;
|
||||
|
||||
var prefs = new CompanyPreferences { CompanyId = companyId.Value };
|
||||
await _unitOfWork.CompanyPreferences.AddAsync(prefs);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return prefs;
|
||||
}
|
||||
|
||||
private async Task<Customer> GetOrCreateOnboardingCustomerAsync(int companyId)
|
||||
{
|
||||
var existing = (await _unitOfWork.Customers.FindAsync(c =>
|
||||
c.CompanyId == companyId
|
||||
&& !c.IsDeleted
|
||||
&& (c.GeneralNotes == SampleCustomerMarker || c.CompanyName == SampleCustomerName)))
|
||||
.OrderBy(c => c.Id)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
CompanyId = companyId,
|
||||
CompanyName = SampleCustomerName,
|
||||
ContactFirstName = "Sample",
|
||||
ContactLastName = "Customer",
|
||||
Phone = "(555) 010-0001",
|
||||
IsCommercial = false,
|
||||
GeneralNotes = SampleCustomerMarker,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await _unitOfWork.Customers.AddAsync(customer);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Created guided activation sample customer {CustomerId} for company {CompanyId}",
|
||||
customer.Id, companyId);
|
||||
|
||||
return customer;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Web.Extensions;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -10,12 +8,12 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize]
|
||||
public class InAppNotificationsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenant;
|
||||
|
||||
public InAppNotificationsController(ApplicationDbContext db, ITenantContext tenant)
|
||||
public InAppNotificationsController(IUnitOfWork unitOfWork, ITenantContext tenant)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenant = tenant;
|
||||
}
|
||||
|
||||
@@ -27,23 +25,15 @@ public class InAppNotificationsController : Controller
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
pageSize = pageSize is 10 or 25 or 50 ? pageSize : 25;
|
||||
|
||||
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
|
||||
var all = _tenant.IsPlatformAdmin()
|
||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||
n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList()
|
||||
: (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList();
|
||||
|
||||
if (_tenant.IsPlatformAdmin())
|
||||
{
|
||||
query = _db.InAppNotifications
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted && n.CompanyId == 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
query = _db.InAppNotifications.AsQueryable();
|
||||
}
|
||||
var totalCount = all.Count;
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
var items = await query
|
||||
.AsNoTracking()
|
||||
var tz = ViewBag.CompanyTimeZone as string;
|
||||
var items = all
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
@@ -58,7 +48,7 @@ public class InAppNotificationsController : Controller
|
||||
n.ReadAt,
|
||||
CreatedAt = n.CreatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
ViewBag.TotalCount = totalCount;
|
||||
ViewBag.PageNumber = pageNumber;
|
||||
@@ -68,31 +58,22 @@ public class InAppNotificationsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint that returns the 20 most recent notifications (read and unread) for the bell dropdown. The response includes a count of unread items so the badge can be updated without a separate round-trip.
|
||||
/// AJAX endpoint that returns the 20 most recent notifications (read and unread) for the bell dropdown.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Recent()
|
||||
{
|
||||
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
|
||||
|
||||
if (_tenant.IsPlatformAdmin())
|
||||
{
|
||||
query = _db.InAppNotifications
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted && n.CompanyId == 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
query = _db.InAppNotifications.AsQueryable();
|
||||
}
|
||||
var all = _tenant.IsPlatformAdmin()
|
||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||
n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList()
|
||||
: (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList();
|
||||
|
||||
var tz = ViewBag.CompanyTimeZone as string;
|
||||
var items = await query
|
||||
.AsNoTracking()
|
||||
var items = all
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(20)
|
||||
.Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.IsRead, n.CreatedAt })
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var unreadCount = items.Count(n => !n.IsRead);
|
||||
return Json(new { count = unreadCount, items = items.Select(n => new {
|
||||
@@ -102,34 +83,19 @@ public class InAppNotificationsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint that returns only unread notifications (up to 20) plus an unread count for the bell badge. Used by the initial page load poll; after that the bell relies on Recent to show history.
|
||||
/// AJAX endpoint that returns only unread notifications (up to 20) plus an unread count for the bell badge.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Unread()
|
||||
{
|
||||
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
|
||||
|
||||
if (_tenant.IsPlatformAdmin())
|
||||
{
|
||||
// SuperAdmins see only platform-level notifications (CompanyId = 0)
|
||||
query = _db.InAppNotifications
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular users see their company's notifications (global filter handles tenant isolation)
|
||||
query = _db.InAppNotifications.Where(n => !n.IsRead);
|
||||
}
|
||||
var items = _tenant.IsPlatformAdmin()
|
||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true))
|
||||
.OrderByDescending(n => n.CreatedAt).Take(20).ToList()
|
||||
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead))
|
||||
.OrderByDescending(n => n.CreatedAt).Take(20).ToList();
|
||||
|
||||
var tz = ViewBag.CompanyTimeZone as string;
|
||||
var items = await query
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(20)
|
||||
.Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.CreatedAt })
|
||||
.ToListAsync();
|
||||
|
||||
return Json(new { count = items.Count, items = items.Select(n => new {
|
||||
n.Id, n.Title, n.Message, n.Link, n.NotificationType,
|
||||
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt")
|
||||
@@ -137,44 +103,38 @@ public class InAppNotificationsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a single notification as read and records the timestamp. Tenant isolation is enforced manually for SuperAdmins (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter.
|
||||
/// Marks a single notification as read. Tenant isolation is enforced manually for SuperAdmins (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> MarkRead(int id)
|
||||
{
|
||||
var notification = _tenant.IsPlatformAdmin()
|
||||
? await _db.InAppNotifications.IgnoreQueryFilters().FirstOrDefaultAsync(n => n.Id == id && n.CompanyId == 0 && !n.IsDeleted)
|
||||
: await _db.InAppNotifications.FirstOrDefaultAsync(n => n.Id == id);
|
||||
? await _unitOfWork.InAppNotifications.FirstOrDefaultAsync(
|
||||
n => n.Id == id && n.CompanyId == 0 && !n.IsDeleted, ignoreQueryFilters: true)
|
||||
: await _unitOfWork.InAppNotifications.GetByIdAsync(id);
|
||||
|
||||
if (notification == null) return NotFound();
|
||||
|
||||
notification.IsRead = true;
|
||||
notification.ReadAt = DateTime.UtcNow;
|
||||
notification.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks every unread notification as read for the current user's scope in a single SaveChanges call for efficiency. Returns the count of items marked so the UI can update the badge without refetching.
|
||||
/// Marks every unread notification as read for the current user's scope in a single SaveChanges call.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> MarkAllRead()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
List<PowderCoating.Core.Entities.InAppNotification> unread;
|
||||
if (_tenant.IsPlatformAdmin())
|
||||
{
|
||||
unread = await _db.InAppNotifications.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead)
|
||||
.ToListAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
unread = await _db.InAppNotifications.Where(n => !n.IsRead).ToListAsync();
|
||||
}
|
||||
var unread = _tenant.IsPlatformAdmin()
|
||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)).ToList()
|
||||
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead)).ToList();
|
||||
|
||||
foreach (var n in unread)
|
||||
{
|
||||
@@ -183,7 +143,7 @@ public class InAppNotificationsController : Controller
|
||||
n.UpdatedAt = now;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Json(new { success = true, count = unread.Count });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QRCoder;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
@@ -29,7 +27,6 @@ public class InventoryController : Controller
|
||||
private readonly IMeasurementConversionService _measurementService;
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public InventoryController(
|
||||
@@ -40,7 +37,6 @@ public class InventoryController : Controller
|
||||
IMeasurementConversionService measurementService,
|
||||
IInventoryAiLookupService aiLookupService,
|
||||
ISubscriptionService subscriptionService,
|
||||
ApplicationDbContext context,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
@@ -50,7 +46,6 @@ public class InventoryController : Controller
|
||||
_measurementService = measurementService;
|
||||
_aiLookupService = aiLookupService;
|
||||
_subscriptionService = subscriptionService;
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
@@ -166,12 +161,12 @@ public class InventoryController : Controller
|
||||
TotalCount = totalCount
|
||||
};
|
||||
|
||||
// Push stats and category list to the database rather than loading all rows
|
||||
var statsBase = _context.InventoryItems;
|
||||
ViewBag.Categories = await statsBase.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToListAsync();
|
||||
ViewBag.StatsLowStockCount = await statsBase.CountAsync(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||
ViewBag.StatsActiveCount = await statsBase.CountAsync(i => i.IsActive);
|
||||
ViewBag.StatsTotalValue = await statsBase.SumAsync(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
||||
// Load all items once to compute sidebar stats and category list in memory
|
||||
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
||||
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
||||
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
||||
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
||||
|
||||
// Set ViewBag for sorting and filters
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
@@ -560,20 +555,8 @@ public class InventoryController : Controller
|
||||
if (string.IsNullOrEmpty(colorName) && string.IsNullOrEmpty(name))
|
||||
return Json(new { success = true, photos = Array.Empty<object>(), totalCount = 0, page, pageSize });
|
||||
|
||||
IQueryable<JobPhoto> query = _context.JobPhotos
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Job).ThenInclude(j => j.Customer)
|
||||
.Where(p => !p.IsDeleted && p.Tags != null && p.Tags != "");
|
||||
|
||||
if (!string.IsNullOrEmpty(colorName) && !string.IsNullOrEmpty(name) && colorName != name)
|
||||
query = query.Where(p => p.Tags!.ToLower().Contains(colorName) || p.Tags!.ToLower().Contains(name));
|
||||
else if (!string.IsNullOrEmpty(colorName))
|
||||
query = query.Where(p => p.Tags!.ToLower().Contains(colorName));
|
||||
else
|
||||
query = query.Where(p => p.Tags!.ToLower().Contains(name!));
|
||||
|
||||
// Exact tag match (avoid "Black" matching "Gloss Black Semi-Gloss")
|
||||
var allMatches = await query.OrderByDescending(p => p.UploadedDate).ToListAsync();
|
||||
var allMatches = await _unitOfWork.JobPhotos.GetTaggedPhotosAsync(colorName, name);
|
||||
|
||||
var searchTerms = new[] { colorName, name }
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
@@ -620,15 +603,7 @@ public class InventoryController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var photos = await _context.JobPhotos
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Job).ThenInclude(j => j.Customer)
|
||||
.Include(p => p.Job).ThenInclude(j => j.JobItems).ThenInclude(ji => ji.Coats)
|
||||
.Where(p => !p.IsDeleted &&
|
||||
p.Job != null &&
|
||||
p.Job.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == id)))
|
||||
.OrderByDescending(p => p.UploadedDate)
|
||||
.ToListAsync();
|
||||
var photos = await _unitOfWork.JobPhotos.GetPhotosByPowderItemAsync(id);
|
||||
|
||||
var totalCount = photos.Count;
|
||||
var paged = photos
|
||||
@@ -728,12 +703,12 @@ public class InventoryController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var allCoatings = await _context.InventoryItems
|
||||
.AsNoTracking()
|
||||
.Include(i => i.InventoryCategory)
|
||||
.Where(i => !i.IsDeleted && i.InventoryCategory != null && i.InventoryCategory.IsCoating)
|
||||
var allCoatings = (await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.InventoryCategory != null && i.InventoryCategory.IsCoating,
|
||||
false,
|
||||
i => i.InventoryCategory))
|
||||
.OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
// Distinct manufacturer list for filter dropdown
|
||||
ViewBag.Manufacturers = allCoatings
|
||||
@@ -955,25 +930,39 @@ public class InventoryController : Controller
|
||||
|
||||
var userId = _userManager.GetUserId(User);
|
||||
|
||||
var myJobs = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId)
|
||||
var myJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||
j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId,
|
||||
false,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus))
|
||||
.OrderBy(j => j.JobNumber)
|
||||
.Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" })
|
||||
.ToListAsync();
|
||||
.Select(j => new ScanJobOption
|
||||
{
|
||||
Id = j.Id,
|
||||
JobNumber = j.JobNumber,
|
||||
CustomerName = j.Customer != null
|
||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||
: "No Customer"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
|
||||
var otherJobs = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id))
|
||||
var otherJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||
j => !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id),
|
||||
false,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus))
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(100)
|
||||
.Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" })
|
||||
.ToListAsync();
|
||||
.Select(j => new ScanJobOption
|
||||
{
|
||||
Id = j.Id,
|
||||
JobNumber = j.JobNumber,
|
||||
CustomerName = j.Customer != null
|
||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||
: "No Customer"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
ViewBag.ItemDto = _mapper.Map<InventoryItemDto>(item);
|
||||
ViewBag.MyJobs = myJobs;
|
||||
@@ -1169,29 +1158,8 @@ public class InventoryController : Controller
|
||||
.ToList();
|
||||
|
||||
// Build transactions query
|
||||
var txnQuery = _context.InventoryTransactions
|
||||
.AsNoTracking()
|
||||
.Include(t => t.InventoryItem)
|
||||
.Include(t => t.PurchaseOrder)
|
||||
.Include(t => t.Job)
|
||||
.Where(t => !t.IsDeleted);
|
||||
|
||||
if (inventoryItemId.HasValue)
|
||||
txnQuery = txnQuery.Where(t => t.InventoryItemId == inventoryItemId.Value);
|
||||
|
||||
if (dateFrom.HasValue)
|
||||
txnQuery = txnQuery.Where(t => t.TransactionDate >= dateFrom.Value);
|
||||
|
||||
if (dateTo.HasValue)
|
||||
txnQuery = txnQuery.Where(t => t.TransactionDate < dateTo.Value.AddDays(1));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<InventoryTransactionType>(typeFilter, out var parsedType))
|
||||
txnQuery = txnQuery.Where(t => t.TransactionType == parsedType);
|
||||
|
||||
var transactions = await txnQuery
|
||||
.OrderByDescending(t => t.TransactionDate)
|
||||
.Take(500)
|
||||
.ToListAsync();
|
||||
InventoryTransactionType? parsedType = !string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<InventoryTransactionType>(typeFilter, out var pt) ? pt : null;
|
||||
var transactions = await _unitOfWork.InventoryTransactions.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo, parsedType);
|
||||
|
||||
// Resolve JobId for legacy JobUsage transactions that stored job number in Reference but not JobId
|
||||
var unresolvedRefs = transactions
|
||||
@@ -1202,36 +1170,12 @@ public class InventoryController : Controller
|
||||
var jobRefLookup = new Dictionary<string, (int Id, string JobNumber)>();
|
||||
if (unresolvedRefs.Any())
|
||||
{
|
||||
var matched = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Where(j => unresolvedRefs.Contains(j.JobNumber))
|
||||
.Select(j => new { j.Id, j.JobNumber })
|
||||
.ToListAsync();
|
||||
var matched = await _unitOfWork.Jobs.FindAsync(j => unresolvedRefs.Contains(j.JobNumber));
|
||||
jobRefLookup = matched.ToDictionary(j => j.JobNumber, j => (j.Id, j.JobNumber));
|
||||
}
|
||||
|
||||
// Build powder usage logs query
|
||||
var usageQuery = _context.PowderUsageLogs
|
||||
.AsNoTracking()
|
||||
.Include(u => u.Job).ThenInclude(j => j.Customer)
|
||||
.Include(u => u.InventoryItem)
|
||||
.Include(u => u.JobItemCoat)
|
||||
.Where(u => !u.IsDeleted);
|
||||
|
||||
if (inventoryItemId.HasValue)
|
||||
usageQuery = usageQuery.Where(u => u.InventoryItemId == inventoryItemId.Value);
|
||||
|
||||
if (dateFrom.HasValue)
|
||||
usageQuery = usageQuery.Where(u => u.RecordedAt >= dateFrom.Value);
|
||||
|
||||
if (dateTo.HasValue)
|
||||
usageQuery = usageQuery.Where(u => u.RecordedAt < dateTo.Value.AddDays(1));
|
||||
|
||||
// Exclude JobUsage type from transactions when showing usage tab (avoid double-counting display)
|
||||
var usageLogs = await usageQuery
|
||||
.OrderByDescending(u => u.RecordedAt)
|
||||
.Take(500)
|
||||
.ToListAsync();
|
||||
// Powder usage logs with dynamic date + item filters
|
||||
var usageLogs = await _unitOfWork.PowderUsageLogs.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo);
|
||||
|
||||
InventoryItem? selectedItem = null;
|
||||
if (inventoryItemId.HasValue)
|
||||
|
||||
@@ -2,7 +2,6 @@ using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Invoice;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
@@ -10,7 +9,6 @@ using PowderCoating.Core.Entities;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Extensions;
|
||||
using PowderCoating.Web.Helpers;
|
||||
@@ -26,7 +24,6 @@ public class InvoicesController : Controller
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<InvoicesController> _logger;
|
||||
private readonly IPdfService _pdfService;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
@@ -37,7 +34,6 @@ public class InvoicesController : Controller
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<InvoicesController> logger,
|
||||
IPdfService pdfService,
|
||||
ApplicationDbContext context,
|
||||
ITenantContext tenantContext,
|
||||
INotificationService notificationService,
|
||||
IAccountBalanceService accountBalanceService)
|
||||
@@ -47,7 +43,6 @@ public class InvoicesController : Controller
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_pdfService = pdfService;
|
||||
_context = context;
|
||||
_tenantContext = tenantContext;
|
||||
_notificationService = notificationService;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
@@ -227,7 +222,7 @@ public class InvoicesController : Controller
|
||||
/// — Whether online payments are allowed: requires plan-level permission AND an active
|
||||
/// Stripe Connect account. Both conditions must be true; per-plan override wins if set.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
|
||||
{
|
||||
if (id == null) return NotFound();
|
||||
|
||||
@@ -256,13 +251,25 @@ public class InvoicesController : Controller
|
||||
|
||||
// Check whether this company's plan allows online payments
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(_tenantContext.GetCurrentCompanyId() ?? 0);
|
||||
var planConfig = company == null ? null : await _context.Set<SubscriptionPlanConfig>()
|
||||
.AsNoTracking()
|
||||
var planConfig = company == null ? null : await _unitOfWork.SubscriptionPlanConfigs
|
||||
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
||||
var onlinePaymentsAllowed = company?.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false);
|
||||
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
|
||||
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.InvoiceCreatedStep)
|
||||
{
|
||||
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = "This is how billing connects back to the job.",
|
||||
Message = "You’ve already seen the shop workflow. From here you can send the invoice, collect payment, or head back to the dashboard.",
|
||||
ActionText = "Go to Dashboard",
|
||||
ActionController = "Dashboard",
|
||||
ActionName = "Index"
|
||||
};
|
||||
}
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -291,19 +298,17 @@ public class InvoicesController : Controller
|
||||
/// — Revenue accounts are pulled from the catalog item's RevenueAccountId, falling back to
|
||||
/// account 4000 (default revenue) if no catalog item is linked.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Create(int? jobId)
|
||||
public async Task<IActionResult> Create(int? jobId, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var prefs = await _context.CompanyPreferences
|
||||
.AsNoTracking()
|
||||
var prefs = await _unitOfWork.CompanyPreferences
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted);
|
||||
|
||||
var costs = await _context.CompanyOperatingCosts
|
||||
.AsNoTracking()
|
||||
var costs = await _unitOfWork.CompanyOperatingCosts
|
||||
.FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted);
|
||||
|
||||
var dto = new CreateInvoiceDto
|
||||
@@ -321,9 +326,7 @@ public class InvoicesController : Controller
|
||||
if (job == null) return NotFound();
|
||||
|
||||
// Validate no existing invoice for this job
|
||||
var existing = await _context.Set<Invoice>()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.JobId == jobId.Value && i.CompanyId == currentUser.CompanyId && !i.IsDeleted);
|
||||
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value, includeDeleted: true);
|
||||
if (existing != null)
|
||||
return RedirectToAction(nameof(Details), new { id = existing.Id });
|
||||
|
||||
@@ -343,8 +346,8 @@ public class InvoicesController : Controller
|
||||
: new Dictionary<int, CatalogItem>();
|
||||
|
||||
// Fall back to the default revenue account (4000) if a catalog item has no specific account
|
||||
var defaultRevenueAccount = await _context.Set<Account>()
|
||||
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.CompanyId == currentUser.CompanyId && !a.IsDeleted);
|
||||
var defaultRevenueAccount = await _unitOfWork.Accounts
|
||||
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
|
||||
|
||||
// If the job came from a quote, load it so we can use the agreed pricing.
|
||||
// The quote stores the approved total including oven batch cost and shop supplies —
|
||||
@@ -352,9 +355,7 @@ public class InvoicesController : Controller
|
||||
PowderCoating.Core.Entities.Quote? sourceQuote = null;
|
||||
if (job.QuoteId.HasValue)
|
||||
{
|
||||
sourceQuote = await _context.Set<PowderCoating.Core.Entities.Quote>()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(q => q.Id == job.QuoteId.Value && !q.IsDeleted);
|
||||
sourceQuote = await _unitOfWork.Quotes.GetByIdAsync(job.QuoteId.Value);
|
||||
}
|
||||
|
||||
// Pre-populate from job items
|
||||
@@ -441,6 +442,7 @@ public class InvoicesController : Controller
|
||||
}
|
||||
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -471,7 +473,7 @@ public class InvoicesController : Controller
|
||||
/// declared outside the lambda and assigned inside — EF requires this pattern with closures.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreateInvoiceDto dto)
|
||||
public async Task<IActionResult> Create(CreateInvoiceDto dto, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -481,6 +483,7 @@ public class InvoicesController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -488,19 +491,19 @@ public class InvoicesController : Controller
|
||||
{
|
||||
ModelState.AddModelError("", "Please add at least one line item before saving.");
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
// Validate no existing invoice for this job before starting the transaction
|
||||
if (dto.JobId.HasValue)
|
||||
{
|
||||
var existing = await _context.Set<Invoice>()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.JobId == dto.JobId && i.CompanyId == currentUser.CompanyId && !i.IsDeleted);
|
||||
var existing = await _unitOfWork.Invoices.GetForJobAsync(dto.JobId.Value, includeDeleted: true);
|
||||
if (existing != null)
|
||||
{
|
||||
ModelState.AddModelError("", "An invoice already exists for this job.");
|
||||
await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -591,11 +594,10 @@ public class InvoicesController : Controller
|
||||
|
||||
// Auto-apply any unapplied deposits for this job (and its linked quote)
|
||||
var job = dto.JobId.HasValue ? await _unitOfWork.Jobs.GetByIdAsync(dto.JobId.Value) : null;
|
||||
var depositQuery = _context.Set<Deposit>()
|
||||
.Where(d => !d.IsDeleted && d.CompanyId == currentUser.CompanyId
|
||||
&& d.AppliedToInvoiceId == null
|
||||
&& (d.JobId == dto.JobId || (job != null && job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value)));
|
||||
pendingDeposits = await depositQuery.ToListAsync();
|
||||
pendingDeposits = (await _unitOfWork.Deposits.FindAsync(
|
||||
d => d.AppliedToInvoiceId == null
|
||||
&& (d.JobId == dto.JobId || (job != null && job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value))))
|
||||
.ToList();
|
||||
|
||||
foreach (var deposit in pendingDeposits)
|
||||
{
|
||||
@@ -658,8 +660,21 @@ public class InvoicesController : Controller
|
||||
var depositMsg = pendingDeposits.Any()
|
||||
? $" {pendingDeposits.Count} deposit(s) totaling {pendingDeposits.Sum(d => d.Amount):C} auto-applied."
|
||||
: "";
|
||||
var workflowJustCompleted = await StampInvoiceCreatedAsync(currentUser.CompanyId);
|
||||
TempData["Success"] = $"Invoice {invoiceNumber} created successfully.{depositMsg}{gcMsg}";
|
||||
return RedirectToAction(nameof(Details), new { id = invoice.Id });
|
||||
if (!string.IsNullOrWhiteSpace(guidedActivation) || workflowJustCompleted)
|
||||
{
|
||||
return RedirectToAction(nameof(Details), new
|
||||
{
|
||||
id = invoice!.Id,
|
||||
guidedActivation = AppConstants.GuidedActivation.InvoiceCreatedStep
|
||||
});
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new
|
||||
{
|
||||
id = invoice!.Id
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -667,6 +682,7 @@ public class InvoicesController : Controller
|
||||
TempData["Error"] = "An error occurred while creating the invoice.";
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser != null) await PopulateCreateViewBagAsync(currentUser.CompanyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -893,24 +909,27 @@ public class InvoicesController : Controller
|
||||
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
|
||||
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
|
||||
|
||||
bool pdfAndNotifSucceeded = false;
|
||||
try
|
||||
{
|
||||
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
|
||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl);
|
||||
pdfAndNotifSucceeded = true;
|
||||
}
|
||||
catch (Exception notifyEx)
|
||||
{
|
||||
_logger.LogWarning(notifyEx, "Invoice sent but notification failed for invoice {Id}", id);
|
||||
_logger.LogError(notifyEx,
|
||||
"Invoice {InvoiceId} ({InvoiceNumber}): PDF generation or email dispatch failed. " +
|
||||
"Inner: {InnerMessage}. Invoice status was already saved as Sent.",
|
||||
id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none");
|
||||
}
|
||||
|
||||
var notifLog = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.InvoiceId == id)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
||||
this.SetNotificationResultToast(notifLog);
|
||||
|
||||
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent.";
|
||||
if (!pdfAndNotifSucceeded)
|
||||
TempData["WarningPermanent"] = "The invoice is marked as sent, but PDF generation or the customer email failed. Check the notification logs or your email configuration.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1036,11 +1055,7 @@ public class InvoicesController : Controller
|
||||
}
|
||||
|
||||
|
||||
var paymentNotifLog = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.InvoiceId == id)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
||||
this.SetNotificationResultToast(paymentNotifLog);
|
||||
|
||||
TempData["Success"] = overpayment > 0
|
||||
@@ -1295,8 +1310,8 @@ public class InvoicesController : Controller
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating PDF for invoice {Id}", id);
|
||||
TempData["Error"] = "An error occurred while generating the PDF.";
|
||||
_logger.LogError(ex, "Error generating PDF for invoice {Id}. Inner: {Inner}", id, ex.InnerException?.Message ?? ex.Message);
|
||||
TempData["ErrorPermanent"] = $"PDF generation failed: {ex.Message}";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
}
|
||||
@@ -1318,9 +1333,7 @@ public class InvoicesController : Controller
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var existing = await _context.Set<Invoice>()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.JobId == jobId && i.CompanyId == currentUser.CompanyId && !i.IsDeleted);
|
||||
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId, includeDeleted: true);
|
||||
|
||||
if (existing != null)
|
||||
return RedirectToAction(nameof(Details), new { id = existing.Id });
|
||||
@@ -1379,11 +1392,7 @@ public class InvoicesController : Controller
|
||||
|
||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename);
|
||||
|
||||
var latestLog = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.InvoiceId == id)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
||||
|
||||
if (latestLog?.Status == NotificationStatus.Failed)
|
||||
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" });
|
||||
@@ -1413,16 +1422,10 @@ public class InvoicesController : Controller
|
||||
public async Task<IActionResult> NotificationsSent(int id)
|
||||
{
|
||||
var tz = ViewBag.CompanyTimeZone as string;
|
||||
var raw = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.InvoiceId == id)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
|
||||
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.Message, n.SentAt })
|
||||
.ToListAsync();
|
||||
|
||||
var logs = raw.Select(n => new { n.Id, n.Channel, n.Type, n.Status, n.RecipientName,
|
||||
n.Recipient, n.Subject, n.ErrorMessage, n.Message, SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
|
||||
var entries = await _unitOfWork.NotificationLogs.GetAllForInvoiceAsync(id);
|
||||
var logs = entries.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
|
||||
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.Message,
|
||||
SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
|
||||
return Json(logs);
|
||||
}
|
||||
|
||||
@@ -1467,32 +1470,24 @@ public class InvoicesController : Controller
|
||||
}
|
||||
|
||||
// Soft-delete line items
|
||||
var invoiceItems = await _context.InvoiceItems
|
||||
.Where(ii => ii.InvoiceId == id && !ii.IsDeleted)
|
||||
.ToListAsync();
|
||||
var invoiceItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == id);
|
||||
foreach (var item in invoiceItems)
|
||||
await _unitOfWork.InvoiceItems.SoftDeleteAsync(item.Id);
|
||||
|
||||
// Soft-delete any payments (draft invoices shouldn't have them, but be safe)
|
||||
var payments = await _context.Payments
|
||||
.Where(p => p.InvoiceId == id && !p.IsDeleted)
|
||||
.ToListAsync();
|
||||
var payments = await _unitOfWork.Payments.FindAsync(p => p.InvoiceId == id);
|
||||
foreach (var payment in payments)
|
||||
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
||||
|
||||
// Un-apply any deposits that were applied to this invoice so they can be
|
||||
// re-applied if the invoice is recreated from the same job
|
||||
var appliedDeposits = await _context.Deposits
|
||||
.Where(d => d.AppliedToInvoiceId == id && !d.IsDeleted)
|
||||
.ToListAsync();
|
||||
var appliedDeposits = await _unitOfWork.Deposits.FindAsync(d => d.AppliedToInvoiceId == id);
|
||||
foreach (var deposit in appliedDeposits)
|
||||
{
|
||||
deposit.AppliedToInvoiceId = null;
|
||||
deposit.AppliedDate = null;
|
||||
deposit.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
if (appliedDeposits.Any())
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Reverse account balances (mirror of Create): credit AR, debit revenue + sales tax
|
||||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
@@ -1522,36 +1517,11 @@ public class InvoicesController : Controller
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Loads a complete invoice aggregate from the DB context (bypasses the generic repository
|
||||
/// to allow the deep multi-level Include chain). Includes customer, job, preparer, all
|
||||
/// non-deleted line items with revenue accounts and generated GCs, all non-deleted payments
|
||||
/// with recorders and deposit accounts, refunds, credit applications, and GC redemptions.
|
||||
/// Returns null if the invoice doesn't exist or is soft-deleted — callers check for null
|
||||
/// and return NotFound rather than loading a partial object.
|
||||
/// Delegates to <see cref="IInvoiceRepository.LoadForViewAsync"/> which expresses the full
|
||||
/// eight-table include chain. Returns null if not found or soft-deleted.
|
||||
/// </summary>
|
||||
private async Task<Invoice?> LoadInvoiceForViewAsync(int id)
|
||||
{
|
||||
return await _context.Set<Invoice>()
|
||||
.Where(i => i.Id == id && !i.IsDeleted)
|
||||
.Include(i => i.Customer)
|
||||
.Include(i => i.Job)
|
||||
.Include(i => i.PreparedBy)
|
||||
.Include(i => i.SalesTaxAccount)
|
||||
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
|
||||
.ThenInclude(ii => ii.RevenueAccount)
|
||||
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
|
||||
.ThenInclude(ii => ii.GeneratedGiftCertificate)
|
||||
.Include(i => i.Payments.Where(p => !p.IsDeleted))
|
||||
.ThenInclude(p => p.RecordedBy)
|
||||
.Include(i => i.Payments.Where(p => !p.IsDeleted))
|
||||
.ThenInclude(p => p.DepositAccount)
|
||||
.Include(i => i.Refunds.Where(r => !r.IsDeleted))
|
||||
.ThenInclude(r => r.IssuedBy)
|
||||
.Include(i => i.CreditApplications.Where(ca => !ca.IsDeleted))
|
||||
.ThenInclude(ca => ca.CreditMemo)
|
||||
.Include(i => i.GiftCertificateRedemptions)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
private async Task<Invoice?> LoadInvoiceForViewAsync(int id) =>
|
||||
await _unitOfWork.Invoices.LoadForViewAsync(id);
|
||||
|
||||
/// <summary>
|
||||
/// Converts an Invoice entity to a fully populated InvoiceDto for the view layer. AutoMapper
|
||||
@@ -1730,11 +1700,10 @@ public class InvoicesController : Controller
|
||||
{
|
||||
var prefix = $"CM-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
|
||||
|
||||
var existing = await _context.Set<CreditMemo>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix))
|
||||
var existing = (await _unitOfWork.CreditMemos.FindAsync(
|
||||
m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix), true))
|
||||
.Select(m => m.MemoNumber)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var maxNum = 0;
|
||||
foreach (var num in existing)
|
||||
@@ -1757,26 +1726,18 @@ public class InvoicesController : Controller
|
||||
/// </summary>
|
||||
private async Task<string> GenerateInvoiceNumberAsync(int companyId)
|
||||
{
|
||||
var prefs = await _context.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.CompanyId == companyId && !p.IsDeleted)
|
||||
.Select(p => new { p.InvoiceNumberPrefix })
|
||||
.FirstOrDefaultAsync();
|
||||
var prefs = await _unitOfWork.CompanyPreferences
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
|
||||
var invoicePrefix = !string.IsNullOrWhiteSpace(prefs?.InvoiceNumberPrefix) ? prefs.InvoiceNumberPrefix : "INV";
|
||||
var prefix = $"{invoicePrefix}-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
|
||||
|
||||
var existing = await _context.Set<Invoice>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(i => i.CompanyId == companyId && i.InvoiceNumber.StartsWith(prefix))
|
||||
.Select(i => i.InvoiceNumber)
|
||||
.ToListAsync();
|
||||
|
||||
var last = await _unitOfWork.Invoices.GetLastInvoiceNumberByPrefixAsync(companyId, prefix);
|
||||
var maxNum = 0;
|
||||
foreach (var num in existing)
|
||||
if (last != null && last.Length >= prefix.Length + 4)
|
||||
{
|
||||
var suffix = num.Length >= prefix.Length + 4 ? num.Substring(prefix.Length) : "";
|
||||
if (int.TryParse(suffix, out int n) && n > maxNum)
|
||||
var suffix = last.Substring(prefix.Length);
|
||||
if (int.TryParse(suffix, out int n))
|
||||
maxNum = n;
|
||||
}
|
||||
|
||||
@@ -1795,8 +1756,7 @@ public class InvoicesController : Controller
|
||||
ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList();
|
||||
|
||||
// Expose company default tax rate and exempt customer IDs for client-side tax handling
|
||||
var costs = await _context.CompanyOperatingCosts
|
||||
.AsNoTracking()
|
||||
var costs = await _unitOfWork.CompanyOperatingCosts
|
||||
.FirstOrDefaultAsync(c => c.CompanyId == companyId && !c.IsDeleted);
|
||||
ViewBag.CompanyTaxPercent = costs?.TaxPercent ?? 0;
|
||||
ViewBag.TaxExemptCustomerIds = customers
|
||||
@@ -1805,12 +1765,12 @@ public class InvoicesController : Controller
|
||||
.ToHashSet();
|
||||
|
||||
// Merchandise items for the invoice merch picker (all active IsMerchandise items)
|
||||
var merchItems = await _context.Set<CatalogItem>()
|
||||
.Include(i => i.Category)
|
||||
.Where(i => i.IsMerchandise && i.IsActive && !i.IsDeleted && i.CompanyId == companyId)
|
||||
var allMerchItems = await _unitOfWork.CatalogItems.FindAsync(
|
||||
i => i.IsMerchandise && i.IsActive, false, i => i.Category);
|
||||
var merchItems = allMerchItems
|
||||
.OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name)
|
||||
.Select(i => new { i.Id, i.Name, i.SKU, CategoryName = i.Category.Name, i.DefaultPrice, i.RevenueAccountId })
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
ViewBag.MerchandiseItems = System.Text.Json.JsonSerializer.Serialize(merchItems,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase });
|
||||
}
|
||||
@@ -1838,15 +1798,13 @@ public class InvoicesController : Controller
|
||||
return accounts.FirstOrDefault()?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Looks up the "2200 Sales Tax Payable" account for this company, or any active OtherCurrentLiability with "tax" in the name.</summary>
|
||||
/// <summary>Looks up the "2200 Sales Tax Payable" account for this company, or any active Liability account with "tax" in the name.</summary>
|
||||
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
|
||||
{
|
||||
var taxAccount = await _context.Set<Account>()
|
||||
.Where(a => a.CompanyId == companyId && !a.IsDeleted && a.IsActive)
|
||||
.OrderBy(a => a.AccountNumber == "2200" ? 0 : 1)
|
||||
.ThenBy(a => a.AccountNumber)
|
||||
.FirstOrDefaultAsync(a => a.AccountNumber == "2200"
|
||||
|| (a.AccountType == AccountType.Liability && a.Name.ToLower().Contains("tax")));
|
||||
var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountNumber == "2200" && a.IsActive);
|
||||
taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax"));
|
||||
return taxAccount?.Id;
|
||||
}
|
||||
|
||||
@@ -2364,16 +2322,13 @@ public class InvoicesController : Controller
|
||||
/// </summary>
|
||||
private async Task<string?> TryGeneratePaymentTokenAsync(Invoice invoice)
|
||||
{
|
||||
var company = await _context.Companies
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted);
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(invoice.CompanyId);
|
||||
|
||||
if (company == null) return null;
|
||||
if (company.StripeConnectStatus != StripeConnectStatus.Active) return null;
|
||||
if (invoice.BalanceDue <= 0) return null;
|
||||
|
||||
var planConfig = await _context.Set<SubscriptionPlanConfig>()
|
||||
.AsNoTracking()
|
||||
var planConfig = await _unitOfWork.SubscriptionPlanConfigs
|
||||
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
||||
|
||||
var onlinePaymentsAllowed = company.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false);
|
||||
@@ -2398,8 +2353,7 @@ public class InvoicesController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var invoice = await _context.Invoices
|
||||
.FirstOrDefaultAsync(i => i.Id == id && !i.IsDeleted);
|
||||
var invoice = await _unitOfWork.Invoices.GetByIdAsync(id);
|
||||
if (invoice == null) return NotFound();
|
||||
|
||||
if (invoice.BalanceDue <= 0)
|
||||
@@ -2410,8 +2364,8 @@ public class InvoicesController : Controller
|
||||
return Json(new { success = false, message = "Online payments are not available for this company." });
|
||||
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Update(invoice);
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var paymentUrl = Url.Action("Index", "Payment", new { token }, Request.Scheme)!
|
||||
.Replace("/Payment/Index/", "/pay/");
|
||||
@@ -2445,27 +2399,11 @@ public class InvoicesController : Controller
|
||||
var endDate = to ?? startDate.AddMonths(1).AddDays(-1);
|
||||
var endDateInclusive = endDate.AddDays(1);
|
||||
|
||||
var invoices = await _context.Invoices
|
||||
.AsNoTracking()
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => !i.IsDeleted
|
||||
&& i.CompanyId == companyId
|
||||
&& i.OnlineAmountPaid > 0
|
||||
&& i.UpdatedAt >= startDate
|
||||
&& i.UpdatedAt < endDateInclusive)
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.ToListAsync();
|
||||
var invoices = await _unitOfWork.Invoices
|
||||
.GetOnlineInvoicesForPeriodAsync(companyId, startDate, endDateInclusive);
|
||||
|
||||
var refunds = await _context.Refunds
|
||||
.AsNoTracking()
|
||||
.Include(r => r.Invoice).ThenInclude(inv => inv!.Customer)
|
||||
.Where(r => !r.IsDeleted
|
||||
&& r.CompanyId == companyId
|
||||
&& r.RefundMethod == PaymentMethod.CreditDebitCard
|
||||
&& r.RefundDate >= startDate
|
||||
&& r.RefundDate < endDateInclusive)
|
||||
.OrderByDescending(r => r.RefundDate)
|
||||
.ToListAsync();
|
||||
var refunds = await _unitOfWork.Invoices
|
||||
.GetOnlineRefundsForPeriodAsync(companyId, startDate, endDateInclusive);
|
||||
|
||||
var vm = new OnlinePaymentsViewModel
|
||||
{
|
||||
@@ -2480,4 +2418,30 @@ public class InvoicesController : Controller
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
|
||||
{
|
||||
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
}
|
||||
|
||||
private async Task<bool> StampInvoiceCreatedAsync(int companyId)
|
||||
{
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null)
|
||||
return false;
|
||||
|
||||
var changed = false;
|
||||
|
||||
if (!prefs.FirstInvoiceCreatedAt.HasValue)
|
||||
{
|
||||
prefs.FirstInvoiceCreatedAt = DateTime.UtcNow;
|
||||
changed = true;
|
||||
_logger.LogInformation("Recorded first invoice creation for company {CompanyId}", companyId);
|
||||
}
|
||||
|
||||
if (changed)
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -23,35 +20,28 @@ public class JobTemplatesController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public JobTemplatesController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
ApplicationDbContext context)
|
||||
ITenantContext tenantContext)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays all non-deleted job templates for the current company, ordered by name, with their
|
||||
/// linked customer and item counts. Uses the direct <c>_context</c> query (bypassing
|
||||
/// <c>IUnitOfWork</c>) to leverage EF Core's filtered includes (<c>.Include(t => t.Items)</c>)
|
||||
/// which are not exposed through the generic repository pattern.
|
||||
/// linked customer and item counts. Multi-tenancy and soft-delete scoping are handled by global
|
||||
/// query filters; the typed repository provides the ThenInclude chain for items.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var templates = await _context.JobTemplates
|
||||
.Include(t => t.Customer)
|
||||
.Include(t => t.Items)
|
||||
.Where(t => !t.IsDeleted && t.CompanyId == companyId)
|
||||
.OrderBy(t => t.Name)
|
||||
.ToListAsync();
|
||||
var templates = await _unitOfWork.JobTemplates.GetAllAsync(
|
||||
false,
|
||||
t => t.Customer,
|
||||
t => t.Items);
|
||||
|
||||
templates = templates.OrderBy(t => t.Name).ToList();
|
||||
return View(templates);
|
||||
}
|
||||
|
||||
@@ -59,22 +49,12 @@ public class JobTemplatesController : Controller
|
||||
/// Shows the full template detail including all non-deleted items, each with their coats
|
||||
/// (including the linked inventory item for color/powder info) and prep services (including the
|
||||
/// prep service entity for the service name). Soft-deleted items, coats, and prep services are
|
||||
/// excluded via EF filtered includes so the view reflects the current active configuration.
|
||||
/// excluded via filtered includes in the typed repository.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
var template = await _context.JobTemplates
|
||||
.Include(t => t.Customer)
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
|
||||
.ThenInclude(p => p.PrepService)
|
||||
.FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted);
|
||||
|
||||
var template = await _unitOfWork.JobTemplates.LoadForDetailsAsync(id);
|
||||
if (template == null) return NotFound();
|
||||
|
||||
return View(template);
|
||||
}
|
||||
|
||||
@@ -85,9 +65,7 @@ public class JobTemplatesController : Controller
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var template = await _context.JobTemplates
|
||||
.FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted);
|
||||
|
||||
var template = await _unitOfWork.JobTemplates.GetByIdAsync(id);
|
||||
if (template == null) return NotFound();
|
||||
|
||||
await PopulateCustomerDropdown(template.CustomerId);
|
||||
@@ -150,12 +128,7 @@ public class JobTemplatesController : Controller
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var job = await _context.Jobs
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
|
||||
.FirstOrDefaultAsync(j => j.Id == jobId && !j.IsDeleted);
|
||||
var job = await _unitOfWork.Jobs.LoadForTemplateSnapshotAsync(jobId);
|
||||
|
||||
if (job == null) return NotFound();
|
||||
|
||||
@@ -254,18 +227,7 @@ public class JobTemplatesController : Controller
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetTemplatesJson()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var templates = await _context.JobTemplates
|
||||
.Include(t => t.Customer)
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
|
||||
.ThenInclude(p => p.PrepService)
|
||||
.Where(t => !t.IsDeleted && t.CompanyId == companyId && t.IsActive)
|
||||
.OrderBy(t => t.Name)
|
||||
.ToListAsync();
|
||||
var templates = await _unitOfWork.JobTemplates.GetAllActiveWithFullIncludesAsync();
|
||||
|
||||
var result = templates.Select(t => new
|
||||
{
|
||||
|
||||
@@ -15,7 +15,6 @@ using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Web.Helpers;
|
||||
using PowderCoating.Web.Hubs;
|
||||
|
||||
@@ -29,7 +28,6 @@ public class JobsController : Controller
|
||||
private readonly IJobPhotoService _jobPhotoService;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<JobsController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IMeasurementConversionService _measurementService;
|
||||
private readonly ILookupCacheService _lookupCache;
|
||||
@@ -45,7 +43,6 @@ public class JobsController : Controller
|
||||
IJobPhotoService jobPhotoService,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<JobsController> logger,
|
||||
ApplicationDbContext context,
|
||||
ITenantContext tenantContext,
|
||||
IMeasurementConversionService measurementService,
|
||||
ILookupCacheService lookupCache,
|
||||
@@ -60,7 +57,6 @@ public class JobsController : Controller
|
||||
_jobPhotoService = jobPhotoService;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
_tenantContext = tenantContext;
|
||||
_measurementService = measurementService;
|
||||
_lookupCache = lookupCache;
|
||||
@@ -229,7 +225,10 @@ public class JobsController : Controller
|
||||
/// columns are also shown for historical context.
|
||||
/// Uses the lookup cache so column headers stay consistent with the configurable status list.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Board(bool showTerminal = false)
|
||||
public async Task<IActionResult> Board(
|
||||
bool showTerminal = false,
|
||||
string? guidedActivation = null,
|
||||
int? highlightJobId = null)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
@@ -239,17 +238,10 @@ public class JobsController : Controller
|
||||
.ToList();
|
||||
|
||||
// Load all active jobs with related data
|
||||
var jobs = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Where(j => !j.IsDeleted)
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Include(j => j.AssignedUser)
|
||||
.OrderBy(j => j.DueDate.HasValue ? 0 : 1)
|
||||
.ThenBy(j => j.DueDate)
|
||||
.ThenBy(j => j.JobPriority.DisplayOrder)
|
||||
.ToListAsync();
|
||||
var jobs = await _unitOfWork.Jobs.GetBoardJobsAsync();
|
||||
var highlightedJob = highlightJobId.HasValue
|
||||
? jobs.FirstOrDefault(j => j.Id == highlightJobId.Value)
|
||||
: null;
|
||||
|
||||
var now = DateTime.UtcNow.Date;
|
||||
|
||||
@@ -285,6 +277,13 @@ public class JobsController : Controller
|
||||
ViewBag.ShowTerminal = showTerminal;
|
||||
ViewBag.TotalTerminal = statuses.Where(s => s.IsTerminalStatus)
|
||||
.Sum(s => jobs.Count(j => j.JobStatusId == s.Id));
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
ViewBag.GuidedActivationHighlightJobId = highlightJobId;
|
||||
ViewBag.GuidedActivationCallout = await BuildBoardGuidedActivationCalloutAsync(
|
||||
companyId,
|
||||
guidedActivation,
|
||||
highlightJobId,
|
||||
highlightedJob);
|
||||
return View(columns);
|
||||
}
|
||||
|
||||
@@ -296,15 +295,13 @@ public class JobsController : Controller
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> MoveCard([FromBody] MoveCardRequest req)
|
||||
{
|
||||
var job = await _context.Jobs
|
||||
.Include(j => j.JobStatus)
|
||||
.FirstOrDefaultAsync(j => j.Id == req.JobId && !j.IsDeleted);
|
||||
var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(req.JobId);
|
||||
|
||||
if (job == null)
|
||||
return Json(new { success = false, message = "Job not found." });
|
||||
|
||||
var newStatus = await _context.JobStatusLookups
|
||||
.FirstOrDefaultAsync(s => s.Id == req.NewStatusId && s.IsActive);
|
||||
var newStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(
|
||||
s => s.Id == req.NewStatusId && s.IsActive);
|
||||
|
||||
if (newStatus == null)
|
||||
return Json(new { success = false, message = "Status not found." });
|
||||
@@ -314,7 +311,11 @@ public class JobsController : Controller
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
job.UpdatedBy = User.Identity?.Name;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
var workflowJustCompleted =
|
||||
req.JobId == req.HighlightJobId
|
||||
&& await MaybeMarkFirstWorkflowCompletedFromBoardAsync(job.CompanyId, req.GuidedActivation);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "StatusChanged",
|
||||
$"Status → {newStatus.DisplayName}");
|
||||
@@ -334,7 +335,10 @@ public class JobsController : Controller
|
||||
success = true,
|
||||
newStatusId = newStatus.Id,
|
||||
newStatusDisplay = newStatus.DisplayName,
|
||||
newStatusColor = newStatus.ColorClass
|
||||
newStatusColor = newStatus.ColorClass,
|
||||
guidedActivationNext = workflowJustCompleted
|
||||
? AppConstants.GuidedActivation.BoardReadyForInvoiceStep
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -345,7 +349,7 @@ public class JobsController : Controller
|
||||
/// correctly without a separate AJAX call. Measurement units (sq ft vs m²) are resolved from
|
||||
/// the tenant's metric preference and passed via ViewBag.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
@@ -354,49 +358,12 @@ public class JobsController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
var job = await _unitOfWork.Jobs.GetByIdAsync(id.Value, false,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus,
|
||||
j => j.JobPriority,
|
||||
j => j.JobItems,
|
||||
j => j.AssignedUser,
|
||||
j => j.Quote,
|
||||
j => j.OvenCost,
|
||||
j => j.OriginalJob,
|
||||
j => j.IntakeCheckedBy);
|
||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value);
|
||||
if (job == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Load JobItemCoats and PrepServices for each JobItem
|
||||
foreach (var item in job.JobItems)
|
||||
{
|
||||
var itemWithDetails = await _context.JobItems
|
||||
.Where(ji => ji.Id == item.Id)
|
||||
.Include(ji => ji.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(ji => ji.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Include(ji => ji.PrepServices)
|
||||
.ThenInclude(ps => ps.PrepService)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (itemWithDetails?.Coats != null)
|
||||
item.Coats = itemWithDetails.Coats;
|
||||
if (itemWithDetails?.PrepServices != null)
|
||||
item.PrepServices = itemWithDetails.PrepServices;
|
||||
}
|
||||
|
||||
// Load prep services for this job
|
||||
var jobPrepServices = await _context.JobPrepServices
|
||||
.Where(jps => jps.JobId == id.Value && !jps.IsDeleted)
|
||||
.Include(jps => jps.PrepService)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
job.JobPrepServices = jobPrepServices;
|
||||
|
||||
// Build photo tag suggestions from coat colors on this job
|
||||
var coatColorSuggestions = job.JobItems
|
||||
.SelectMany(ji => ji.Coats)
|
||||
@@ -411,13 +378,7 @@ public class JobsController : Controller
|
||||
var jobDto = _mapper.Map<JobDto>(job);
|
||||
|
||||
// Load change history
|
||||
var changeHistories = await _context.JobChangeHistories
|
||||
.Where(h => h.JobId == id.Value && !h.IsDeleted)
|
||||
.Include(h => h.ChangedBy)
|
||||
.OrderByDescending(h => h.ChangedAt)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
var changeHistories = await _unitOfWork.Jobs.GetChangeHistoryAsync(id.Value);
|
||||
_logger.LogInformation("Loaded {Count} change history records for Job {JobId}", changeHistories.Count, id.Value);
|
||||
|
||||
var changeHistoryDtos = _mapper.Map<List<JobChangeHistoryDto>>(changeHistories);
|
||||
@@ -435,10 +396,7 @@ public class JobsController : Controller
|
||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||
|
||||
// Check if an invoice exists for this job
|
||||
var jobInvoice = await _context.Set<Invoice>()
|
||||
.Where(i => i.JobId == id.Value && !i.IsDeleted)
|
||||
.Select(i => new { i.Id, i.InvoiceNumber, i.Status })
|
||||
.FirstOrDefaultAsync();
|
||||
var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(id.Value);
|
||||
ViewBag.JobInvoiceId = jobInvoice?.Id;
|
||||
ViewBag.JobInvoiceNumber = jobInvoice?.InvoiceNumber;
|
||||
ViewBag.JobInvoiceStatus = jobInvoice?.Status;
|
||||
@@ -498,21 +456,16 @@ public class JobsController : Controller
|
||||
}).ToList();
|
||||
|
||||
// Load deposits for this job (and any linked quote)
|
||||
var depositQuery = _context.Set<Deposit>()
|
||||
.Where(d => !d.IsDeleted && d.CompanyId == job.CompanyId
|
||||
&& (d.JobId == id.Value || (job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value)))
|
||||
.Include(d => d.RecordedBy)
|
||||
.OrderByDescending(d => d.ReceivedDate)
|
||||
.AsNoTracking();
|
||||
ViewBag.Deposits = await depositQuery.ToListAsync();
|
||||
var jobDeposits = (await _unitOfWork.Deposits.FindAsync(
|
||||
d => d.JobId == id.Value || (job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value),
|
||||
false, d => d.RecordedBy))
|
||||
.OrderByDescending(d => d.ReceivedDate).ToList();
|
||||
ViewBag.Deposits = jobDeposits;
|
||||
|
||||
// Materials used on this job via QR scan or manual log
|
||||
ViewBag.MaterialsUsed = await _context.Set<InventoryTransaction>()
|
||||
.Where(t => !t.IsDeleted && t.JobId == id.Value)
|
||||
.Include(t => t.InventoryItem)
|
||||
.OrderByDescending(t => t.TransactionDate)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
ViewBag.MaterialsUsed = (await _unitOfWork.InventoryTransactions.FindAsync(
|
||||
t => t.JobId == id.Value, false, t => t.InventoryItem))
|
||||
.OrderByDescending(t => t.TransactionDate).ToList();
|
||||
|
||||
// Job photo subscription limits — used to disable the upload button in the view
|
||||
var photoCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
@@ -535,6 +488,28 @@ public class JobsController : Controller
|
||||
.OrderBy(c => c.Text)
|
||||
.ToList();
|
||||
|
||||
var jobPrefs = await GetCompanyPreferencesAsync(job.CompanyId);
|
||||
if (guidedActivation == AppConstants.GuidedActivation.JobCreatedStep
|
||||
&& jobPrefs?.FirstWorkflowCompleted == false)
|
||||
{
|
||||
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = jobPrefs.OnboardingPath == AppConstants.GuidedActivation.QuoteFirstPath
|
||||
? "Now your approved quote is a job. This is where you track it through your shop."
|
||||
: "This job is now live in your shop workflow.",
|
||||
Message = "Next, open the Daily Board and move it to the next stage so you can see how work flows across the shop.",
|
||||
ActionText = "Open Daily Board",
|
||||
ActionController = "Jobs",
|
||||
ActionName = "Board",
|
||||
ActionRouteValues = new
|
||||
{
|
||||
guidedActivation = AppConstants.GuidedActivation.BoardIntroStep,
|
||||
highlightJobId = job.Id
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return View(jobDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -650,46 +625,12 @@ public class JobsController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
var job = await _unitOfWork.Jobs.GetByIdAsync(id.Value, false,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus,
|
||||
j => j.JobPriority,
|
||||
j => j.JobItems,
|
||||
j => j.AssignedUser,
|
||||
j => j.Quote);
|
||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value);
|
||||
if (job == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Load JobItemCoats and PrepServices for each JobItem
|
||||
foreach (var item in job.JobItems)
|
||||
{
|
||||
var itemWithDetails = await _context.JobItems
|
||||
.Where(ji => ji.Id == item.Id)
|
||||
.Include(ji => ji.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(ji => ji.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Include(ji => ji.PrepServices)
|
||||
.ThenInclude(ps => ps.PrepService)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (itemWithDetails?.Coats != null)
|
||||
item.Coats = itemWithDetails.Coats;
|
||||
if (itemWithDetails?.PrepServices != null)
|
||||
item.PrepServices = itemWithDetails.PrepServices;
|
||||
}
|
||||
|
||||
// Load prep services for this job
|
||||
var jobPrepServices = await _context.JobPrepServices
|
||||
.Where(jps => jps.JobId == id.Value && !jps.IsDeleted)
|
||||
.Include(jps => jps.PrepService)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
job.JobPrepServices = jobPrepServices;
|
||||
|
||||
// Use AutoMapper to map the job entity to JobDto
|
||||
var jobDto = _mapper.Map<JobDto>(job);
|
||||
|
||||
@@ -918,7 +859,7 @@ public class JobsController : Controller
|
||||
/// (pre-configured job types with standard items). If <paramref name="customerId"/> is provided,
|
||||
/// the customer dropdown is pre-selected. The wizard is the same multi-step UI as the quote wizard.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Create(int? customerId, int? templateId)
|
||||
public async Task<IActionResult> Create(int? customerId, int? templateId, string? guidedActivation = null)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
@@ -940,19 +881,31 @@ public class JobsController : Controller
|
||||
if (customerId.HasValue)
|
||||
dto.CustomerId = customerId.Value;
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.JobFirstPath && customerId.HasValue)
|
||||
{
|
||||
dto = GuidedActivationDefaults.BuildJobDraft(customerId.Value, normalPriority?.Id ?? 1);
|
||||
}
|
||||
|
||||
// Pre-populate from template if provided
|
||||
if (templateId.HasValue)
|
||||
{
|
||||
var template = await _context.JobTemplates
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.Include(t => t.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
|
||||
.ThenInclude(p => p.PrepService)
|
||||
.FirstOrDefaultAsync(t => t.Id == templateId.Value && !t.IsDeleted);
|
||||
|
||||
var template = await _unitOfWork.JobTemplates.GetByIdAsync(templateId.Value);
|
||||
if (template != null)
|
||||
{
|
||||
var templateItemsEnum = await _unitOfWork.JobTemplateItems.FindAsync(
|
||||
i => i.JobTemplateId == templateId.Value, false, i => i.Coats, i => i.PrepServices);
|
||||
var templateItems = templateItemsEnum.ToList();
|
||||
|
||||
var tplPrepIds = templateItems
|
||||
.SelectMany(i => i.PrepServices.Select(p => p.PrepServiceId))
|
||||
.Distinct().ToList();
|
||||
Dictionary<int, string> tplPrepNameMap = new();
|
||||
if (tplPrepIds.Any())
|
||||
{
|
||||
var tplPreps = await _unitOfWork.PrepServices.FindAsync(p => tplPrepIds.Contains(p.Id));
|
||||
tplPrepNameMap = tplPreps.ToDictionary(p => p.Id, p => p.ServiceName);
|
||||
}
|
||||
|
||||
if (!customerId.HasValue && template.CustomerId.HasValue)
|
||||
dto.CustomerId = template.CustomerId.Value;
|
||||
dto.SpecialInstructions = template.SpecialInstructions;
|
||||
@@ -962,7 +915,7 @@ public class JobsController : Controller
|
||||
{
|
||||
id = template.Id,
|
||||
name = template.Name,
|
||||
items = template.Items.OrderBy(i => i.DisplayOrder).Select(i => new
|
||||
items = templateItems.OrderBy(i => i.DisplayOrder).Select(i => new
|
||||
{
|
||||
description = i.Description,
|
||||
quantity = i.Quantity,
|
||||
@@ -993,7 +946,7 @@ public class JobsController : Controller
|
||||
prepServices = i.PrepServices.Select(p => new
|
||||
{
|
||||
prepServiceId = p.PrepServiceId,
|
||||
prepServiceName = p.PrepService?.ServiceName,
|
||||
prepServiceName = tplPrepNameMap.TryGetValue(p.PrepServiceId, out var psName) ? psName : null,
|
||||
estimatedMinutes = p.EstimatedMinutes
|
||||
})
|
||||
})
|
||||
@@ -1001,6 +954,7 @@ public class JobsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1013,13 +967,14 @@ public class JobsController : Controller
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreateJobDto dto)
|
||||
public async Task<IActionResult> Create(CreateJobDto dto, string? guidedActivation = null)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1031,6 +986,7 @@ public class JobsController : Controller
|
||||
$"You have reached your plan limit of {max} active jobs. " +
|
||||
"Please upgrade your plan or complete/cancel existing jobs to add more.");
|
||||
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1071,14 +1027,14 @@ public class JobsController : Controller
|
||||
{
|
||||
foreach (var prepServiceId in dto.PrepServiceIds)
|
||||
{
|
||||
await _context.JobPrepServices.AddAsync(new JobPrepService
|
||||
await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService
|
||||
{
|
||||
JobId = job.Id,
|
||||
PrepServiceId = prepServiceId,
|
||||
CompanyId = companyId
|
||||
});
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
// Save job items from wizard
|
||||
@@ -1154,7 +1110,7 @@ public class JobsController : Controller
|
||||
{
|
||||
foreach (var psDto in itemDto.PrepServices)
|
||||
{
|
||||
await _context.JobItemPrepServices.AddAsync(new JobItemPrepService
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
|
||||
{
|
||||
JobItemId = jobItem.Id,
|
||||
PrepServiceId = psDto.PrepServiceId,
|
||||
@@ -1186,7 +1142,18 @@ public class JobsController : Controller
|
||||
if (!string.IsNullOrEmpty(createCompanyId))
|
||||
await _shopHub.Clients.Group($"shop-{createCompanyId}").SendAsync("DailyBoardUpdated");
|
||||
|
||||
await StampJobCreatedAsync(companyId);
|
||||
|
||||
this.ToastSuccess($"Job {job.JobNumber} created successfully!");
|
||||
if (guidedActivation == AppConstants.GuidedActivation.JobFirstPath)
|
||||
{
|
||||
return RedirectToAction(nameof(Details), new
|
||||
{
|
||||
id = job.Id,
|
||||
guidedActivation = AppConstants.GuidedActivation.JobCreatedStep
|
||||
});
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id = job.Id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1194,6 +1161,7 @@ public class JobsController : Controller
|
||||
_logger.LogError(ex, "Error creating job");
|
||||
this.ToastError("An error occurred while creating the job. Please try again.");
|
||||
await PopulateCreateEditWizardViewBagsAsync(companyId);
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -1212,26 +1180,12 @@ public class JobsController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
var job = await _unitOfWork.Jobs.GetByIdAsync(id.Value, false, j => j.JobStatus, j => j.JobPriority);
|
||||
var job = await _unitOfWork.Jobs.LoadForEditAsync(id.Value);
|
||||
if (job == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Load prep services for this job
|
||||
var jobPrepServices = await _context.JobPrepServices
|
||||
.Where(jps => jps.JobId == id.Value && !jps.IsDeleted)
|
||||
.Select(jps => jps.PrepServiceId)
|
||||
.ToListAsync();
|
||||
|
||||
// Load job items with full detail for wizard pre-fill
|
||||
var jobItemsWithDetail = await _context.JobItems
|
||||
.Where(ji => ji.JobId == id.Value && !ji.IsDeleted)
|
||||
.Include(ji => ji.Coats)
|
||||
.Include(ji => ji.PrepServices)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
var dto = new UpdateJobDto
|
||||
{
|
||||
Id = job.Id,
|
||||
@@ -1252,7 +1206,7 @@ public class JobsController : Controller
|
||||
DiscountType = job.DiscountType.ToString(),
|
||||
DiscountValue = job.DiscountValue,
|
||||
DiscountReason = job.DiscountReason,
|
||||
JobItems = jobItemsWithDetail.Select(ji => new CreateQuoteItemDto
|
||||
JobItems = job.JobItems.Where(ji => !ji.IsDeleted).Select(ji => new CreateQuoteItemDto
|
||||
{
|
||||
Description = ji.Description,
|
||||
Quantity = ji.Quantity,
|
||||
@@ -1289,7 +1243,7 @@ public class JobsController : Controller
|
||||
EstimatedMinutes = ps.EstimatedMinutes
|
||||
}).ToList()
|
||||
}).ToList(),
|
||||
PrepServiceIds = jobPrepServices
|
||||
PrepServiceIds = job.JobPrepServices.Select(jps => jps.PrepServiceId).ToList()
|
||||
};
|
||||
|
||||
var editCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
@@ -1339,9 +1293,8 @@ public class JobsController : Controller
|
||||
try
|
||||
{
|
||||
// Replace job items: soft-delete old, recreate from wizard
|
||||
var oldItems = await _context.JobItems
|
||||
.Where(ji => ji.JobId == id && !ji.IsDeleted)
|
||||
.ToListAsync();
|
||||
var oldItemsEnum = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == id);
|
||||
var oldItems = oldItemsEnum.ToList();
|
||||
var itemsChanged = dto.JobItems.Any() || oldItems.Any();
|
||||
|
||||
foreach (var oldItem in oldItems)
|
||||
@@ -1349,7 +1302,7 @@ public class JobsController : Controller
|
||||
oldItem.IsDeleted = true;
|
||||
oldItem.DeletedAt = DateTime.UtcNow;
|
||||
}
|
||||
if (oldItems.Any()) await _context.SaveChangesAsync();
|
||||
if (oldItems.Any()) await _unitOfWork.CompleteAsync();
|
||||
|
||||
foreach (var itemDto in dto.JobItems)
|
||||
{
|
||||
@@ -1419,7 +1372,7 @@ public class JobsController : Controller
|
||||
{
|
||||
foreach (var psDto in itemDto.PrepServices)
|
||||
{
|
||||
await _context.JobItemPrepServices.AddAsync(new JobItemPrepService
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
|
||||
{
|
||||
JobItemId = jobItem.Id,
|
||||
PrepServiceId = psDto.PrepServiceId,
|
||||
@@ -1698,11 +1651,13 @@ public class JobsController : Controller
|
||||
}
|
||||
|
||||
// Update prep services
|
||||
// Delete existing prep services
|
||||
var existingPrepServices = await _context.JobPrepServices
|
||||
.Where(jps => jps.JobId == job.Id && !jps.IsDeleted)
|
||||
.ToListAsync();
|
||||
_context.JobPrepServices.RemoveRange(existingPrepServices);
|
||||
// Soft-delete existing prep services
|
||||
var existingPrepServicesEnum = await _unitOfWork.JobPrepServices.FindAsync(jps => jps.JobId == job.Id);
|
||||
foreach (var eps in existingPrepServicesEnum)
|
||||
{
|
||||
eps.IsDeleted = true;
|
||||
eps.DeletedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Add new prep services
|
||||
if (dto.PrepServiceIds != null && dto.PrepServiceIds.Any())
|
||||
@@ -1715,7 +1670,7 @@ public class JobsController : Controller
|
||||
PrepServiceId = prepServiceId,
|
||||
CompanyId = currentUser.CompanyId
|
||||
};
|
||||
await _context.JobPrepServices.AddAsync(jobPrepService);
|
||||
await _unitOfWork.JobPrepServices.AddAsync(jobPrepService);
|
||||
}
|
||||
_logger.LogInformation("Updated prep services for job {JobNumber}: {Count} services", job.JobNumber, dto.PrepServiceIds.Count);
|
||||
}
|
||||
@@ -1753,11 +1708,7 @@ public class JobsController : Controller
|
||||
_logger.LogWarning(ex, "Notification failed for job {Id}", job.Id);
|
||||
}
|
||||
|
||||
var editNotifLog = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.JobId == job.Id)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
var editNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(job.Id);
|
||||
this.SetNotificationResultToast(editNotifLog);
|
||||
}
|
||||
|
||||
@@ -1945,23 +1896,13 @@ public class JobsController : Controller
|
||||
var month = DateTime.Now.Month.ToString("D2");
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var prefs = await _context.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.CompanyId == companyId && !p.IsDeleted)
|
||||
.Select(p => new { p.JobNumberPrefix })
|
||||
.FirstOrDefaultAsync();
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB";
|
||||
var prefix = $"{jobPrefix}-{year}{month}";
|
||||
|
||||
// IgnoreQueryFilters so soft-deleted jobs are counted (prevents number reuse)
|
||||
// Explicit CompanyId filter scopes to current company only
|
||||
var lastJobNumber = await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => j.CompanyId == companyId && j.JobNumber.StartsWith(prefix))
|
||||
.OrderByDescending(j => j.JobNumber)
|
||||
.Select(j => j.JobNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix);
|
||||
|
||||
if (lastJobNumber != null)
|
||||
{
|
||||
@@ -1987,27 +1928,14 @@ public class JobsController : Controller
|
||||
var today = date?.Date ?? DateTime.Today;
|
||||
|
||||
// Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel)
|
||||
var allStatuses = await _context.JobStatusLookups
|
||||
.Where(s => !s.IsDeleted && s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED"
|
||||
&& s.StatusCode != "DELIVERED" && s.StatusCode != "QUOTED"
|
||||
&& s.StatusCode != "PENDING" && s.StatusCode != "APPROVED")
|
||||
.OrderBy(s => s.DisplayOrder)
|
||||
.ToListAsync();
|
||||
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
|
||||
s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED"
|
||||
&& s.StatusCode != "DELIVERED" && s.StatusCode != "QUOTED"
|
||||
&& s.StatusCode != "PENDING" && s.StatusCode != "APPROVED");
|
||||
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
|
||||
|
||||
// Get all jobs scheduled for today with related data including items and coats
|
||||
var jobQuery = _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Include(j => j.AssignedUser)
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats)
|
||||
.Where(j => j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today && !j.IsDeleted && !j.JobStatus.IsTerminalStatus);
|
||||
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
jobQuery = jobQuery.Where(j => j.AssignedUserId == userId);
|
||||
|
||||
var jobs = await jobQuery.ToListAsync();
|
||||
var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today, userId);
|
||||
|
||||
// Get existing priority records for today
|
||||
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
||||
@@ -2107,27 +2035,13 @@ public class JobsController : Controller
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (!companyId.HasValue) return RedirectToAction(nameof(Index));
|
||||
|
||||
var allStatuses = await _context.JobStatusLookups
|
||||
.Where(s => !s.IsDeleted && !s.IsTerminalStatus
|
||||
&& s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED"
|
||||
&& s.StatusCode != "DELIVERED")
|
||||
.OrderBy(s => s.DisplayOrder)
|
||||
.ToListAsync();
|
||||
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
|
||||
!s.IsTerminalStatus
|
||||
&& s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED"
|
||||
&& s.StatusCode != "DELIVERED");
|
||||
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
|
||||
|
||||
var jobQuery = _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Include(j => j.AssignedUser)
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats)
|
||||
.Where(j => j.CompanyId == companyId && !j.IsDeleted && !j.JobStatus.IsTerminalStatus
|
||||
&& j.JobStatus.StatusCode != "ON_HOLD" && j.JobStatus.StatusCode != "CANCELLED");
|
||||
|
||||
if (!string.IsNullOrEmpty(workerId))
|
||||
jobQuery = jobQuery.Where(j => j.AssignedUserId == workerId);
|
||||
|
||||
var jobs = await jobQuery.ToListAsync();
|
||||
var jobs = await _unitOfWork.Jobs.GetActiveJobsForMobileAsync(companyId.Value, workerId);
|
||||
|
||||
var jobDtos = jobs.Select(j =>
|
||||
{
|
||||
@@ -2251,12 +2165,10 @@ public class JobsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await _context.Jobs
|
||||
.Include(j => j.JobStatus)
|
||||
.FirstOrDefaultAsync(j => j.Id == request.JobId && !j.IsDeleted);
|
||||
var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(request.JobId);
|
||||
if (job == null) return Json(new { success = false, message = "Job not found" });
|
||||
|
||||
var newStatus = await _context.JobStatusLookups.FindAsync(request.NewStatusId);
|
||||
var newStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(request.NewStatusId);
|
||||
if (newStatus == null) return Json(new { success = false, message = "Status not found" });
|
||||
|
||||
var oldStatusId = job.JobStatusId;
|
||||
@@ -2276,6 +2188,10 @@ public class JobsController : Controller
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
var workflowJustCompleted =
|
||||
request.JobId == request.HighlightJobId
|
||||
&& await MaybeMarkFirstWorkflowCompletedFromBoardAsync(job.CompanyId, request.GuidedActivation);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()?.ToString();
|
||||
@@ -2288,7 +2204,15 @@ public class JobsController : Controller
|
||||
statusColorClass = newStatus.ColorClass
|
||||
});
|
||||
|
||||
return Json(new { success = true, newStatusDisplayName = newStatus.DisplayName, newStatusColorClass = newStatus.ColorClass });
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
newStatusDisplayName = newStatus.DisplayName,
|
||||
newStatusColorClass = newStatus.ColorClass,
|
||||
guidedActivationNext = workflowJustCompleted
|
||||
? AppConstants.GuidedActivation.BoardReadyForInvoiceStep
|
||||
: null
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2330,9 +2254,8 @@ public class JobsController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint for inline worker reassignment on the Job Details page.
|
||||
/// Uses EF ExecuteUpdateAsync (bulk update without loading the entity) for efficiency —
|
||||
/// no need to load the full job just to update one FK. The response includes the worker's
|
||||
/// display name so the UI can update the badge without a page reload.
|
||||
/// Loads the job, sets <see cref="Job.AssignedUserId"/>, and saves.
|
||||
/// The response includes the worker's display name so the UI can update the badge without a page reload.
|
||||
/// Passing WorkerId = null unassigns the current worker.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
@@ -2341,16 +2264,15 @@ public class JobsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var rowsAffected = await _context.Jobs
|
||||
.Where(j => j.Id == request.JobId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(j => j.AssignedUserId, request.WorkerId)
|
||||
.SetProperty(j => j.UpdatedAt, DateTime.UtcNow));
|
||||
|
||||
if (rowsAffected == 0)
|
||||
var workerJob = await _unitOfWork.Jobs.GetByIdAsync(request.JobId);
|
||||
if (workerJob == null)
|
||||
{
|
||||
return Json(new { success = false, message = "Job not found" });
|
||||
}
|
||||
workerJob.AssignedUserId = request.WorkerId;
|
||||
workerJob.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Jobs.UpdateAsync(workerJob);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
string? workerName = null;
|
||||
if (!string.IsNullOrEmpty(request.WorkerId))
|
||||
@@ -2359,13 +2281,9 @@ public class JobsController : Controller
|
||||
workerName = user?.FullName;
|
||||
}
|
||||
|
||||
var assignedJob = await _unitOfWork.Jobs.GetByIdAsync(request.JobId);
|
||||
if (assignedJob != null)
|
||||
{
|
||||
var assignDetail = workerName != null ? $"Assigned to {workerName}" : "Worker unassigned";
|
||||
await BroadcastJobUpdate(assignedJob.CompanyId, assignedJob.JobNumber!, assignedJob.Id,
|
||||
"WorkerChanged", assignDetail);
|
||||
}
|
||||
var assignDetail = workerName != null ? $"Assigned to {workerName}" : "Worker unassigned";
|
||||
await BroadcastJobUpdate(workerJob.CompanyId, workerJob.JobNumber!, workerJob.Id,
|
||||
"WorkerChanged", assignDetail);
|
||||
|
||||
return Json(new { success = true, workerName = workerName });
|
||||
}
|
||||
@@ -2476,11 +2394,7 @@ public class JobsController : Controller
|
||||
_logger.LogWarning(ex, "Notification failed for job {Id}", request.JobId);
|
||||
}
|
||||
|
||||
var statusNotifLog = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.JobId == request.JobId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
var statusNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(request.JobId);
|
||||
this.SetNotificationResultToast(statusNotifLog);
|
||||
}
|
||||
|
||||
@@ -2545,14 +2459,8 @@ public class JobsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use AsNoTracking to fetch fresh data from database without EF caching
|
||||
var photos = await _context.JobPhotos
|
||||
.AsNoTracking()
|
||||
.Include(p => p.UploadedBy)
|
||||
.Where(p => p.JobId == jobId && !p.IsDeleted)
|
||||
.OrderBy(p => p.DisplayOrder)
|
||||
.ThenBy(p => p.UploadedDate)
|
||||
.ToListAsync();
|
||||
var photosEnum = await _unitOfWork.JobPhotos.FindAsync(p => p.JobId == jobId, false, p => p.UploadedBy);
|
||||
var photos = photosEnum.OrderBy(p => p.DisplayOrder).ThenBy(p => p.UploadedDate).ToList();
|
||||
|
||||
var photoDtos = photos
|
||||
.Select(p => _mapper.Map<JobPhotoDto>(p))
|
||||
@@ -2740,7 +2648,7 @@ public class JobsController : Controller
|
||||
job.CompletedDate = DateTime.UtcNow;
|
||||
|
||||
// Find the "Completed" status
|
||||
var completedStatus = await _context.JobStatusLookups
|
||||
var completedStatus = await _unitOfWork.JobStatusLookups
|
||||
.FirstOrDefaultAsync(s => s.StatusCode == "COMPLETED" && s.CompanyId == job.CompanyId);
|
||||
|
||||
if (completedStatus != null)
|
||||
@@ -2844,11 +2752,7 @@ public class JobsController : Controller
|
||||
_logger.LogWarning(ex, "Notification failed for job {Id}", dto.JobId);
|
||||
}
|
||||
|
||||
var completeNotifLog = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.JobId == dto.JobId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
var completeNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(dto.JobId);
|
||||
this.SetNotificationResultToast(completeNotifLog);
|
||||
}
|
||||
|
||||
@@ -2999,9 +2903,8 @@ public class JobsController : Controller
|
||||
try
|
||||
{
|
||||
// Soft-delete existing job items (and their children)
|
||||
var oldItems = await _context.JobItems
|
||||
.Where(ji => ji.JobId == job.Id && !ji.IsDeleted)
|
||||
.ToListAsync();
|
||||
var oldItemsEnum = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id);
|
||||
var oldItems = oldItemsEnum.ToList();
|
||||
|
||||
foreach (var oldItem in oldItems)
|
||||
{
|
||||
@@ -3009,7 +2912,7 @@ public class JobsController : Controller
|
||||
oldItem.DeletedAt = DateTime.UtcNow;
|
||||
oldItem.DeletedBy = currentUser.UserName;
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Create new items
|
||||
foreach (var itemDto in model.JobItems)
|
||||
@@ -3094,7 +2997,7 @@ public class JobsController : Controller
|
||||
CompanyId = currentUser.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _context.JobItemPrepServices.AddAsync(ps);
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(ps);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3136,8 +3039,7 @@ public class JobsController : Controller
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var item = await _context.JobItems
|
||||
.FirstOrDefaultAsync(ji => ji.Id == id && !ji.IsDeleted);
|
||||
var item = await _unitOfWork.JobItems.GetByIdAsync(id);
|
||||
if (item == null) return NotFound();
|
||||
|
||||
var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId);
|
||||
@@ -3146,14 +3048,11 @@ public class JobsController : Controller
|
||||
item.IsDeleted = true;
|
||||
item.DeletedAt = DateTime.UtcNow;
|
||||
item.DeletedBy = currentUser.UserName;
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Recalculate job total from remaining items
|
||||
var remainingItems = await _context.JobItems
|
||||
.Where(ji => ji.JobId == job.Id && !ji.IsDeleted)
|
||||
.Include(ji => ji.Coats)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
var remainingItemsEnum = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id, false, ji => ji.Coats);
|
||||
var remainingItems = remainingItemsEnum.ToList();
|
||||
|
||||
var remainingDtos = remainingItems.Select(ji => new CreateQuoteItemDto
|
||||
{
|
||||
@@ -3475,12 +3374,7 @@ public class JobsController : Controller
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
|
||||
{
|
||||
var job = await _context.Jobs
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
|
||||
.FirstOrDefaultAsync(j => j.Id == dto.JobId && !j.IsDeleted);
|
||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(dto.JobId);
|
||||
if (job == null) return NotFound();
|
||||
|
||||
var companyId = job.CompanyId;
|
||||
@@ -3572,7 +3466,7 @@ public class JobsController : Controller
|
||||
|
||||
foreach (var prep in item.PrepServices)
|
||||
{
|
||||
_context.Set<JobItemPrepService>().Add(new JobItemPrepService
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
|
||||
{
|
||||
JobItemId = newItem.Id,
|
||||
PrepServiceId = prep.PrepServiceId,
|
||||
@@ -3742,16 +3636,7 @@ public class JobsController : Controller
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Load job with items, coats, and time entries
|
||||
var job = await _context.Jobs
|
||||
.Where(j => j.Id == jobId && j.CompanyId == companyId && !j.IsDeleted)
|
||||
.Include(j => j.OvenCost)
|
||||
.Include(j => j.Invoice)
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
|
||||
.ThenInclude(t => t.Worker)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync();
|
||||
var job = await _unitOfWork.Jobs.LoadForCostingAsync(jobId, companyId);
|
||||
|
||||
if (job == null) return NotFound();
|
||||
|
||||
@@ -3889,6 +3774,100 @@ public class JobsController : Controller
|
||||
return Json(new { error = "Unable to compute costing breakdown." });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
|
||||
{
|
||||
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
}
|
||||
|
||||
private async Task<Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel?> BuildBoardGuidedActivationCalloutAsync(
|
||||
int companyId,
|
||||
string? guidedActivation,
|
||||
int? highlightJobId,
|
||||
Job? highlightedJob)
|
||||
{
|
||||
if (!highlightJobId.HasValue || highlightedJob == null)
|
||||
return null;
|
||||
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null || !prefs.SetupWizardCompleted || string.IsNullOrWhiteSpace(prefs.OnboardingPath))
|
||||
return null;
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.BoardIntroStep && !prefs.FirstWorkflowCompleted)
|
||||
{
|
||||
return new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = "This is your shop in real time",
|
||||
Message = "Every active job shows up here so you can see what's in production, what's waiting, and what's ready to go.",
|
||||
InstructionText = "Move this job to the next stage to see how your workflow updates.",
|
||||
SecondaryActionText = "View job",
|
||||
SecondaryActionController = "Jobs",
|
||||
SecondaryActionName = "Details",
|
||||
SecondaryActionRouteValues = new { id = highlightJobId.Value }
|
||||
};
|
||||
}
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.BoardReadyForInvoiceStep)
|
||||
{
|
||||
var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(highlightJobId.Value);
|
||||
var hasInvoice = jobInvoice != null;
|
||||
|
||||
return new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = "Nice — your workflow just updated. This is how you track work through your shop.",
|
||||
Message = hasInvoice
|
||||
? "You've already tied billing to this job. Open the invoice or keep exploring the board."
|
||||
: "When the work is done, you can create the invoice.",
|
||||
ActionText = hasInvoice ? "View Invoice" : "Create Invoice",
|
||||
ActionController = "Invoices",
|
||||
ActionName = hasInvoice ? "Details" : "Create",
|
||||
ActionRouteValues = hasInvoice
|
||||
? new { id = jobInvoice!.Id }
|
||||
: new
|
||||
{
|
||||
jobId = highlightJobId.Value,
|
||||
guidedActivation = AppConstants.GuidedActivation.BoardReadyForInvoiceStep
|
||||
},
|
||||
SecondaryActionText = "View job",
|
||||
SecondaryActionController = "Jobs",
|
||||
SecondaryActionName = "Details",
|
||||
SecondaryActionRouteValues = new { id = highlightJobId.Value }
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> MaybeMarkFirstWorkflowCompletedFromBoardAsync(int companyId, string? guidedActivation)
|
||||
{
|
||||
if (guidedActivation != AppConstants.GuidedActivation.BoardIntroStep)
|
||||
return false;
|
||||
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null || prefs.FirstWorkflowCompleted || !prefs.SetupWizardCompleted)
|
||||
return false;
|
||||
|
||||
prefs.FirstWorkflowCompleted = true;
|
||||
prefs.FirstWorkflowCompletedAt = DateTime.UtcNow;
|
||||
prefs.GuidedActivationDismissedAt = null;
|
||||
|
||||
_logger.LogInformation("Marked first workflow complete from Daily Board tracking for company {CompanyId}", companyId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task StampJobCreatedAsync(int companyId)
|
||||
{
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null || prefs.FirstJobCreatedAt.HasValue)
|
||||
return;
|
||||
|
||||
prefs.FirstJobCreatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
||||
@@ -3945,12 +3924,16 @@ public class MoveCardRequest
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public int NewStatusId { get; set; }
|
||||
public string? GuidedActivation { get; set; }
|
||||
public int? HighlightJobId { get; set; }
|
||||
}
|
||||
|
||||
public class AdvanceJobStatusRequest
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public int NewStatusId { get; set; }
|
||||
public string? GuidedActivation { get; set; }
|
||||
public int? HighlightJobId { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateDatesRequest
|
||||
|
||||
@@ -9,7 +9,6 @@ using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Hubs;
|
||||
|
||||
@@ -19,7 +18,6 @@ namespace PowderCoating.Web.Controllers;
|
||||
public class JobsPriorityController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ILogger<JobsPriorityController> _logger;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
@@ -27,14 +25,12 @@ public class JobsPriorityController : Controller
|
||||
|
||||
public JobsPriorityController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ApplicationDbContext context,
|
||||
ILogger<JobsPriorityController> logger,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITenantContext tenantContext,
|
||||
IHubContext<ShopHub> shopHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
_userManager = userManager;
|
||||
_tenantContext = tenantContext;
|
||||
@@ -63,13 +59,7 @@ public class JobsPriorityController : Controller
|
||||
var today = date?.Date ?? DateTime.Today;
|
||||
|
||||
// Get all jobs scheduled for today with related data
|
||||
var jobs = await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
.Include(j => j.AssignedUser)
|
||||
.Where(j => j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today && !j.IsDeleted)
|
||||
.ToListAsync();
|
||||
var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today);
|
||||
|
||||
// Get existing priority records for today
|
||||
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
||||
@@ -108,15 +98,14 @@ public class JobsPriorityController : Controller
|
||||
.ToListAsync();
|
||||
|
||||
// Get maintenance records scheduled for today (Scheduled or InProgress)
|
||||
var maintenanceItems = await _context.MaintenanceRecords
|
||||
.Include(m => m.Equipment)
|
||||
.Include(m => m.AssignedUser)
|
||||
.Where(m => m.ScheduledDate.Date == today && !m.IsDeleted &&
|
||||
(m.Status == MaintenanceStatus.Scheduled ||
|
||||
m.Status == MaintenanceStatus.InProgress))
|
||||
var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync(
|
||||
m => m.ScheduledDate.Date == today &&
|
||||
(m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress),
|
||||
false,
|
||||
m => m.Equipment, m => m.AssignedUser))
|
||||
.OrderByDescending(m => (int)m.Priority)
|
||||
.ThenBy(m => m.ScheduledDate)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
ViewBag.ScheduledDate = today;
|
||||
ViewBag.MaintenanceItems = maintenanceItems;
|
||||
@@ -378,14 +367,10 @@ public class JobsPriorityController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var record = await _context.MaintenanceRecords.FindAsync(maintenanceId);
|
||||
var record = await _unitOfWork.MaintenanceRecords.GetByIdAsync(maintenanceId);
|
||||
if (record == null || record.IsDeleted)
|
||||
return Json(new { success = false, message = "Maintenance record not found" });
|
||||
|
||||
// FindAsync bypasses global query filters — verify company ownership explicitly
|
||||
if (!_tenantContext.IsSuperAdmin() && record.CompanyId != _tenantContext.GetCurrentCompanyId())
|
||||
return Json(new { success = false, message = "Access denied." });
|
||||
|
||||
string workerName = "Unassigned";
|
||||
if (!string.IsNullOrEmpty(workerId))
|
||||
{
|
||||
@@ -402,7 +387,7 @@ public class JobsPriorityController : Controller
|
||||
}
|
||||
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true, message = "Worker assigned successfully", workerName });
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Notification;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -14,31 +13,23 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// <c>CanManageJobs</c> policy (Managers and above within a company).
|
||||
/// The platform-wide equivalent for SuperAdmins lives in
|
||||
/// <see cref="PlatformNotificationsController"/>.
|
||||
/// Uses <see cref="ApplicationDbContext"/> directly to enable LINQ projections
|
||||
/// that avoid loading full message bodies into memory on the list page.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
public class NotificationLogsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<NotificationLogsController> _logger;
|
||||
|
||||
public NotificationLogsController(ApplicationDbContext context, ILogger<NotificationLogsController> logger)
|
||||
public NotificationLogsController(IUnitOfWork unitOfWork, ILogger<NotificationLogsController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays a paginated, filterable list of notification log entries for the
|
||||
/// current company. Supports filtering by free-text search, channel (Email/SMS),
|
||||
/// delivery status, notification type, and an optional job context.
|
||||
/// <para>
|
||||
/// The <c>pageSize</c> is validated against an allowlist (10/25/50/100) and
|
||||
/// defaults to 25 to prevent callers from requesting arbitrarily large result sets.
|
||||
/// Sorting defaults to most-recent-first (<c>SentAt DESC</c>) because operators
|
||||
/// almost always want to see the latest delivery attempts first.
|
||||
/// </para>
|
||||
/// Displays a paginated, filterable list of notification log entries for the current company.
|
||||
/// Supports filtering by free-text search, channel, delivery status, notification type, and
|
||||
/// an optional job context. Page size is validated against an allowlist (10/25/50/100).
|
||||
/// </summary>
|
||||
// GET: /NotificationLogs
|
||||
public async Task<IActionResult> Index(
|
||||
@@ -55,74 +46,35 @@ public class NotificationLogsController : Controller
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||
|
||||
var query = _context.NotificationLogs
|
||||
.AsNoTracking()
|
||||
.Include(n => n.Customer)
|
||||
.Include(n => n.Job)
|
||||
.Include(n => n.Quote)
|
||||
.AsQueryable();
|
||||
NotificationChannel? channel = Enum.TryParse<NotificationChannel>(channelFilter, out var ch) ? ch : null;
|
||||
NotificationStatus? status = Enum.TryParse<NotificationStatus>(statusFilter, out var st) ? st : null;
|
||||
NotificationType? type = Enum.TryParse<NotificationType>(typeFilter, out var ty) ? ty : null;
|
||||
|
||||
// Filters
|
||||
if (jobId.HasValue)
|
||||
query = query.Where(n => n.JobId == jobId.Value);
|
||||
var (logs, totalCount) = await _unitOfWork.NotificationLogs.GetPagedFilteredAsync(
|
||||
pageNumber, pageSize, searchTerm, channel, status, type, jobId,
|
||||
sortColumn ?? "SentAt", sortDirection);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
var items = logs.Select(n => new NotificationLogDto
|
||||
{
|
||||
var search = searchTerm.ToLower();
|
||||
query = query.Where(n =>
|
||||
n.RecipientName.ToLower().Contains(search) ||
|
||||
n.Recipient.ToLower().Contains(search) ||
|
||||
(n.Subject != null && n.Subject.ToLower().Contains(search)) ||
|
||||
(n.Job != null && n.Job.JobNumber.ToLower().Contains(search)) ||
|
||||
(n.Quote != null && n.Quote.QuoteNumber.ToLower().Contains(search)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelFilter) && Enum.TryParse<NotificationChannel>(channelFilter, out var channel))
|
||||
query = query.Where(n => n.Channel == channel);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse<NotificationStatus>(statusFilter, out var status))
|
||||
query = query.Where(n => n.Status == status);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<NotificationType>(typeFilter, out var type))
|
||||
query = query.Where(n => n.NotificationType == type);
|
||||
|
||||
// Sorting
|
||||
query = (sortColumn ?? "SentAt") switch
|
||||
{
|
||||
"RecipientName" => sortDirection == "asc" ? query.OrderBy(n => n.RecipientName) : query.OrderByDescending(n => n.RecipientName),
|
||||
"Channel" => sortDirection == "asc" ? query.OrderBy(n => n.Channel) : query.OrderByDescending(n => n.Channel),
|
||||
"Status" => sortDirection == "asc" ? query.OrderBy(n => n.Status) : query.OrderByDescending(n => n.Status),
|
||||
"Type" => sortDirection == "asc" ? query.OrderBy(n => n.NotificationType) : query.OrderByDescending(n => n.NotificationType),
|
||||
_ => sortDirection == "asc" ? query.OrderBy(n => n.SentAt) : query.OrderByDescending(n => n.SentAt)
|
||||
};
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
var items = await query
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(n => new NotificationLogDto
|
||||
{
|
||||
Id = n.Id,
|
||||
Channel = n.Channel,
|
||||
NotificationType = n.NotificationType,
|
||||
Status = n.Status,
|
||||
RecipientName = n.RecipientName,
|
||||
Recipient = n.Recipient,
|
||||
Subject = n.Subject,
|
||||
Message = n.Message,
|
||||
ErrorMessage = n.ErrorMessage,
|
||||
SentAt = n.SentAt,
|
||||
CustomerId = n.CustomerId,
|
||||
JobId = n.JobId,
|
||||
QuoteId = n.QuoteId,
|
||||
JobNumber = n.Job != null ? n.Job.JobNumber : null,
|
||||
QuoteNumber = n.Quote != null ? n.Quote.QuoteNumber : null,
|
||||
CustomerName = n.Customer != null
|
||||
? (n.Customer.CompanyName ?? $"{n.Customer.ContactFirstName} {n.Customer.ContactLastName}".Trim())
|
||||
: null
|
||||
})
|
||||
.ToListAsync();
|
||||
Id = n.Id,
|
||||
Channel = n.Channel,
|
||||
NotificationType = n.NotificationType,
|
||||
Status = n.Status,
|
||||
RecipientName = n.RecipientName,
|
||||
Recipient = n.Recipient,
|
||||
Subject = n.Subject,
|
||||
Message = n.Message,
|
||||
ErrorMessage = n.ErrorMessage,
|
||||
SentAt = n.SentAt,
|
||||
CustomerId = n.CustomerId,
|
||||
JobId = n.JobId,
|
||||
QuoteId = n.QuoteId,
|
||||
JobNumber = n.Job?.JobNumber,
|
||||
QuoteNumber = n.Quote?.QuoteNumber,
|
||||
CustomerName = n.Customer != null
|
||||
? (n.Customer.CompanyName ?? $"{n.Customer.ContactFirstName} {n.Customer.ContactLastName}".Trim())
|
||||
: null
|
||||
}).ToList();
|
||||
|
||||
var pagedResult = new PagedResult<NotificationLogDto>
|
||||
{
|
||||
@@ -144,22 +96,17 @@ public class NotificationLogsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays the full details of a single notification log entry including the
|
||||
/// complete message body and any error message, which are omitted from the list
|
||||
/// view to keep response sizes small. Loads related Customer, Job, and Quote
|
||||
/// navigation properties to resolve display names.
|
||||
/// Displays the full details of a single notification log entry including the complete message
|
||||
/// body and any error message, which are omitted from the list view. Loads related Customer,
|
||||
/// Job, and Quote navigation properties to resolve display names.
|
||||
/// </summary>
|
||||
// GET: /NotificationLogs/Details/5
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var log = await _context.NotificationLogs
|
||||
.AsNoTracking()
|
||||
.Include(n => n.Customer)
|
||||
.Include(n => n.Job)
|
||||
.Include(n => n.Quote)
|
||||
.FirstOrDefaultAsync(n => n.Id == id);
|
||||
var log = await _unitOfWork.NotificationLogs.GetByIdAsync(
|
||||
id, false, n => n.Customer, n => n.Job, n => n.Quote);
|
||||
|
||||
if (log == null) return NotFound();
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// matches automatically on localhost, dev, staging, and production without any
|
||||
/// environment-specific configuration.
|
||||
/// </summary>
|
||||
// Intentional exception: WebAuthn/FIDO2 identity infrastructure. UserPasskeys is an ASP.NET Identity concern not exposed through IUnitOfWork; the anonymous login path has no tenant context; FIDO2 async callbacks capture _db by closure. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Route("[controller]/[action]")]
|
||||
public class PasskeyController : Controller
|
||||
{
|
||||
@@ -248,6 +249,10 @@ public class PasskeyController : Controller
|
||||
// Sign in — passkey satisfies both factors; no further 2FA required
|
||||
await _signInManager.SignInAsync(user, isPersistent: false);
|
||||
|
||||
// Track login date so CompanyHealth and audit pages show accurate last-login times
|
||||
user.LastLoginDate = DateTime.UtcNow;
|
||||
await _userManager.UpdateAsync(user);
|
||||
|
||||
_logger.LogInformation("User {UserId} signed in via passkey", user.Id);
|
||||
|
||||
return Ok(new { redirectUrl = Url.Action("Index", "Dashboard") });
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -10,28 +9,21 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// <summary>
|
||||
/// SuperAdmin-only cross-company notification log viewer.
|
||||
/// The company-scoped version lives in NotificationLogsController (CanManageJobs policy).
|
||||
/// Uses IgnoreQueryFilters throughout to bypass the multi-tenancy global filter and see
|
||||
/// logs from all companies simultaneously.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class PlatformNotificationsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public PlatformNotificationsController(ApplicationDbContext db) => _db = db;
|
||||
public PlatformNotificationsController(IUnitOfWork unitOfWork) => _unitOfWork = unitOfWork;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a paginated, filterable cross-company notification log view visible
|
||||
/// only to SuperAdmins. Unlike the tenant-scoped
|
||||
/// <see cref="NotificationLogsController.Index"/>, this action uses
|
||||
/// <c>IgnoreQueryFilters()</c> to bypass the multi-tenancy global filter and
|
||||
/// see logs from all companies simultaneously.
|
||||
/// <para>
|
||||
/// Company names are resolved in a single follow-up query after paging (not via
|
||||
/// a JOIN) because company data lives outside the notification-log table's usual
|
||||
/// query scope, and a separate query keeps the main sort/filter logic clean.
|
||||
/// Summary counts (total, failed, last 24 h) are computed as independent queries
|
||||
/// against the full unfiltered dataset so the summary reflects platform-wide
|
||||
/// health regardless of the active filters.
|
||||
/// </para>
|
||||
/// Renders a paginated, filterable cross-company notification log view visible only to SuperAdmins.
|
||||
/// All filter/sort/paging is applied in-memory after a single filtered repository fetch.
|
||||
/// Company names are resolved in a follow-up batch query keyed on the page's distinct company IDs.
|
||||
/// Summary counts reflect the full unfiltered dataset so the health cards are accurate regardless of active filters.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index(
|
||||
int? companyId,
|
||||
@@ -47,41 +39,38 @@ public class PlatformNotificationsController : Controller
|
||||
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50;
|
||||
page = Math.Max(1, page);
|
||||
|
||||
var query = _db.NotificationLogs
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted)
|
||||
.AsQueryable();
|
||||
// Load all non-deleted logs across all tenants, then filter in-memory.
|
||||
var all = (await _unitOfWork.NotificationLogs.FindAsync(n => !n.IsDeleted, ignoreQueryFilters: true))
|
||||
.AsEnumerable();
|
||||
|
||||
if (companyId.HasValue)
|
||||
query = query.Where(n => n.CompanyId == companyId);
|
||||
all = all.Where(n => n.CompanyId == companyId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(type) && Enum.TryParse<NotificationType>(type, out var typeEnum))
|
||||
query = query.Where(n => n.NotificationType == typeEnum);
|
||||
all = all.Where(n => n.NotificationType == typeEnum);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<NotificationStatus>(status, out var statusEnum))
|
||||
query = query.Where(n => n.Status == statusEnum);
|
||||
all = all.Where(n => n.Status == statusEnum);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channel) && Enum.TryParse<NotificationChannel>(channel, out var channelEnum))
|
||||
query = query.Where(n => n.Channel == channelEnum);
|
||||
all = all.Where(n => n.Channel == channelEnum);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
query = query.Where(n =>
|
||||
n.RecipientName.Contains(search) ||
|
||||
n.Recipient.Contains(search) ||
|
||||
(n.Subject != null && n.Subject.Contains(search)));
|
||||
all = all.Where(n =>
|
||||
n.RecipientName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
n.Recipient.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
(n.Subject != null && n.Subject.Contains(search, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
if (from.HasValue)
|
||||
query = query.Where(n => n.SentAt >= from.Value.Date);
|
||||
all = all.Where(n => n.SentAt >= from.Value.Date);
|
||||
|
||||
if (to.HasValue)
|
||||
query = query.Where(n => n.SentAt < to.Value.Date.AddDays(1));
|
||||
all = all.Where(n => n.SentAt < to.Value.Date.AddDays(1));
|
||||
|
||||
query = query.OrderByDescending(n => n.SentAt);
|
||||
var filtered = all.OrderByDescending(n => n.SentAt).ToList();
|
||||
var totalCount = filtered.Count;
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
var items = await query
|
||||
var items = filtered
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(n => new PlatformNotificationRow
|
||||
@@ -97,30 +86,26 @@ public class PlatformNotificationsController : Controller
|
||||
ErrorMessage = n.ErrorMessage,
|
||||
SentAt = n.SentAt
|
||||
})
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
// Resolve company names for the rows in one query
|
||||
// Resolve company names for the page's rows in one query
|
||||
var cids = items.Select(i => i.CompanyId).Distinct().ToList();
|
||||
var companyNames = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => cids.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id, c => c.CompanyName);
|
||||
|
||||
var companyNames = (await _unitOfWork.Companies.FindAsync(c => cids.Contains(c.Id), ignoreQueryFilters: true))
|
||||
.ToDictionary(c => c.Id, c => c.CompanyName);
|
||||
foreach (var item in items)
|
||||
item.CompanyName = companyNames.GetValueOrDefault(item.CompanyId);
|
||||
|
||||
// Sidebar company list for filter dropdown
|
||||
var companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted)
|
||||
var companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true))
|
||||
.OrderBy(c => c.CompanyName)
|
||||
.Select(c => new { c.Id, c.CompanyName })
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
// Summary counts
|
||||
ViewBag.TotalCount = await _db.NotificationLogs.IgnoreQueryFilters().CountAsync(n => !n.IsDeleted);
|
||||
ViewBag.FailedCount = await _db.NotificationLogs.IgnoreQueryFilters()
|
||||
.CountAsync(n => !n.IsDeleted && n.Status == NotificationStatus.Failed);
|
||||
ViewBag.Last24hCount = await _db.NotificationLogs.IgnoreQueryFilters()
|
||||
.CountAsync(n => !n.IsDeleted && n.SentAt >= DateTime.UtcNow.AddHours(-24));
|
||||
// Summary counts from the unfiltered dataset
|
||||
var allLogs = await _unitOfWork.NotificationLogs.FindAsync(n => !n.IsDeleted, ignoreQueryFilters: true);
|
||||
ViewBag.TotalCount = allLogs.Count();
|
||||
ViewBag.FailedCount = allLogs.Count(n => n.Status == NotificationStatus.Failed);
|
||||
ViewBag.Last24hCount = allLogs.Count(n => n.SentAt >= DateTime.UtcNow.AddHours(-24));
|
||||
|
||||
ViewBag.Companies = companies;
|
||||
ViewBag.CompanyIdFilter = companyId;
|
||||
@@ -139,23 +124,17 @@ public class PlatformNotificationsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full notification log entry for the given <paramref name="id"/>,
|
||||
/// including the complete message body and error details. The owning company name
|
||||
/// is resolved separately (rather than via navigation property) because company
|
||||
/// records are in a different query-filter context.
|
||||
/// Returns the full notification log entry for the given id, including the complete message body and error details.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
var log = await _db.NotificationLogs.AsNoTracking().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(n => n.Id == id);
|
||||
var log = await _unitOfWork.NotificationLogs.FirstOrDefaultAsync(n => n.Id == id, ignoreQueryFilters: true);
|
||||
if (log == null) return NotFound();
|
||||
|
||||
string? companyName = null;
|
||||
if (log.CompanyId > 0)
|
||||
companyName = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => c.Id == log.CompanyId)
|
||||
.Select(c => c.CompanyName)
|
||||
.FirstOrDefaultAsync();
|
||||
companyName = (await _unitOfWork.Companies.FirstOrDefaultAsync(
|
||||
c => c.Id == log.CompanyId, ignoreQueryFilters: true))?.CompanyName;
|
||||
|
||||
ViewBag.CompanyName = companyName;
|
||||
return View(log);
|
||||
|
||||
@@ -3,14 +3,13 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.PurchaseOrder;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Helpers;
|
||||
|
||||
@@ -23,7 +22,6 @@ public class PurchaseOrdersController : Controller
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<PurchaseOrdersController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IPdfService _pdfService;
|
||||
|
||||
public PurchaseOrdersController(
|
||||
@@ -31,14 +29,12 @@ public class PurchaseOrdersController : Controller
|
||||
IMapper mapper,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<PurchaseOrdersController> logger,
|
||||
ApplicationDbContext context,
|
||||
IPdfService pdfService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
_pdfService = pdfService;
|
||||
}
|
||||
|
||||
@@ -69,55 +65,10 @@ public class PurchaseOrdersController : Controller
|
||||
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
|
||||
var query = _context.Set<PurchaseOrder>()
|
||||
.Include(po => po.Vendor)
|
||||
.Include(po => po.Items.Where(i => !i.IsDeleted))
|
||||
.Where(po => !po.IsDeleted && po.CompanyId == currentUser.CompanyId)
|
||||
.AsQueryable();
|
||||
|
||||
if (statusFilter.HasValue)
|
||||
query = query.Where(po => po.Status == statusFilter.Value);
|
||||
|
||||
if (vendorId.HasValue)
|
||||
query = query.Where(po => po.VendorId == vendorId.Value);
|
||||
|
||||
if (dateFrom.HasValue)
|
||||
query = query.Where(po => po.OrderDate >= dateFrom.Value);
|
||||
|
||||
if (dateTo.HasValue)
|
||||
query = query.Where(po => po.OrderDate <= dateTo.Value.AddDays(1));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var term = searchTerm.Trim().ToLower();
|
||||
query = query.Where(po =>
|
||||
po.PoNumber.ToLower().Contains(term) ||
|
||||
po.Vendor.CompanyName.ToLower().Contains(term) ||
|
||||
(po.Notes != null && po.Notes.ToLower().Contains(term)));
|
||||
}
|
||||
|
||||
query = (sortColumn?.ToLower(), sortDirection?.ToLower()) switch
|
||||
{
|
||||
("ponumber", "asc") => query.OrderBy(po => po.PoNumber),
|
||||
("ponumber", _) => query.OrderByDescending(po => po.PoNumber),
|
||||
("vendor", "asc") => query.OrderBy(po => po.Vendor.CompanyName),
|
||||
("vendor", _) => query.OrderByDescending(po => po.Vendor.CompanyName),
|
||||
("status", "asc") => query.OrderBy(po => po.Status),
|
||||
("status", _) => query.OrderByDescending(po => po.Status),
|
||||
("orderdate", "asc") => query.OrderBy(po => po.OrderDate),
|
||||
("orderdate", _) => query.OrderByDescending(po => po.OrderDate),
|
||||
("expected", "asc") => query.OrderBy(po => po.ExpectedDeliveryDate),
|
||||
("expected", _) => query.OrderByDescending(po => po.ExpectedDeliveryDate),
|
||||
("total", "asc") => query.OrderBy(po => po.TotalAmount),
|
||||
("total", _) => query.OrderByDescending(po => po.TotalAmount),
|
||||
_ => query.OrderByDescending(po => po.OrderDate)
|
||||
};
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var items = await query
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
var (items, totalCount) = await _unitOfWork.PurchaseOrders.GetPagedAsync(
|
||||
currentUser.CompanyId, pageNumber, pageSize,
|
||||
statusFilter, vendorId, dateFrom, dateTo,
|
||||
searchTerm, sortColumn, sortDirection);
|
||||
|
||||
var dtos = _mapper.Map<List<PurchaseOrderListDto>>(items);
|
||||
|
||||
@@ -129,24 +80,12 @@ public class PurchaseOrdersController : Controller
|
||||
PageSize = pageSize
|
||||
};
|
||||
|
||||
// Stats
|
||||
var allForStats = await _context.Set<PurchaseOrder>()
|
||||
.Where(po => !po.IsDeleted && po.CompanyId == currentUser.CompanyId)
|
||||
.Select(po => new { po.Status, po.TotalAmount, po.ExpectedDeliveryDate })
|
||||
.ToListAsync();
|
||||
|
||||
ViewBag.TotalCount = allForStats.Count;
|
||||
ViewBag.OpenCount = allForStats.Count(p =>
|
||||
p.Status == PurchaseOrderStatus.Draft ||
|
||||
p.Status == PurchaseOrderStatus.Submitted ||
|
||||
p.Status == PurchaseOrderStatus.PartiallyReceived);
|
||||
ViewBag.CommittedValue = allForStats
|
||||
.Where(p => p.Status != PurchaseOrderStatus.Cancelled)
|
||||
.Sum(p => p.TotalAmount);
|
||||
ViewBag.OverdueCount = allForStats.Count(p =>
|
||||
(p.Status == PurchaseOrderStatus.Draft || p.Status == PurchaseOrderStatus.Submitted || p.Status == PurchaseOrderStatus.PartiallyReceived)
|
||||
&& p.ExpectedDeliveryDate.HasValue
|
||||
&& p.ExpectedDeliveryDate.Value.Date < DateTime.UtcNow.Date);
|
||||
// Stats (server-side projection — only three columns fetched)
|
||||
var stats = await _unitOfWork.PurchaseOrders.GetStatsAsync(currentUser.CompanyId);
|
||||
ViewBag.TotalCount = stats.TotalCount;
|
||||
ViewBag.OpenCount = stats.OpenCount;
|
||||
ViewBag.CommittedValue = stats.CommittedValue;
|
||||
ViewBag.OverdueCount = stats.OverdueCount;
|
||||
|
||||
await PopulateVendorFilterDropdownAsync(currentUser.CompanyId);
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
@@ -172,13 +111,7 @@ public class PurchaseOrdersController : Controller
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var po = await _context.Set<PurchaseOrder>()
|
||||
.Include(p => p.Vendor)
|
||||
.Include(p => p.Bill)
|
||||
.Include(p => p.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.InventoryItem)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
|
||||
|
||||
var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId);
|
||||
if (po == null) return NotFound();
|
||||
|
||||
var dto = _mapper.Map<PurchaseOrderDto>(po);
|
||||
@@ -283,10 +216,7 @@ public class PurchaseOrdersController : Controller
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var po = await _context.Set<PurchaseOrder>()
|
||||
.Include(p => p.Items.Where(i => !i.IsDeleted))
|
||||
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
|
||||
|
||||
var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId);
|
||||
if (po == null) return NotFound();
|
||||
if (po.Status != PurchaseOrderStatus.Draft)
|
||||
{
|
||||
@@ -335,10 +265,7 @@ public class PurchaseOrdersController : Controller
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var po = await _context.Set<PurchaseOrder>()
|
||||
.Include(p => p.Items.Where(i => !i.IsDeleted))
|
||||
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
|
||||
|
||||
var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId);
|
||||
if (po == null) return NotFound();
|
||||
if (po.Status != PurchaseOrderStatus.Draft)
|
||||
{
|
||||
@@ -416,10 +343,7 @@ public class PurchaseOrdersController : Controller
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var po = await _context.Set<PurchaseOrder>()
|
||||
.Include(p => p.Vendor)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
|
||||
|
||||
var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId);
|
||||
if (po == null) return NotFound();
|
||||
|
||||
if (po.Status != PurchaseOrderStatus.Draft && po.Status != PurchaseOrderStatus.Cancelled)
|
||||
@@ -487,10 +411,7 @@ public class PurchaseOrdersController : Controller
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var po = await _context.Set<PurchaseOrder>()
|
||||
.Include(p => p.Items.Where(i => !i.IsDeleted))
|
||||
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
|
||||
|
||||
var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId);
|
||||
if (po == null) return NotFound();
|
||||
|
||||
if (po.Status != PurchaseOrderStatus.Draft)
|
||||
@@ -560,11 +481,7 @@ public class PurchaseOrdersController : Controller
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var po = await _context.Set<PurchaseOrder>()
|
||||
.Include(p => p.Vendor)
|
||||
.Include(p => p.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.InventoryItem)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
|
||||
var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId);
|
||||
|
||||
if (po == null) return NotFound();
|
||||
|
||||
@@ -618,11 +535,7 @@ public class PurchaseOrdersController : Controller
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var po = await _context.Set<PurchaseOrder>()
|
||||
.Include(p => p.Vendor)
|
||||
.Include(p => p.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.InventoryItem)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
|
||||
var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId);
|
||||
|
||||
if (po == null) return NotFound();
|
||||
|
||||
@@ -692,7 +605,7 @@ public class PurchaseOrdersController : Controller
|
||||
CompanyId = po.CompanyId
|
||||
};
|
||||
|
||||
await _context.Set<InventoryTransaction>().AddAsync(transaction);
|
||||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,9 +633,8 @@ public class PurchaseOrdersController : Controller
|
||||
}
|
||||
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
}); // end ExecuteInTransactionAsync
|
||||
}); // end ExecuteInTransactionAsync — SaveChangesAsync called automatically before commit
|
||||
|
||||
this.ToastSuccess(allReceived
|
||||
? $"All items received for {po.PoNumber}."
|
||||
@@ -754,11 +666,7 @@ public class PurchaseOrdersController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
var po = await _context.Set<PurchaseOrder>()
|
||||
.Include(p => p.Vendor)
|
||||
.Include(p => p.Items.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.InventoryItem)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId);
|
||||
var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId);
|
||||
|
||||
if (po == null) return NotFound();
|
||||
|
||||
@@ -806,14 +714,12 @@ public class PurchaseOrdersController : Controller
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
// Find low-stock items that have a primary vendor
|
||||
var lowStockItems = await _context.Set<InventoryItem>()
|
||||
.Include(i => i.PrimaryVendor)
|
||||
.Where(i => !i.IsDeleted
|
||||
&& i.IsActive
|
||||
&& i.CompanyId == currentUser.CompanyId
|
||||
&& i.PrimaryVendorId != null
|
||||
&& i.QuantityOnHand <= i.ReorderPoint)
|
||||
.ToListAsync();
|
||||
var lowStockItems = (await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.IsActive && i.CompanyId == currentUser.CompanyId &&
|
||||
i.PrimaryVendorId != null && i.QuantityOnHand <= i.ReorderPoint,
|
||||
false,
|
||||
i => i.PrimaryVendor!))
|
||||
.ToList();
|
||||
|
||||
if (!lowStockItems.Any())
|
||||
{
|
||||
@@ -878,11 +784,10 @@ public class PurchaseOrdersController : Controller
|
||||
{
|
||||
var prefix = $"PO-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
|
||||
|
||||
var existing = await _context.Set<PurchaseOrder>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(po => po.CompanyId == companyId && po.PoNumber.StartsWith(prefix))
|
||||
.Select(po => po.PoNumber)
|
||||
.ToListAsync();
|
||||
var existingPos = await _unitOfWork.PurchaseOrders.FindAsync(
|
||||
po => po.CompanyId == companyId && po.PoNumber.StartsWith(prefix),
|
||||
ignoreQueryFilters: true);
|
||||
var existing = existingPos.Select(po => po.PoNumber).ToList();
|
||||
|
||||
var maxNum = 0;
|
||||
foreach (var num in existing)
|
||||
@@ -903,17 +808,18 @@ public class PurchaseOrdersController : Controller
|
||||
/// </summary>
|
||||
private async Task PopulateCreateViewBagAsync(int companyId)
|
||||
{
|
||||
var vendors = await _context.Set<Vendor>()
|
||||
.Where(v => !v.IsDeleted && v.CompanyId == companyId && v.IsActive)
|
||||
var vendorEntities = await _unitOfWork.Vendors.FindAsync(
|
||||
v => v.CompanyId == companyId && v.IsActive);
|
||||
var vendors = vendorEntities
|
||||
.OrderBy(v => v.CompanyName)
|
||||
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString()))
|
||||
.ToListAsync();
|
||||
|
||||
.ToList();
|
||||
vendors.Insert(0, new SelectListItem("— Select Vendor —", ""));
|
||||
ViewBag.Vendors = vendors;
|
||||
|
||||
var inventoryItems = await _context.Set<InventoryItem>()
|
||||
.Where(i => !i.IsDeleted && i.CompanyId == companyId && i.IsActive)
|
||||
var inventoryEntities = await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.CompanyId == companyId && i.IsActive);
|
||||
var inventoryItems = inventoryEntities
|
||||
.OrderBy(i => i.Name)
|
||||
.Select(i => new
|
||||
{
|
||||
@@ -922,8 +828,7 @@ public class PurchaseOrdersController : Controller
|
||||
uom = i.UnitOfMeasure ?? "units",
|
||||
cost = i.LastPurchasePrice > 0 ? i.LastPurchasePrice : i.UnitCost
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
.ToList();
|
||||
ViewBag.InventoryItemsJson = System.Text.Json.JsonSerializer.Serialize(inventoryItems);
|
||||
}
|
||||
|
||||
@@ -934,12 +839,11 @@ public class PurchaseOrdersController : Controller
|
||||
/// </summary>
|
||||
private async Task PopulateVendorFilterDropdownAsync(int companyId)
|
||||
{
|
||||
var vendors = await _context.Set<Vendor>()
|
||||
.Where(v => !v.IsDeleted && v.CompanyId == companyId)
|
||||
var vendorEntities = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
|
||||
var vendors = vendorEntities
|
||||
.OrderBy(v => v.CompanyName)
|
||||
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString()))
|
||||
.ToListAsync();
|
||||
|
||||
.ToList();
|
||||
vendors.Insert(0, new SelectListItem("All Vendors", ""));
|
||||
ViewBag.VendorList = vendors;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Hubs;
|
||||
using PowderCoating.Web.ViewModels;
|
||||
@@ -22,7 +21,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Public)]
|
||||
public class QuoteApprovalController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly INotificationService _notifications;
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
private readonly IStripeConnectService _stripeConnect;
|
||||
@@ -31,7 +30,7 @@ public class QuoteApprovalController : Controller
|
||||
private readonly IHubContext<NotificationHub> _hub;
|
||||
|
||||
public QuoteApprovalController(
|
||||
ApplicationDbContext db,
|
||||
IUnitOfWork unitOfWork,
|
||||
INotificationService notifications,
|
||||
IInAppNotificationService inApp,
|
||||
IStripeConnectService stripeConnect,
|
||||
@@ -39,7 +38,7 @@ public class QuoteApprovalController : Controller
|
||||
IConfiguration configuration,
|
||||
IHubContext<NotificationHub> hub)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
_notifications = notifications;
|
||||
_inApp = inApp;
|
||||
_stripeConnect = stripeConnect;
|
||||
@@ -50,11 +49,8 @@ public class QuoteApprovalController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// Renders the main customer-facing approval page showing quote line items, totals, and
|
||||
/// Approve/Decline buttons. The <c>[ActionName("View")]</c> attribute overrides the method name
|
||||
/// so the route is <c>/quote-approval/{token}</c> without exposing the internal method name.
|
||||
/// All token validation (expiry, already-acted) is centralised in <see cref="ValidateTokenAsync"/>.
|
||||
/// Approve/Decline buttons.
|
||||
/// </summary>
|
||||
// GET /quote-approval/{token}
|
||||
[HttpGet("{token}")]
|
||||
[ActionName("View")]
|
||||
public async Task<IActionResult> ShowApprovalPage(string token)
|
||||
@@ -67,19 +63,15 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the contact-details confirmation step for prospect (non-customer) quotes. Prospects
|
||||
/// have no <c>CustomerId</c> on the quote, so we collect their contact information before
|
||||
/// finalising the approval. Quotes already linked to a customer skip this step and go directly
|
||||
/// to <see cref="ApproveInternal"/> — a customer's details are already on file.
|
||||
/// Shows the contact-details confirmation step for prospect (non-customer) quotes.
|
||||
/// Quotes already linked to a customer skip this step and go directly to ApproveInternal.
|
||||
/// </summary>
|
||||
// GET /quote-approval/{token}/confirm-details
|
||||
[HttpGet("{token}/confirm-details")]
|
||||
public async Task<IActionResult> ConfirmDetails(string token)
|
||||
{
|
||||
var (quote, errorResult) = await ValidateTokenAsync(token);
|
||||
if (errorResult != null) return errorResult;
|
||||
|
||||
// Only prospects need to fill in details; linked customers go straight through.
|
||||
if (quote!.CustomerId.HasValue)
|
||||
return await ApproveInternal(token, quote);
|
||||
|
||||
@@ -88,13 +80,10 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the contact-detail form submission from prospects. Requires at minimum a name and
|
||||
/// either an email or phone number — this minimal validation mirrors the fields required to
|
||||
/// later convert the quote to a customer record. On success, persists the prospect contact
|
||||
/// details to the quote and delegates to <see cref="ApproveInternal"/> so the approval and
|
||||
/// audit trail are written in a single path shared with the customer flow.
|
||||
/// Handles the contact-detail form submission from prospects. Persists contact details to the
|
||||
/// quote then delegates to ApproveInternal so the approval and audit trail are written in a single
|
||||
/// path shared with the customer flow.
|
||||
/// </summary>
|
||||
// POST /quote-approval/{token}/confirm-details
|
||||
[HttpPost("{token}/confirm-details")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SubmitDetails(string token,
|
||||
@@ -110,7 +99,6 @@ public class QuoteApprovalController : Controller
|
||||
var (quote, errorResult) = await ValidateTokenAsync(token);
|
||||
if (errorResult != null) return errorResult;
|
||||
|
||||
// Require at minimum a name and either email or phone
|
||||
if (string.IsNullOrWhiteSpace(contactName) ||
|
||||
(string.IsNullOrWhiteSpace(email) && string.IsNullOrWhiteSpace(phone)))
|
||||
{
|
||||
@@ -127,7 +115,6 @@ public class QuoteApprovalController : Controller
|
||||
return base.View("ConfirmDetails", model);
|
||||
}
|
||||
|
||||
// Update prospect fields on the quote
|
||||
quote!.ProspectContactName = contactName?.Trim();
|
||||
quote.ProspectEmail = email?.Trim();
|
||||
quote.ProspectPhone = phone?.Trim();
|
||||
@@ -137,18 +124,15 @@ public class QuoteApprovalController : Controller
|
||||
quote.ProspectState = state?.Trim();
|
||||
quote.ProspectZipCode = zipCode?.Trim();
|
||||
quote.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return await ApproveInternal(token, quote);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for the Approve button on the approval page. For prospect quotes, redirects to
|
||||
/// the contact-details form rather than approving immediately. For customer-linked quotes,
|
||||
/// delegates directly to <see cref="ApproveInternal"/>. This two-path design keeps the main
|
||||
/// approval page simple (one Approve button) while still collecting required prospect data.
|
||||
/// Entry point for the Approve button. For prospect quotes, redirects to the contact-details form.
|
||||
/// For customer-linked quotes, delegates directly to ApproveInternal.
|
||||
/// </summary>
|
||||
// POST /quote-approval/{token}/approve
|
||||
[HttpPost("{token}/approve")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Approve(string token)
|
||||
@@ -156,7 +140,6 @@ public class QuoteApprovalController : Controller
|
||||
var (quote, errorResult) = await ValidateTokenAsync(token);
|
||||
if (errorResult != null) return errorResult;
|
||||
|
||||
// Prospect quotes collect contact details first
|
||||
if (!quote!.CustomerId.HasValue)
|
||||
{
|
||||
var model = await BuildViewModelAsync(quote, token);
|
||||
@@ -167,22 +150,15 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core approval logic shared by both the customer path (<see cref="Approve"/>) and the prospect
|
||||
/// path (<see cref="SubmitDetails"/>). Sets the quote status to the company's designated
|
||||
/// <c>IsApprovedStatus</c> lookup entry, records <c>ApprovalTokenUsedAt</c> to prevent token
|
||||
/// reuse, clears any prior decline reason (customers can re-approve after declining), and writes
|
||||
/// a <c>QuoteChangeHistory</c> audit entry with <c>ChangedByUserId = null</c> to indicate the
|
||||
/// action was performed by the customer rather than a staff member. After persisting, a SignalR
|
||||
/// push notifies logged-in staff in real time. If the quote requires a deposit and the company
|
||||
/// has Stripe Connect active, a time-limited deposit payment link token (7 days) is generated
|
||||
/// and saved so the confirmation page can surface a "Pay deposit online" button. Prospect quotes
|
||||
/// skip the deposit link because there is no <c>Customer</c> row to attach a <c>Deposit</c> to.
|
||||
/// Core approval logic shared by the customer path and the prospect path. Sets the quote status,
|
||||
/// records ApprovalTokenUsedAt, writes an audit entry, pushes a SignalR notification to staff,
|
||||
/// and optionally generates a deposit payment link token if Stripe Connect is active.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> ApproveInternal(string token, Quote quote)
|
||||
{
|
||||
var approvedStatus = await _db.QuoteStatusLookups
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(s => s.CompanyId == quote.CompanyId && s.IsApprovedStatus && !s.IsDeleted);
|
||||
var approvedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
|
||||
s => s.CompanyId == quote.CompanyId && s.IsApprovedStatus && !s.IsDeleted,
|
||||
ignoreQueryFilters: true);
|
||||
|
||||
var oldStatusName = quote.QuoteStatus?.DisplayName ?? "Unknown";
|
||||
|
||||
@@ -199,9 +175,9 @@ public class QuoteApprovalController : Controller
|
||||
quote.DeclineReason = null;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var approveEntry = new PowderCoating.Core.Entities.QuoteChangeHistory
|
||||
var approveEntry = new QuoteChangeHistory
|
||||
{
|
||||
QuoteId = quote.Id,
|
||||
ChangedByUserId = null,
|
||||
@@ -215,8 +191,8 @@ public class QuoteApprovalController : Controller
|
||||
CompanyId = quote.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.QuoteChangeHistories.Add(approveEntry);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.QuoteChangeHistories.AddAsync(approveEntry);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
await _hub.Clients.Group($"company-{quote.CompanyId}").SendAsync("QuoteActedByCustomer", new
|
||||
{
|
||||
@@ -246,20 +222,19 @@ public class QuoteApprovalController : Controller
|
||||
_logger.LogWarning(ex, "Failed to send approval notification for quote {QuoteId}", quote.Id);
|
||||
}
|
||||
|
||||
// Generate deposit payment link if this quote requires a deposit and the company has Stripe Connect
|
||||
if (quote.RequiresDeposit
|
||||
&& quote.DepositPercent > 0
|
||||
&& quote.CustomerId.HasValue // prospects don't have a Customer row to attach a Deposit to
|
||||
&& quote.CustomerId.HasValue
|
||||
&& quote.DepositAmountPaid <= 0)
|
||||
{
|
||||
var company = await _db.Companies.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId && !c.IsDeleted);
|
||||
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
|
||||
c => c.Id == quote.CompanyId && !c.IsDeleted);
|
||||
|
||||
if (company?.StripeConnectStatus == StripeConnectStatus.Active)
|
||||
{
|
||||
quote.DepositPaymentLinkToken = Guid.NewGuid().ToString("N");
|
||||
quote.DepositPaymentLinkExpiresAt = DateTime.UtcNow.AddDays(7);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,15 +242,9 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the customer declining a quote. Requires a non-empty reason (enforced with a
|
||||
/// field-level error, not ModelState) so staff always know why a quote was rejected. The
|
||||
/// decline reason is truncated to 1000 characters before persistence to avoid oversized inputs.
|
||||
/// The customer's IP is recorded (<c>DeclinedByIp</c>) for audit purposes. A SignalR event and
|
||||
/// in-app notification are pushed to staff. The token is marked used (<c>ApprovalTokenUsedAt</c>)
|
||||
/// so the customer cannot approve after declining via the same link — they must request a new
|
||||
/// link from the shop.
|
||||
/// Handles the customer declining a quote. Records the decline reason, marks the token used,
|
||||
/// pushes a SignalR notification and in-app notification to staff.
|
||||
/// </summary>
|
||||
// POST /quote-approval/{token}/decline
|
||||
[HttpPost("{token}/decline")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Decline(string token, [FromForm] string reason)
|
||||
@@ -290,13 +259,12 @@ public class QuoteApprovalController : Controller
|
||||
return base.View("ApprovalPage", model);
|
||||
}
|
||||
|
||||
// Find the rejected status for this company (by flag, or fall back to StatusCode)
|
||||
var rejectedStatus = await _db.QuoteStatusLookups
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(s => s.CompanyId == quote!.CompanyId && s.IsRejectedStatus && !s.IsDeleted)
|
||||
?? await _db.QuoteStatusLookups
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(s => s.CompanyId == quote!.CompanyId && s.StatusCode == "REJECTED" && !s.IsDeleted);
|
||||
var rejectedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
|
||||
s => s.CompanyId == quote!.CompanyId && s.IsRejectedStatus && !s.IsDeleted,
|
||||
ignoreQueryFilters: true)
|
||||
?? await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
|
||||
s => s.CompanyId == quote!.CompanyId && s.StatusCode == "REJECTED" && !s.IsDeleted,
|
||||
ignoreQueryFilters: true);
|
||||
|
||||
var oldDeclineStatusName = quote!.QuoteStatus?.DisplayName ?? "Unknown";
|
||||
|
||||
@@ -308,10 +276,9 @@ public class QuoteApprovalController : Controller
|
||||
quote.DeclinedByIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
quote.ApprovalTokenUsedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Audit log — decline
|
||||
var declineEntry = new PowderCoating.Core.Entities.QuoteChangeHistory
|
||||
var declineEntry = new QuoteChangeHistory
|
||||
{
|
||||
QuoteId = quote.Id,
|
||||
ChangedByUserId = null,
|
||||
@@ -323,10 +290,9 @@ public class QuoteApprovalController : Controller
|
||||
CompanyId = quote.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.QuoteChangeHistories.Add(declineEntry);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.QuoteChangeHistories.AddAsync(declineEntry);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Push real-time toast to any logged-in company users
|
||||
await _hub.Clients.Group($"company-{quote.CompanyId}").SendAsync("QuoteActedByCustomer", new
|
||||
{
|
||||
approved = false,
|
||||
@@ -359,40 +325,30 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the post-action confirmation page shown after the customer approves or declines.
|
||||
/// Does NOT re-validate the token against the used/expired guards in
|
||||
/// <see cref="ValidateTokenAsync"/> — by this point the token is already marked used, so those
|
||||
/// guards would incorrectly redirect to AlreadyActed. Instead, it only checks that the quote
|
||||
/// exists. The <c>action</c> query-string value ("approved" / "declined") is set by the
|
||||
/// redirect from <see cref="ApproveInternal"/> or <see cref="Decline"/> and drives the
|
||||
/// confirmation message in the view. The deposit link token is only surfaced if it is present
|
||||
/// and not yet expired.
|
||||
/// Renders the post-action confirmation page. Does NOT re-validate the token against the
|
||||
/// used/expired guards — by this point the token is already marked used.
|
||||
/// </summary>
|
||||
// GET /quote-approval/{token}/confirmation
|
||||
[HttpGet("{token}/confirmation")]
|
||||
public async Task<IActionResult> Confirmation(string token, [FromQuery] string action)
|
||||
{
|
||||
var quote = await _db.Quotes
|
||||
.IgnoreQueryFilters()
|
||||
.Include(q => q.Customer)
|
||||
.FirstOrDefaultAsync(q => q.ApprovalToken == token && !q.IsDeleted);
|
||||
var quote = await _unitOfWork.Quotes.FirstOrDefaultAsync(
|
||||
q => q.ApprovalToken == token && !q.IsDeleted,
|
||||
ignoreQueryFilters: true,
|
||||
q => q.Customer);
|
||||
|
||||
if (quote == null)
|
||||
return base.View("InvalidToken");
|
||||
|
||||
var company = await _db.Companies
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId);
|
||||
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
|
||||
c => c.Id == quote.CompanyId, ignoreQueryFilters: true);
|
||||
|
||||
var prefs = await _db.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == quote.CompanyId && !p.IsDeleted);
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == quote.CompanyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var depositAmount = quote.RequiresDeposit && quote.DepositPercent > 0
|
||||
? Math.Round(quote.Total * (quote.DepositPercent / 100m), 2)
|
||||
: 0m;
|
||||
|
||||
// Only surface the deposit link if it's valid and not expired
|
||||
var depositToken = (!string.IsNullOrEmpty(quote.DepositPaymentLinkToken)
|
||||
&& quote.DepositPaymentLinkExpiresAt > DateTime.UtcNow)
|
||||
? quote.DepositPaymentLinkToken
|
||||
@@ -424,26 +380,16 @@ public class QuoteApprovalController : Controller
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Validates the approval token and returns the quote if it is still actionable, or an
|
||||
/// <c>IActionResult</c> error view if not. Checks in order: token exists, not expired, not
|
||||
/// already used (<c>ApprovalTokenUsedAt != null</c>), and not already in a terminal status
|
||||
/// (approved, rejected, or converted). The terminal-status check is a belt-and-suspenders guard
|
||||
/// for cases where the status was changed by staff inside the app after the token was issued but
|
||||
/// before the customer clicked. Uses <c>IgnoreQueryFilters</c> because the customer has no
|
||||
/// tenant context. Loads <c>QuoteItems</c>, <c>QuoteStatus</c>, and <c>Customer</c> eagerly to
|
||||
/// avoid N+1 queries in <see cref="BuildViewModelAsync"/>.
|
||||
/// Validates the approval token and returns the quote if still actionable. Uses
|
||||
/// IQuoteRepository.GetByApprovalTokenAsync which loads with IgnoreQueryFilters (the portal is
|
||||
/// unauthenticated — no tenant context exists on the request).
|
||||
/// </summary>
|
||||
private async Task<(Quote? quote, IActionResult? errorResult)> ValidateTokenAsync(string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return (null, base.View("InvalidToken"));
|
||||
|
||||
var quote = await _db.Quotes
|
||||
.IgnoreQueryFilters()
|
||||
.Include(q => q.QuoteItems)
|
||||
.Include(q => q.QuoteStatus)
|
||||
.Include(q => q.Customer)
|
||||
.FirstOrDefaultAsync(q => q.ApprovalToken == token && !q.IsDeleted);
|
||||
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
|
||||
|
||||
if (quote == null)
|
||||
return (null, base.View("InvalidToken"));
|
||||
@@ -462,7 +408,6 @@ public class QuoteApprovalController : Controller
|
||||
return (null, base.View("AlreadyActed", actedModel));
|
||||
}
|
||||
|
||||
// Also check terminal status
|
||||
if (quote.QuoteStatus != null &&
|
||||
(quote.QuoteStatus.IsApprovedStatus || quote.QuoteStatus.IsRejectedStatus || quote.QuoteStatus.IsConvertedStatus))
|
||||
{
|
||||
@@ -476,22 +421,16 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <c>QuoteApprovalViewModel</c> from a validated quote. Fetches company details and
|
||||
/// preferences (e.g. from-email address for the contact section) separately because they are
|
||||
/// not eagerly loaded by <see cref="ValidateTokenAsync"/>. Soft-deleted line items are filtered
|
||||
/// out so the customer only sees active items. Also maps all prospect contact fields so the
|
||||
/// <c>ConfirmDetails</c> view can pre-populate them if the prospect has previously started and
|
||||
/// returned to the approval page.
|
||||
/// Builds the QuoteApprovalViewModel from a validated quote. Fetches company details and
|
||||
/// preferences separately because they are not eagerly loaded by ValidateTokenAsync.
|
||||
/// </summary>
|
||||
private async Task<QuoteApprovalViewModel> BuildViewModelAsync(Quote quote, string token)
|
||||
{
|
||||
var company = await _db.Companies
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId);
|
||||
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
|
||||
c => c.Id == quote.CompanyId, ignoreQueryFilters: true);
|
||||
|
||||
var prefs = await _db.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == quote.CompanyId && !p.IsDeleted);
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == quote.CompanyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var items = (quote.QuoteItems ?? new List<QuoteItem>())
|
||||
.Where(i => !i.IsDeleted)
|
||||
@@ -537,9 +476,7 @@ public class QuoteApprovalController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the display name for the customer or prospect on a quote. Prefers the company name
|
||||
/// for commercial customers, falls back to contact first/last name, then to prospect fields, and
|
||||
/// finally to the generic "Valued Customer" sentinel so the view always has something to show.
|
||||
/// Resolves the display name for the customer or prospect on a quote.
|
||||
/// </summary>
|
||||
private static string GetCustomerName(Quote quote)
|
||||
{
|
||||
|
||||
@@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.AI;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
@@ -15,7 +14,6 @@ using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Web.Extensions;
|
||||
using PowderCoating.Web.Helpers;
|
||||
|
||||
@@ -30,7 +28,6 @@ public class QuotesController : Controller
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<QuotesController> _logger;
|
||||
private readonly IPdfService _pdfService;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IMeasurementConversionService _measurementService;
|
||||
private readonly ILookupCacheService _lookupCache;
|
||||
@@ -51,7 +48,6 @@ public class QuotesController : Controller
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<QuotesController> logger,
|
||||
IPdfService pdfService,
|
||||
ApplicationDbContext context,
|
||||
ITenantContext tenantContext,
|
||||
IMeasurementConversionService measurementService,
|
||||
ILookupCacheService lookupCache,
|
||||
@@ -71,7 +67,6 @@ public class QuotesController : Controller
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_pdfService = pdfService;
|
||||
_context = context;
|
||||
_tenantContext = tenantContext;
|
||||
_measurementService = measurementService;
|
||||
_lookupCache = lookupCache;
|
||||
@@ -234,11 +229,10 @@ public class QuotesController : Controller
|
||||
var approvedConvertedIds = quoteStatuses
|
||||
.Where(s => s.StatusCode == "APPROVED" || s.StatusCode == "CONVERTED")
|
||||
.Select(s => s.Id).ToList();
|
||||
var allCompanyQuotes = _context.Quotes
|
||||
.Where(q => q.CompanyId == companyId && !q.IsDeleted);
|
||||
ViewBag.StatOpenCount = await allCompanyQuotes.CountAsync(q => draftSentIds.Contains(q.QuoteStatusId));
|
||||
ViewBag.StatApprovedCount = await allCompanyQuotes.CountAsync(q => approvedConvertedIds.Contains(q.QuoteStatusId));
|
||||
ViewBag.StatTotalValue = await allCompanyQuotes.SumAsync(q => (decimal?)q.Total) ?? 0m;
|
||||
var indexStats = await _unitOfWork.Quotes.GetIndexStatsAsync(draftSentIds, approvedConvertedIds);
|
||||
ViewBag.StatOpenCount = indexStats.OpenCount;
|
||||
ViewBag.StatApprovedCount = indexStats.ApprovedConvertedCount;
|
||||
ViewBag.StatTotalValue = indexStats.TotalValue;
|
||||
|
||||
// Calibration nudge — suppress when named blast setups exist OR legacy CFM is set
|
||||
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
|
||||
@@ -266,7 +260,7 @@ public class QuotesController : Controller
|
||||
/// the customer even if operating costs have changed since the quote was created.
|
||||
/// Also verifies that ConvertedToJobId still points to a live job (clears stale references).
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
public async Task<IActionResult> Details(int? id, string? guidedActivation = null)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
@@ -275,32 +269,14 @@ public class QuotesController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
// Load quote with items and their related entities
|
||||
var quote = await _unitOfWork.Quotes.GetByIdAsync(
|
||||
id.Value,
|
||||
false,
|
||||
q => q.QuoteItems,
|
||||
q => q.Customer,
|
||||
q => q.PreparedBy,
|
||||
q => q.QuoteStatus,
|
||||
q => q.QuotePrepServices,
|
||||
q => q.OvenCost
|
||||
);
|
||||
// Load quote with all navigations needed for the Details view
|
||||
var quote = await _unitOfWork.Quotes.LoadForDetailsAsync(id.Value);
|
||||
|
||||
if (quote == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Load prep services with their details
|
||||
var quotePrepServices = await _context.QuotePrepServices
|
||||
.Where(qps => qps.QuoteId == id.Value && !qps.IsDeleted)
|
||||
.Include(qps => qps.PrepService)
|
||||
.ToListAsync();
|
||||
|
||||
// Assign to quote so AutoMapper can map them
|
||||
quote.QuotePrepServices = quotePrepServices;
|
||||
|
||||
var quoteDto = _mapper.Map<QuoteDto>(quote);
|
||||
|
||||
// Get customer info if exists
|
||||
@@ -353,19 +329,7 @@ public class QuotesController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Get quote items with their related entities (Coats, CatalogItem, and PrepServices)
|
||||
var quoteItems = await _context.QuoteItems
|
||||
.Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Include(qi => qi.CatalogItem)
|
||||
.Include(qi => qi.PrepServices)
|
||||
.ThenInclude(ps => ps.PrepService)
|
||||
.ToListAsync();
|
||||
|
||||
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
|
||||
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quote.QuoteItems);
|
||||
|
||||
// DEBUG: Log coat data
|
||||
_logger.LogInformation($"=== DETAILS VIEW: Quote {id} has {quoteDto.QuoteItems.Count} items ===");
|
||||
@@ -427,13 +391,7 @@ public class QuotesController : Controller
|
||||
}
|
||||
|
||||
// Load change history
|
||||
var changeHistories = await _context.QuoteChangeHistories
|
||||
.Where(h => h.QuoteId == id.Value && !h.IsDeleted)
|
||||
.Include(h => h.ChangedBy)
|
||||
.OrderByDescending(h => h.ChangedAt)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
var changeHistories = await _unitOfWork.Quotes.GetChangeHistoryAsync(id.Value);
|
||||
var changeHistoryDtos = _mapper.Map<List<QuoteChangeHistoryDto>>(changeHistories);
|
||||
ViewBag.ChangeHistory = changeHistoryDtos;
|
||||
|
||||
@@ -458,12 +416,9 @@ public class QuotesController : Controller
|
||||
await PopulateEmailNotificationDefaultsAsync(currentUserDetails.CompanyId);
|
||||
|
||||
// Load deposits recorded against this quote
|
||||
var quoteDeposits = await _context.Set<Deposit>()
|
||||
.Where(d => !d.IsDeleted && d.CompanyId == quote.CompanyId && d.QuoteId == id.Value)
|
||||
.Include(d => d.RecordedBy)
|
||||
var quoteDeposits = (await _unitOfWork.Deposits.FindAsync(d => d.QuoteId == id.Value, false, d => d.RecordedBy))
|
||||
.OrderByDescending(d => d.ReceivedDate)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
ViewBag.Deposits = quoteDeposits;
|
||||
|
||||
// Customer list for inline customer-change dropdown
|
||||
@@ -480,6 +435,21 @@ public class QuotesController : Controller
|
||||
.OrderBy(c => c.Text)
|
||||
.ToList();
|
||||
|
||||
var quotePrefs = await GetCompanyPreferencesAsync(currentUser!.CompanyId);
|
||||
if (guidedActivation == AppConstants.GuidedActivation.QuoteCreatedStep
|
||||
&& quotePrefs?.OnboardingPath == AppConstants.GuidedActivation.QuoteFirstPath
|
||||
&& quotePrefs.FirstWorkflowCompleted == false)
|
||||
{
|
||||
ViewBag.GuidedActivationMode = AppConstants.GuidedActivation.QuoteFirstPath;
|
||||
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
{
|
||||
Show = true,
|
||||
Title = "This is the quote you would send to your customer.",
|
||||
Message = "Next, convert it into a job so it moves into your real production workflow.",
|
||||
ActionText = "Convert to Job"
|
||||
};
|
||||
}
|
||||
|
||||
return View(quoteDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -564,16 +534,7 @@ public class QuotesController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Get quote items with their related entities (Coats and CatalogItem)
|
||||
var quoteItems = await _context.QuoteItems
|
||||
.Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Include(qi => qi.CatalogItem)
|
||||
.ToListAsync();
|
||||
|
||||
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value);
|
||||
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
|
||||
|
||||
// Get company info and logo
|
||||
@@ -632,10 +593,8 @@ public class QuotesController : Controller
|
||||
};
|
||||
|
||||
// Load company preferences for PDF template settings
|
||||
var prefs = await _context.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted);
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var template = new Application.DTOs.Company.QuoteTemplateSettingsDto
|
||||
{
|
||||
@@ -683,7 +642,7 @@ public class QuotesController : Controller
|
||||
/// Optionally pre-selects a customer when <paramref name="customerId"/> is provided (e.g. when
|
||||
/// navigating from the Customer Details page using the "New Quote" shortcut).
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Create(int? customerId)
|
||||
public async Task<IActionResult> Create(int? customerId, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -738,6 +697,17 @@ public class QuotesController : Controller
|
||||
CustomerId = customerId
|
||||
};
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath)
|
||||
{
|
||||
var draft = GuidedActivationDefaults.BuildQuoteDraft(customerId);
|
||||
dto.CustomerId = draft.CustomerId;
|
||||
dto.Description = draft.Description;
|
||||
dto.Notes = draft.Notes;
|
||||
dto.QuoteItems = draft.QuoteItems;
|
||||
}
|
||||
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -759,7 +729,7 @@ public class QuotesController : Controller
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreateQuoteDto dto)
|
||||
public async Task<IActionResult> Create(CreateQuoteDto dto, string? guidedActivation = null)
|
||||
{
|
||||
_logger.LogInformation("=== CREATE QUOTE POST ACTION CALLED ===");
|
||||
_logger.LogInformation("IsForProspect: {IsForProspect}", dto.IsForProspect);
|
||||
@@ -888,6 +858,7 @@ public class QuotesController : Controller
|
||||
|
||||
await PopulateDropDownsAsync(currentUser!.CompanyId, operatingCosts?.OvenOperatingCostPerHour ?? 0);
|
||||
await SetMeasurementViewBagAsync();
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1186,15 +1157,22 @@ public class QuotesController : Controller
|
||||
_logger.LogWarning(ex, "Notification failed for quote {Id}", quote.Id);
|
||||
}
|
||||
|
||||
var quoteCreateNotifLog = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.QuoteId == quote.Id)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
var quoteCreateNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(quote.Id);
|
||||
this.SetNotificationResultToast(quoteCreateNotifLog);
|
||||
}
|
||||
|
||||
await StampQuoteCreatedAsync(currentUser.CompanyId);
|
||||
|
||||
this.ToastSuccess($"Quote {quote.QuoteNumber} created successfully!");
|
||||
if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath)
|
||||
{
|
||||
return RedirectToAction(nameof(Details), new
|
||||
{
|
||||
id = quote.Id,
|
||||
guidedActivation = AppConstants.GuidedActivation.QuoteCreatedStep
|
||||
});
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id = quote.Id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1205,6 +1183,7 @@ public class QuotesController : Controller
|
||||
var catchCosts = await _pricingService.GetOperatingCostsAsync(catchUser!.CompanyId);
|
||||
await PopulateDropDownsAsync(catchUser.CompanyId, catchCosts?.OvenOperatingCostPerHour ?? 0);
|
||||
await SetMeasurementViewBagAsync();
|
||||
ViewBag.GuidedActivation = guidedActivation;
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
@@ -1231,13 +1210,7 @@ public class QuotesController : Controller
|
||||
}
|
||||
|
||||
// Get quote items with their coats, prep services and catalog item
|
||||
var quoteItems = await _context.QuoteItems
|
||||
.Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(qi => qi.PrepServices)
|
||||
.Include(qi => qi.CatalogItem)
|
||||
.ToListAsync();
|
||||
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value);
|
||||
|
||||
_logger.LogInformation("=== LOADING QUOTE {QuoteId} FOR EDIT ===", id.Value);
|
||||
foreach (var item in quoteItems)
|
||||
@@ -2166,7 +2139,7 @@ public class QuotesController : Controller
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ConvertToJob(int id)
|
||||
public async Task<IActionResult> ConvertToJob(int id, string? guidedActivation = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -2230,15 +2203,13 @@ public class QuotesController : Controller
|
||||
// Check for an orphaned partial job (previous conversion attempt that failed mid-way).
|
||||
// This can happen if SaveChangesAsync succeeded for the Job row but failed for JobItems.
|
||||
// The unique index on Jobs.QuoteId would block a retry — clean it up first.
|
||||
var orphanedJob = await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(j => j.QuoteId == id && j.CompanyId == quote.CompanyId);
|
||||
var orphanedJob = await _unitOfWork.Jobs.GetOrphanedConversionJobAsync(id, quote.CompanyId);
|
||||
if (orphanedJob != null)
|
||||
{
|
||||
_logger.LogWarning("Found orphaned job {JobNumber} (Id={JobId}) from a previous failed conversion of quote {QuoteId}. Cleaning up.",
|
||||
orphanedJob.JobNumber, orphanedJob.Id, id);
|
||||
_context.Jobs.Remove(orphanedJob);
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.Jobs.DeleteAsync(orphanedJob);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
@@ -2299,9 +2270,20 @@ public class QuotesController : Controller
|
||||
|
||||
this.ToastSuccess($"Job has been successfully created from quote {quote.QuoteNumber}!");
|
||||
|
||||
await StampJobCreatedAsync(currentUser!.CompanyId);
|
||||
|
||||
// Redirect to the newly created job's details page
|
||||
if (quote.ConvertedToJobId.HasValue)
|
||||
{
|
||||
if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath)
|
||||
{
|
||||
return RedirectToAction("Details", "Jobs", new
|
||||
{
|
||||
id = quote.ConvertedToJobId.Value,
|
||||
guidedActivation = AppConstants.GuidedActivation.JobCreatedStep
|
||||
});
|
||||
}
|
||||
|
||||
return RedirectToAction("Details", "Jobs", new { id = quote.ConvertedToJobId.Value });
|
||||
}
|
||||
|
||||
@@ -2351,25 +2333,11 @@ public class QuotesController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Get quote items with their related entities (Coats and CatalogItem)
|
||||
var quoteItems = await _context.QuoteItems
|
||||
.Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Include(qi => qi.CatalogItem)
|
||||
.ToListAsync();
|
||||
|
||||
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value);
|
||||
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
|
||||
|
||||
// Warn on confirmation page if a job is linked
|
||||
var linkedJob = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Where(j => j.QuoteId == id.Value && !j.IsDeleted)
|
||||
.Select(j => new { j.Id, j.JobNumber })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var linkedJob = await _unitOfWork.Jobs.FirstOrDefaultAsync(j => j.QuoteId == id.Value);
|
||||
if (linkedJob != null)
|
||||
{
|
||||
ViewBag.LinkedJobId = linkedJob.Id;
|
||||
@@ -2403,12 +2371,7 @@ public class QuotesController : Controller
|
||||
}
|
||||
|
||||
// Block deletion if a job was created from this quote
|
||||
var linkedJob = await _context.Jobs
|
||||
.AsNoTracking()
|
||||
.Where(j => j.QuoteId == id && !j.IsDeleted)
|
||||
.Select(j => new { j.Id, j.JobNumber })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var linkedJob = await _unitOfWork.Jobs.FirstOrDefaultAsync(j => j.QuoteId == id);
|
||||
if (linkedJob != null)
|
||||
{
|
||||
this.ToastError($"Quote {quote.QuoteNumber} cannot be deleted because Job {linkedJob.JobNumber} was created from it. Delete the job first, or keep the quote as a record.");
|
||||
@@ -2465,8 +2428,8 @@ public class QuotesController : Controller
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
// Find the Approved status for this company
|
||||
var approvedStatus = await _context.QuoteStatusLookups
|
||||
.FirstOrDefaultAsync(s => s.StatusCode == "APPROVED" && s.CompanyId == currentUser!.CompanyId);
|
||||
var approvedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
|
||||
s => s.StatusCode == "APPROVED" && s.CompanyId == currentUser!.CompanyId);
|
||||
|
||||
if (approvedStatus == null)
|
||||
{
|
||||
@@ -2518,11 +2481,7 @@ public class QuotesController : Controller
|
||||
_logger.LogWarning(ex, "Notification failed for quote {Id}", id);
|
||||
}
|
||||
|
||||
var approveNotifLog = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.QuoteId == id)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
var approveNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id);
|
||||
this.SetNotificationResultToast(approveNotifLog);
|
||||
}
|
||||
|
||||
@@ -2642,6 +2601,11 @@ public class QuotesController : Controller
|
||||
.Where(c => c.IsTaxExempt)
|
||||
.Select(c => c.Id)
|
||||
.ToHashSet();
|
||||
// Map used by JS to disable the email checkbox when the customer has notifications turned off
|
||||
ViewBag.CustomerEmailOptOutIds = customers
|
||||
.Where(c => !c.NotifyByEmail)
|
||||
.Select(c => c.Id)
|
||||
.ToHashSet();
|
||||
// Stored separately so views can restore the company default when switching away from an exempt customer
|
||||
// (ViewBag.CompanyTaxPercent is set by the calling action if it has access to operatingCosts)
|
||||
if (ViewBag.CompanyTaxPercent == null && customers.Any())
|
||||
@@ -2761,9 +2725,8 @@ public class QuotesController : Controller
|
||||
/// </summary>
|
||||
private async Task SetDefaultTermsAsync(int companyId)
|
||||
{
|
||||
var prefs = await _context.CompanyPreferences
|
||||
.IgnoreQueryFilters().AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
ViewBag.DefaultTerms = prefs?.QtDefaultTerms;
|
||||
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||
@@ -2836,13 +2799,7 @@ public class QuotesController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
var quoteItems = await _context.QuoteItems
|
||||
.Where(qi => qi.QuoteId == quoteId && !qi.IsDeleted)
|
||||
.Include(qi => qi.Coats).ThenInclude(c => c.InventoryItem)
|
||||
.Include(qi => qi.Coats).ThenInclude(c => c.Vendor)
|
||||
.Include(qi => qi.CatalogItem)
|
||||
.ToListAsync();
|
||||
|
||||
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quoteId);
|
||||
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
|
||||
@@ -2859,10 +2816,8 @@ public class QuotesController : Controller
|
||||
PrimaryContactEmail = company.PrimaryContactEmail
|
||||
};
|
||||
|
||||
var prefs = await _context.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted);
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var template = new Application.DTOs.Company.QuoteTemplateSettingsDto
|
||||
{
|
||||
@@ -2890,23 +2845,14 @@ public class QuotesController : Controller
|
||||
var now = DateTime.UtcNow;
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var prefs = await _context.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.CompanyId == companyId && !p.IsDeleted)
|
||||
.Select(p => new { p.QuoteNumberPrefix })
|
||||
.FirstOrDefaultAsync();
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT";
|
||||
var prefix = $"{quotePrefix}-{now:yy}{now:MM}";
|
||||
|
||||
// IgnoreQueryFilters so soft-deleted quotes are counted (prevents number reuse)
|
||||
// Explicit CompanyId filter scopes to current company only
|
||||
var lastQuoteNumber = await _context.Quotes
|
||||
.IgnoreQueryFilters()
|
||||
.Where(q => q.CompanyId == companyId && q.QuoteNumber.StartsWith(prefix))
|
||||
.OrderByDescending(q => q.QuoteNumber)
|
||||
.Select(q => q.QuoteNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix);
|
||||
|
||||
int nextNumber = 1;
|
||||
if (lastQuoteNumber != null)
|
||||
@@ -3012,15 +2958,7 @@ public class QuotesController : Controller
|
||||
|
||||
// Always reload quote items with full coat/prep-service data so this works
|
||||
// regardless of which caller loaded the quote (some callers don't include coats).
|
||||
var fullItems = await _context.QuoteItems
|
||||
.Where(qi => qi.QuoteId == quote.Id && !qi.IsDeleted)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.Vendor)
|
||||
.Include(qi => qi.PrepServices)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quote.Id);
|
||||
// Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning
|
||||
// no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
|
||||
|
||||
@@ -3161,9 +3099,8 @@ public class QuotesController : Controller
|
||||
// Aggregate unique prep services from all quote items and copy to job
|
||||
// Load from DB directly to ensure prep services are available regardless of caller's includes
|
||||
var quoteItemIds = fullItems.Select(qi => qi.Id).ToList();
|
||||
var itemPrepServices = await _context.QuoteItemPrepServices
|
||||
.Where(ps => quoteItemIds.Contains(ps.QuoteItemId) && !ps.IsDeleted)
|
||||
.ToListAsync();
|
||||
var itemPrepServices = (await _unitOfWork.QuoteItemPrepServices.FindAsync(
|
||||
ps => quoteItemIds.Contains(ps.QuoteItemId))).ToList();
|
||||
var uniquePrepServiceIds = itemPrepServices
|
||||
.Select(ps => ps.PrepServiceId)
|
||||
.Distinct()
|
||||
@@ -3173,7 +3110,7 @@ public class QuotesController : Controller
|
||||
{
|
||||
foreach (var prepServiceId in uniquePrepServiceIds)
|
||||
{
|
||||
await _context.JobPrepServices.AddAsync(new JobPrepService
|
||||
await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService
|
||||
{
|
||||
JobId = job.Id,
|
||||
PrepServiceId = prepServiceId,
|
||||
@@ -3181,7 +3118,7 @@ public class QuotesController : Controller
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
_logger.LogInformation("Copied {Count} unique prep services to job {JobNumber}",
|
||||
uniquePrepServiceIds.Count, job.JobNumber);
|
||||
}
|
||||
@@ -3195,10 +3132,8 @@ public class QuotesController : Controller
|
||||
// AI analysis photos are copied with IsAiAnalysisPhoto=true so they don't count against subscription limits
|
||||
try
|
||||
{
|
||||
var quotePhotos = await _context.QuotePhotos
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.QuoteId == quote.Id && !p.IsDeleted)
|
||||
.ToListAsync();
|
||||
var quotePhotos = (await _unitOfWork.QuotePhotos.FindAsync(
|
||||
p => p.QuoteId == quote.Id && !p.IsDeleted, ignoreQueryFilters: true)).ToList();
|
||||
|
||||
foreach (var qp in quotePhotos)
|
||||
{
|
||||
@@ -3218,7 +3153,7 @@ public class QuotesController : Controller
|
||||
|
||||
if (saved)
|
||||
{
|
||||
await _context.JobPhotos.AddAsync(new JobPhoto
|
||||
await _unitOfWork.JobPhotos.AddAsync(new JobPhoto
|
||||
{
|
||||
JobId = job.Id,
|
||||
CompanyId = quote.CompanyId,
|
||||
@@ -3234,7 +3169,7 @@ public class QuotesController : Controller
|
||||
});
|
||||
}
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception photoEx)
|
||||
{
|
||||
@@ -3258,23 +3193,14 @@ public class QuotesController : Controller
|
||||
var month = DateTime.Now.Month.ToString("D2");
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var prefs = await _context.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.CompanyId == companyId && !p.IsDeleted)
|
||||
.Select(p => new { p.JobNumberPrefix })
|
||||
.FirstOrDefaultAsync();
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB";
|
||||
var prefix = $"{jobPrefix}-{year}{month}";
|
||||
|
||||
// IgnoreQueryFilters so soft-deleted jobs are counted (prevents number reuse)
|
||||
// Explicit CompanyId filter scopes to current company only
|
||||
var lastJobNumber = await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => j.CompanyId == companyId && j.JobNumber.StartsWith(prefix))
|
||||
.OrderByDescending(j => j.JobNumber)
|
||||
.Select(j => j.JobNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix);
|
||||
|
||||
int nextNumber = 1;
|
||||
if (lastJobNumber != null)
|
||||
@@ -3347,11 +3273,7 @@ public class QuotesController : Controller
|
||||
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename);
|
||||
|
||||
// Check the most recent log entry to get actual send status
|
||||
var latestLog = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.QuoteId == id)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id);
|
||||
|
||||
if (latestLog?.Status == NotificationStatus.Failed)
|
||||
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" });
|
||||
@@ -3378,16 +3300,10 @@ public class QuotesController : Controller
|
||||
public async Task<IActionResult> NotificationsSent(int id)
|
||||
{
|
||||
var tz = ViewBag.CompanyTimeZone as string;
|
||||
var raw = await _context.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.QuoteId == id)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
|
||||
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.SentAt })
|
||||
.ToListAsync();
|
||||
|
||||
var logs = raw.Select(n => new { n.Id, n.Channel, n.Type, n.Status, n.RecipientName,
|
||||
n.Recipient, n.Subject, n.ErrorMessage, SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
|
||||
var rawLogs = await _unitOfWork.NotificationLogs.GetAllForQuoteAsync(id);
|
||||
var logs = rawLogs.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
|
||||
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage,
|
||||
SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
|
||||
return Json(logs);
|
||||
}
|
||||
|
||||
@@ -3925,6 +3841,35 @@ public class QuotesController : Controller
|
||||
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
|
||||
{
|
||||
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
}
|
||||
|
||||
private async Task StampQuoteCreatedAsync(int companyId)
|
||||
{
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null || prefs.FirstQuoteCreatedAt.HasValue)
|
||||
return;
|
||||
|
||||
prefs.FirstQuoteCreatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Recorded first quote creation for company {CompanyId}", companyId);
|
||||
}
|
||||
|
||||
private async Task StampJobCreatedAsync(int companyId)
|
||||
{
|
||||
var prefs = await GetCompanyPreferencesAsync(companyId);
|
||||
if (prefs == null || prefs.FirstJobCreatedAt.HasValue)
|
||||
return;
|
||||
|
||||
prefs.FirstJobCreatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
||||
}
|
||||
}
|
||||
|
||||
// Request model for AJAX pricing calculation
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -19,17 +18,16 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// </summary>
|
||||
public class ReleaseNotesController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
|
||||
public ReleaseNotesController(ApplicationDbContext db, IInAppNotificationService inApp)
|
||||
public ReleaseNotesController(IUnitOfWork unitOfWork, IInAppNotificationService inApp)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
_inApp = inApp;
|
||||
}
|
||||
|
||||
// ── Public: Changelog ────────────────────────────────────────────────────
|
||||
// Visible to all authenticated users
|
||||
|
||||
/// <summary>
|
||||
/// Renders the public changelog — shows only published release notes ordered
|
||||
@@ -39,54 +37,39 @@ public class ReleaseNotesController : Controller
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var notes = await _db.ReleaseNotes
|
||||
.AsNoTracking()
|
||||
.Where(r => r.IsPublished)
|
||||
var notes = (await _unitOfWork.ReleaseNotes.FindAsync(r => r.IsPublished))
|
||||
.OrderByDescending(r => r.ReleasedAt)
|
||||
.ThenByDescending(r => r.Id)
|
||||
.ToListAsync();
|
||||
|
||||
.ToList();
|
||||
return View(notes);
|
||||
}
|
||||
|
||||
// ── SuperAdmin: Manage ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the SuperAdmin management list of all release notes (published and
|
||||
/// draft alike), ordered newest-first. Unlike <see cref="Index"/> there is no
|
||||
/// <c>IsPublished</c> filter here so admins can see and edit drafts.
|
||||
/// Returns the SuperAdmin management list of all release notes (published and draft alike), ordered newest-first.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public async Task<IActionResult> Manage()
|
||||
{
|
||||
var notes = await _db.ReleaseNotes
|
||||
.AsNoTracking()
|
||||
var notes = (await _unitOfWork.ReleaseNotes.GetAllAsync())
|
||||
.OrderByDescending(r => r.ReleasedAt)
|
||||
.ThenByDescending(r => r.Id)
|
||||
.ToListAsync();
|
||||
|
||||
.ToList();
|
||||
return View(notes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Create form pre-populated with today's UTC date and the "Feature"
|
||||
/// tag as sensible defaults for new entries, reducing data-entry friction.
|
||||
/// Returns the Create form pre-populated with today's UTC date and the "Feature" tag as sensible defaults.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public IActionResult Create()
|
||||
{
|
||||
return View(new ReleaseNote
|
||||
{
|
||||
ReleasedAt = DateTime.UtcNow,
|
||||
Tag = "Feature"
|
||||
});
|
||||
return View(new ReleaseNote { ReleasedAt = DateTime.UtcNow, Tag = "Feature" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a new release note and captures the creating SuperAdmin's identity
|
||||
/// (<c>CreatedByUserId</c> / <c>CreatedByUserName</c>) for audit purposes.
|
||||
/// New notes start unpublished by default unless the form explicitly sets
|
||||
/// <c>IsPublished = true</c>, giving authors a chance to review before going live.
|
||||
/// Persists a new release note. New notes start unpublished unless the form explicitly sets IsPublished = true.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
@@ -99,8 +82,8 @@ public class ReleaseNotesController : Controller
|
||||
model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
model.CreatedByUserName = User.Identity?.Name;
|
||||
|
||||
_db.ReleaseNotes.Add(model);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.ReleaseNotes.AddAsync(model);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
if (model.IsPublished)
|
||||
await NotifyAllTenantsAsync(model);
|
||||
@@ -109,22 +92,18 @@ public class ReleaseNotesController : Controller
|
||||
return RedirectToAction(nameof(Manage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Edit form loaded from the database by primary key.
|
||||
/// </summary>
|
||||
/// <summary>Returns the Edit form loaded from the database by primary key.</summary>
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var note = await _db.ReleaseNotes.FindAsync(id);
|
||||
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
|
||||
if (note == null) return NotFound();
|
||||
return View(note);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the edited values to the tracked entity. Uses explicit field
|
||||
/// mapping (rather than <c>_db.Entry(model).State = Modified</c>) to prevent
|
||||
/// over-posting attacks and to ensure audit fields like <c>CreatedAt</c> and
|
||||
/// <c>CreatedByUserId</c> are never overwritten.
|
||||
/// Applies the edited values to the tracked entity using explicit field mapping to prevent
|
||||
/// over-posting attacks and ensure audit fields are never overwritten.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
@@ -134,40 +113,37 @@ public class ReleaseNotesController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
return View(model);
|
||||
|
||||
var note = await _db.ReleaseNotes.FindAsync(id);
|
||||
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
|
||||
if (note == null) return NotFound();
|
||||
|
||||
note.Version = model.Version;
|
||||
note.Title = model.Title;
|
||||
note.Body = model.Body;
|
||||
note.Tag = model.Tag;
|
||||
note.IsPublished= model.IsPublished;
|
||||
note.ReleasedAt = model.ReleasedAt;
|
||||
note.UpdatedAt = DateTime.UtcNow;
|
||||
note.Version = model.Version;
|
||||
note.Title = model.Title;
|
||||
note.Body = model.Body;
|
||||
note.Tag = model.Tag;
|
||||
note.IsPublished = model.IsPublished;
|
||||
note.ReleasedAt = model.ReleasedAt;
|
||||
note.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Release note v{note.Version} updated.";
|
||||
return RedirectToAction(nameof(Manage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the published state of a release note. Publishing makes the note
|
||||
/// immediately visible to all authenticated users via <see cref="Index"/>;
|
||||
/// un-publishing hides it without permanently deleting it so it can be revised
|
||||
/// and re-published later.
|
||||
/// Toggles the published state of a release note. Publishing fires an in-app notification to all tenants.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public async Task<IActionResult> TogglePublish(int id)
|
||||
{
|
||||
var note = await _db.ReleaseNotes.FindAsync(id);
|
||||
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
|
||||
if (note == null) return NotFound();
|
||||
|
||||
var wasPublished = note.IsPublished;
|
||||
note.IsPublished = !note.IsPublished;
|
||||
note.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
if (note.IsPublished && !wasPublished)
|
||||
await NotifyAllTenantsAsync(note);
|
||||
@@ -179,29 +155,24 @@ public class ReleaseNotesController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permanently (hard) deletes a release note. This is intentional — release notes
|
||||
/// are platform metadata, not business data, so they do not use soft delete.
|
||||
/// Use <see cref="TogglePublish"/> to hide a note without permanent removal.
|
||||
/// Permanently (hard) deletes a release note. Release notes are platform metadata and do not use soft delete.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var note = await _db.ReleaseNotes.FindAsync(id);
|
||||
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
|
||||
if (note == null) return NotFound();
|
||||
|
||||
_db.ReleaseNotes.Remove(note);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.ReleaseNotes.DeleteAsync(note);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Release note v{note.Version} deleted.";
|
||||
return RedirectToAction(nameof(Manage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fans out a "What's New" in-app notification to every active tenant company when
|
||||
/// a release note transitions to published. Notification fires exactly once per
|
||||
/// publish event — re-publishing after unpublishing will send a second notification,
|
||||
/// which is intentional (the content may have changed).
|
||||
/// Fans out a "What's New" in-app notification to every active tenant company when a release note is published.
|
||||
/// </summary>
|
||||
private Task NotifyAllTenantsAsync(ReleaseNote note)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,6 @@ using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.ViewModels.Reports;
|
||||
|
||||
@@ -20,17 +19,19 @@ public class ReportsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ReportsController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IFinancialReportService _financialReports;
|
||||
private readonly IOperationalReportService _operationalReports;
|
||||
private readonly IPdfService _pdfService;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IAccountingAiService _accountingAi;
|
||||
private readonly IAiUsageLogger _usageLogger;
|
||||
|
||||
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, ApplicationDbContext context, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger)
|
||||
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
_financialReports = financialReports;
|
||||
_operationalReports = operationalReports;
|
||||
_pdfService = pdfService;
|
||||
_userManager = userManager;
|
||||
_accountingAi = accountingAi;
|
||||
@@ -493,11 +494,7 @@ public class ReportsController : Controller
|
||||
.ToList();
|
||||
|
||||
// === EXPENSE / AP ANALYTICS ===
|
||||
var allBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Include(b => b.Payments.Where(p => !p.IsDeleted))
|
||||
.Where(b => !b.IsDeleted && b.Status != BillStatus.Voided)
|
||||
.ToListAsync();
|
||||
var allBills = await _operationalReports.GetActiveBillsAsync();
|
||||
|
||||
var totalBilled = allBills.Sum(b => b.Total);
|
||||
var totalBillsPaid = allBills.Sum(b => b.AmountPaid);
|
||||
@@ -536,10 +533,7 @@ public class ReportsController : Controller
|
||||
.ToList();
|
||||
|
||||
// Expenses by account
|
||||
var allExpenses = await _context.Expenses
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Where(e => !e.IsDeleted)
|
||||
.ToListAsync();
|
||||
var allExpenses = await _operationalReports.GetAllExpensesAsync();
|
||||
|
||||
var expensesByAccount = allExpenses
|
||||
.Where(e => e.ExpenseAccount != null)
|
||||
@@ -664,10 +658,7 @@ public class ReportsController : Controller
|
||||
.ToList();
|
||||
|
||||
// === JOB CYCLE TIME ===
|
||||
var allStatusHistory = await _context.JobStatusHistory
|
||||
.Include(h => h.FromStatus)
|
||||
.Include(h => h.ToStatus)
|
||||
.ToListAsync();
|
||||
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
|
||||
|
||||
var historyByJob = allStatusHistory
|
||||
.GroupBy(h => h.JobId)
|
||||
@@ -1002,102 +993,10 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> ProfitAndLoss(DateTime? from, DateTime? to)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var toEnd = toDate.AddDays(1).AddTicks(-1);
|
||||
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
// ── Revenue: InvoiceItems posted to revenue accounts ──────────────────
|
||||
var revenueByAccount = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId != null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.GroupBy(ii => ii.RevenueAccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
|
||||
.ToListAsync();
|
||||
|
||||
// Unlinked invoice totals (items without a revenue account) → lump into a default "Sales" bucket
|
||||
var unlinkedRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId == null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
|
||||
var revenueAccounts = await _context.Accounts
|
||||
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
||||
.ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var revenueLines = revenueByAccount
|
||||
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
|
||||
.Select(r => new FinancialReportLine
|
||||
{
|
||||
AccountId = r.AccountId,
|
||||
AccountNumber = revenueAccounts[r.AccountId].AccountNumber,
|
||||
AccountName = revenueAccounts[r.AccountId].Name,
|
||||
Amount = r.Amount
|
||||
})
|
||||
.OrderBy(l => l.AccountNumber)
|
||||
.ToList();
|
||||
|
||||
if (unlinkedRevenue > 0)
|
||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
||||
|
||||
// ── COGS & Expenses: Expenses + BillLineItems by account type ─────────
|
||||
// Direct expenses
|
||||
var directByAccount = await _context.Expenses
|
||||
.Where(e => e.Date >= fromDate && e.Date <= toEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToListAsync();
|
||||
|
||||
// Bill line items (skip lines with no account — QB-imported without account mapping)
|
||||
var billLinesByAccount = await _context.BillLineItems
|
||||
.Where(bli => bli.AccountId != null
|
||||
&& bli.Bill.Status != BillStatus.Draft
|
||||
&& bli.Bill.Status != BillStatus.Voided
|
||||
&& bli.Bill.BillDate >= fromDate && bli.Bill.BillDate <= toEnd)
|
||||
.GroupBy(bli => bli.AccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) })
|
||||
.ToListAsync();
|
||||
|
||||
// Merge the two expense sources per account
|
||||
var expenseAmounts = new Dictionary<int, decimal>();
|
||||
foreach (var e in directByAccount)
|
||||
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
|
||||
foreach (var b in billLinesByAccount)
|
||||
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
|
||||
|
||||
var expAccounts = await _context.Accounts
|
||||
.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
|
||||
.ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var cogsLines = new List<FinancialReportLine>();
|
||||
var expenseLines = new List<FinancialReportLine>();
|
||||
|
||||
foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999"))
|
||||
{
|
||||
if (!expAccounts.TryGetValue(accountId, out var acct)) continue;
|
||||
var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount };
|
||||
if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line);
|
||||
else expenseLines.Add(line);
|
||||
}
|
||||
|
||||
var dto = new ProfitAndLossDto
|
||||
{
|
||||
From = fromDate,
|
||||
To = toDate,
|
||||
CompanyName = companyName,
|
||||
RevenueLines = revenueLines,
|
||||
TotalRevenue = revenueLines.Sum(l => l.Amount),
|
||||
CogsLines = cogsLines,
|
||||
TotalCogs = cogsLines.Sum(l => l.Amount),
|
||||
ExpenseLines = expenseLines,
|
||||
TotalExpenses = expenseLines.Sum(l => l.Amount),
|
||||
};
|
||||
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetProfitAndLossAsync(companyId, fromDate, toDate);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1116,157 +1015,9 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> BalanceSheet(DateTime? asOf)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
|
||||
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
// ── Pre-compute balance contributions per account (batch queries) ──────
|
||||
|
||||
// Asset: payments deposited INTO account (DEBIT) — exclude voided/written-off invoices
|
||||
var depositsByAcct = await _context.Payments
|
||||
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||
.GroupBy(p => p.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Asset: expenses paid FROM account (CREDIT)
|
||||
var expFromByAcct = await _context.Expenses
|
||||
.Where(e => e.Date <= asOfEnd)
|
||||
.GroupBy(e => e.PaymentAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Asset: bill payments FROM account (CREDIT)
|
||||
var bpFromByAcct = await _context.BillPayments
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.BankAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Liability: bills posted to AP account (CREDIT)
|
||||
var billsByApAcct = await _context.Bills
|
||||
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||
.GroupBy(b => b.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Liability: bill payments reducing AP (DEBIT)
|
||||
var bpByApAcct = await _context.BillPayments
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.Bill.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Liability: sales tax payable (CREDIT)
|
||||
var taxByAcct = await _context.Invoices
|
||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate <= asOfEnd)
|
||||
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// AR total (used for AR sub-type accounts)
|
||||
var arDebits = await _context.Invoices.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd).SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||
var arCredits = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff).SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
// ── Retained earnings = net P&L from inception ────────────────────────
|
||||
var lifetimeRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
var lifetimeCogs = await _context.Expenses.Where(e => e.Date <= asOfEnd).Include(e => e.ExpenseAccount).SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||
var lifetimeBillCosts = await _context.BillLineItems.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd).SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
|
||||
|
||||
// ── Compute balance for each account ──────────────────────────────────
|
||||
decimal ComputeBalance(Account a)
|
||||
{
|
||||
bool normalDebit = a.AccountType == AccountType.Asset;
|
||||
decimal debits = 0, credits = 0;
|
||||
|
||||
if (a.AccountSubType == AccountSubType.AccountsReceivable)
|
||||
{
|
||||
debits = arDebits; credits = arCredits;
|
||||
}
|
||||
else if (a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
{
|
||||
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
debits += depositsByAcct.GetValueOrDefault(a.Id);
|
||||
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
||||
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||
}
|
||||
|
||||
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOfDate)
|
||||
? a.OpeningBalance : 0;
|
||||
decimal net = normalDebit ? debits - credits : credits - debits;
|
||||
return opening + net;
|
||||
}
|
||||
|
||||
FinancialReportLine ToLine(Account a) => new()
|
||||
{
|
||||
AccountId = a.Id,
|
||||
AccountNumber = a.AccountNumber,
|
||||
AccountName = a.Name,
|
||||
Amount = ComputeBalance(a)
|
||||
};
|
||||
|
||||
// Load accounts by type
|
||||
var accounts = await _context.Accounts.Where(a => a.IsActive).OrderBy(a => a.AccountNumber).ToListAsync();
|
||||
|
||||
var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList();
|
||||
var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList();
|
||||
var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList();
|
||||
|
||||
var currentAssets = assetAccts
|
||||
.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset)
|
||||
.Select(ToLine).ToList();
|
||||
var fixedAssets = assetAccts
|
||||
.Where(a => a.AccountSubType == AccountSubType.FixedAsset)
|
||||
.Select(ToLine).ToList();
|
||||
var otherAssets = assetAccts
|
||||
.Where(a => a.AccountSubType == AccountSubType.OtherAsset)
|
||||
.Select(ToLine).ToList();
|
||||
|
||||
var currentLiabilities = liabilityAccts
|
||||
.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability)
|
||||
.Select(ToLine).ToList();
|
||||
var longTermLiabilities = liabilityAccts
|
||||
.Where(a => a.AccountSubType == AccountSubType.LongTermLiability)
|
||||
.Select(ToLine).ToList();
|
||||
|
||||
var equityLines = equityAccts.Select(ToLine).ToList();
|
||||
|
||||
var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount);
|
||||
var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount);
|
||||
var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings;
|
||||
|
||||
var dto = new BalanceSheetDto
|
||||
{
|
||||
AsOf = asOfDate,
|
||||
CompanyName = companyName,
|
||||
CurrentAssets = currentAssets,
|
||||
FixedAssets = fixedAssets,
|
||||
OtherAssets = otherAssets,
|
||||
TotalAssets = totalAssets,
|
||||
CurrentLiabilities = currentLiabilities,
|
||||
LongTermLiabilities = longTermLiabilities,
|
||||
TotalLiabilities = totalLiabilities,
|
||||
EquityLines = equityLines,
|
||||
RetainedEarnings = retainedEarnings,
|
||||
TotalEquity = totalEquity,
|
||||
};
|
||||
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetBalanceSheetAsync(companyId, asOfDate);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1282,85 +1033,9 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> ArAging(DateTime? asOf)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
|
||||
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var openInvoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.Paid
|
||||
&& i.InvoiceDate <= asOfEnd
|
||||
&& (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0)
|
||||
.OrderBy(i => i.Customer!.CompanyName)
|
||||
.ThenBy(i => i.DueDate)
|
||||
.ToListAsync();
|
||||
|
||||
var customerGroups = openInvoices
|
||||
.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial });
|
||||
|
||||
static string AgingBucket(int daysOverdue) => daysOverdue switch
|
||||
{
|
||||
<= 0 => "current",
|
||||
<= 30 => "1-30",
|
||||
<= 60 => "31-60",
|
||||
<= 90 => "61-90",
|
||||
_ => "90+"
|
||||
};
|
||||
|
||||
var customers = new List<ArAgingCustomerDto>();
|
||||
|
||||
foreach (var grp in customerGroups)
|
||||
{
|
||||
var customerName = grp.Key.IsCommercial
|
||||
? grp.Key.CompanyName
|
||||
: $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim();
|
||||
|
||||
var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName };
|
||||
|
||||
foreach (var inv in grp)
|
||||
{
|
||||
var balance = inv.BalanceDue;
|
||||
var daysOverdue = inv.DueDate.HasValue ? (int)(asOfDate - inv.DueDate.Value.Date).TotalDays : 0;
|
||||
var bucket = AgingBucket(daysOverdue);
|
||||
|
||||
custDto.Invoices.Add(new ArAgingInvoiceDto
|
||||
{
|
||||
InvoiceId = inv.Id,
|
||||
InvoiceNumber = inv.InvoiceNumber,
|
||||
InvoiceDate = inv.InvoiceDate,
|
||||
DueDate = inv.DueDate,
|
||||
BalanceDue = balance,
|
||||
DaysOverdue = daysOverdue
|
||||
});
|
||||
|
||||
switch (bucket)
|
||||
{
|
||||
case "current": custDto.TotalCurrent += balance; break;
|
||||
case "1-30": custDto.Total1to30 += balance; break;
|
||||
case "31-60": custDto.Total31to60 += balance; break;
|
||||
case "61-90": custDto.Total61to90 += balance; break;
|
||||
default: custDto.TotalOver90 += balance; break;
|
||||
}
|
||||
}
|
||||
|
||||
customers.Add(custDto);
|
||||
}
|
||||
|
||||
var dto = new ArAgingReportDto
|
||||
{
|
||||
AsOf = asOfDate,
|
||||
CompanyName = companyName,
|
||||
Customers = customers.OrderByDescending(c => c.TotalBalance).ToList(),
|
||||
TotalCurrent = customers.Sum(c => c.TotalCurrent),
|
||||
Total1to30 = customers.Sum(c => c.Total1to30),
|
||||
Total31to60 = customers.Sum(c => c.Total31to60),
|
||||
Total61to90 = customers.Sum(c => c.Total61to90),
|
||||
TotalOver90 = customers.Sum(c => c.TotalOver90),
|
||||
};
|
||||
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetArAgingAsync(companyId, asOfDate);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1375,90 +1050,10 @@ public class ReportsController : Controller
|
||||
// GET: /Reports/SalesAndIncome
|
||||
public async Task<IActionResult> SalesAndIncome(DateTime? from, DateTime? to)
|
||||
{
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var toEnd = toDate.AddDays(1).AddTicks(-1);
|
||||
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var invoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Include(i => i.Payments)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toEnd)
|
||||
.OrderBy(i => i.InvoiceDate)
|
||||
.ToListAsync();
|
||||
|
||||
// Payments collected within the period (may differ from invoice dates)
|
||||
var collectedInPeriod = await _context.Payments
|
||||
.Where(p => p.PaymentDate >= fromDate && p.PaymentDate <= toEnd)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
// By customer
|
||||
var byCustomer = invoices
|
||||
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial
|
||||
? i.Customer.CompanyName
|
||||
: $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() })
|
||||
.Select(g => new SalesByCustomerDto
|
||||
{
|
||||
CustomerId = g.Key.CustomerId,
|
||||
CustomerName = g.Key.Name,
|
||||
InvoiceCount = g.Count(),
|
||||
TotalInvoiced = g.Sum(i => i.Total),
|
||||
TotalPaid = g.Sum(i => i.AmountPaid),
|
||||
BalanceDue = g.Sum(i => i.BalanceDue),
|
||||
})
|
||||
.OrderByDescending(c => c.TotalInvoiced)
|
||||
.ToList();
|
||||
|
||||
// By month
|
||||
var byMonth = invoices
|
||||
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
|
||||
.Select(g => new SalesByMonthDto
|
||||
{
|
||||
Year = g.Key.Year,
|
||||
Month = g.Key.Month,
|
||||
Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"),
|
||||
TotalInvoiced = g.Sum(i => i.Total),
|
||||
TotalCollected = g.Sum(i => i.AmountPaid),
|
||||
InvoiceCount = g.Count(),
|
||||
})
|
||||
.OrderBy(m => m.Year).ThenBy(m => m.Month)
|
||||
.ToList();
|
||||
|
||||
// Invoice detail lines
|
||||
var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto
|
||||
{
|
||||
InvoiceId = i.Id,
|
||||
InvoiceNumber = i.InvoiceNumber,
|
||||
CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
|
||||
InvoiceDate = i.InvoiceDate,
|
||||
DueDate = i.DueDate,
|
||||
Status = i.Status.ToString(),
|
||||
SubTotal = i.SubTotal,
|
||||
TaxAmount = i.TaxAmount,
|
||||
Total = i.Total,
|
||||
AmountPaid = i.AmountPaid,
|
||||
BalanceDue = i.BalanceDue,
|
||||
}).ToList();
|
||||
|
||||
var dto = new SalesIncomeReportDto
|
||||
{
|
||||
From = fromDate,
|
||||
To = toDate,
|
||||
CompanyName = companyName,
|
||||
TotalInvoiced = invoices.Sum(i => i.Total),
|
||||
TotalCollected = collectedInPeriod,
|
||||
TotalTax = invoices.Sum(i => i.TaxAmount),
|
||||
TotalDiscount = invoices.Sum(i => i.DiscountAmount),
|
||||
InvoiceCount = invoices.Count,
|
||||
CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(),
|
||||
ByCustomer = byCustomer,
|
||||
ByMonth = byMonth,
|
||||
Invoices = invoiceLines,
|
||||
};
|
||||
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetSalesAndIncomeAsync(companyId, fromDate, toDate);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -1476,64 +1071,10 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> ProfitAndLossPdf(DateTime? from, DateTime? to, bool inline = false)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var toEnd = toDate.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var revenueByAccount = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId != null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.GroupBy(ii => ii.RevenueAccountId!.Value)
|
||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
|
||||
.ToListAsync();
|
||||
|
||||
var unlinkedRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId == null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
|
||||
var revenueAccounts = await _context.Accounts.Where(a => a.AccountType == AccountType.Revenue && a.IsActive).ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var revenueLines = revenueByAccount
|
||||
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
|
||||
.Select(r => new FinancialReportLine { AccountId = r.AccountId, AccountNumber = revenueAccounts[r.AccountId].AccountNumber, AccountName = revenueAccounts[r.AccountId].Name, Amount = r.Amount })
|
||||
.OrderBy(l => l.AccountNumber).ToList();
|
||||
|
||||
if (unlinkedRevenue > 0)
|
||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
||||
|
||||
var directByAccount = await _context.Expenses.Where(e => e.Date >= fromDate && e.Date <= toEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId).Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) }).ToListAsync();
|
||||
|
||||
var billLinesByAccount = await _context.BillLineItems
|
||||
.Where(bli => bli.AccountId != null && bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate >= fromDate && bli.Bill.BillDate <= toEnd)
|
||||
.GroupBy(bli => bli.AccountId!.Value).Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) }).ToListAsync();
|
||||
|
||||
var expenseAmounts = new Dictionary<int, decimal>();
|
||||
foreach (var e in directByAccount) expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
|
||||
foreach (var b in billLinesByAccount) expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
|
||||
|
||||
var expAccounts = await _context.Accounts.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive).ToDictionaryAsync(a => a.Id);
|
||||
|
||||
var cogsLines = new List<FinancialReportLine>(); var expenseLines = new List<FinancialReportLine>();
|
||||
foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999"))
|
||||
{
|
||||
if (!expAccounts.TryGetValue(accountId, out var acct)) continue;
|
||||
var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount };
|
||||
if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line); else expenseLines.Add(line);
|
||||
}
|
||||
|
||||
var dto = new ProfitAndLossDto
|
||||
{
|
||||
From = fromDate, To = toDate, CompanyName = companyName,
|
||||
RevenueLines = revenueLines, TotalRevenue = revenueLines.Sum(l => l.Amount),
|
||||
CogsLines = cogsLines, TotalCogs = cogsLines.Sum(l => l.Amount),
|
||||
ExpenseLines = expenseLines, TotalExpenses = expenseLines.Sum(l => l.Amount),
|
||||
};
|
||||
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetProfitAndLossAsync(companyId, fromDate, toDate);
|
||||
var pdfBytes = await _pdfService.GenerateProfitAndLossPdfAsync(dto);
|
||||
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"ProfitAndLoss-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
@@ -1546,75 +1087,9 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> BalanceSheetPdf(DateTime? asOf, bool inline = false)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var depositsByAcct = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null)
|
||||
.GroupBy(p => p.DepositAccountId!.Value).Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var expFromByAcct = await _context.Expenses.Where(e => e.Date <= asOfEnd)
|
||||
.GroupBy(e => e.PaymentAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var bpFromByAcct = await _context.BillPayments.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.BankAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var billsByApAcct = await _context.Bills.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||
.GroupBy(b => b.APAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var bpByApAcct = await _context.BillPayments.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.Bill.APAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var taxByAcct = await _context.Invoices.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0 && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
|
||||
.GroupBy(i => i.SalesTaxAccountId!.Value).Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var arDebits = await _context.Invoices.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd).SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||
var arCredits = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd).SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
var lifetimeRevenue = await _context.InvoiceItems.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd).SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
var lifetimeCogs = await _context.Expenses.Where(e => e.Date <= asOfEnd).Include(e => e.ExpenseAccount).SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||
var lifetimeBillCosts = await _context.BillLineItems.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd).SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
|
||||
|
||||
var accounts = await _context.Accounts.Where(a => a.IsActive).OrderBy(a => a.AccountNumber).ToListAsync();
|
||||
|
||||
decimal ComputeBalance(Account a)
|
||||
{
|
||||
bool normalDebit = a.AccountType == AccountType.Asset;
|
||||
decimal debits = 0, credits = 0;
|
||||
if (a.AccountSubType == AccountSubType.AccountsReceivable) { debits = arDebits; credits = arCredits; }
|
||||
else if (a.AccountSubType == AccountSubType.AccountsPayable) { credits = billsByApAcct.GetValueOrDefault(a.Id); debits = bpByApAcct.GetValueOrDefault(a.Id); }
|
||||
else { debits += depositsByAcct.GetValueOrDefault(a.Id); credits += expFromByAcct.GetValueOrDefault(a.Id); credits += bpFromByAcct.GetValueOrDefault(a.Id); credits += taxByAcct.GetValueOrDefault(a.Id); }
|
||||
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOfDate) ? a.OpeningBalance : 0;
|
||||
decimal net = normalDebit ? debits - credits : credits - debits;
|
||||
return opening + net;
|
||||
}
|
||||
|
||||
FinancialReportLine ToLine(Account a) => new() { AccountId = a.Id, AccountNumber = a.AccountNumber, AccountName = a.Name, Amount = ComputeBalance(a) };
|
||||
|
||||
var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList();
|
||||
var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList();
|
||||
var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList();
|
||||
|
||||
var currentAssets = assetAccts.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset).Select(ToLine).ToList();
|
||||
var fixedAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.FixedAsset).Select(ToLine).ToList();
|
||||
var otherAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.OtherAsset).Select(ToLine).ToList();
|
||||
var currentLiabilities = liabilityAccts.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability).Select(ToLine).ToList();
|
||||
var longTermLiabilities = liabilityAccts.Where(a => a.AccountSubType == AccountSubType.LongTermLiability).Select(ToLine).ToList();
|
||||
var equityLines = equityAccts.Select(ToLine).ToList();
|
||||
|
||||
var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount);
|
||||
var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount);
|
||||
var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings;
|
||||
|
||||
var dto = new BalanceSheetDto
|
||||
{
|
||||
AsOf = asOfDate, CompanyName = companyName,
|
||||
CurrentAssets = currentAssets, FixedAssets = fixedAssets, OtherAssets = otherAssets, TotalAssets = totalAssets,
|
||||
CurrentLiabilities = currentLiabilities, LongTermLiabilities = longTermLiabilities, TotalLiabilities = totalLiabilities,
|
||||
EquityLines = equityLines, RetainedEarnings = retainedEarnings, TotalEquity = totalEquity,
|
||||
};
|
||||
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetBalanceSheetAsync(companyId, asOfDate);
|
||||
var pdfBytes = await _pdfService.GenerateBalanceSheetPdfAsync(dto);
|
||||
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"BalanceSheet-{asOfDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
@@ -1627,46 +1102,9 @@ public class ReportsController : Controller
|
||||
public async Task<IActionResult> ArAgingPdf(DateTime? asOf, bool inline = false)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var openInvoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.Paid && i.InvoiceDate <= asOfEnd
|
||||
&& (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0)
|
||||
.OrderBy(i => i.Customer!.CompanyName).ThenBy(i => i.DueDate)
|
||||
.ToListAsync();
|
||||
|
||||
var customerGroups = openInvoices.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial });
|
||||
|
||||
static string AgingBucket(int d) => d switch { <= 0 => "current", <= 30 => "1-30", <= 60 => "31-60", <= 90 => "61-90", _ => "90+" };
|
||||
|
||||
var customers = new List<ArAgingCustomerDto>();
|
||||
foreach (var grp in customerGroups)
|
||||
{
|
||||
var customerName = grp.Key.IsCommercial ? grp.Key.CompanyName : $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim();
|
||||
var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName };
|
||||
foreach (var inv in grp)
|
||||
{
|
||||
var balance = inv.BalanceDue;
|
||||
var daysOverdue = inv.DueDate.HasValue ? (int)(asOfDate - inv.DueDate.Value.Date).TotalDays : 0;
|
||||
custDto.Invoices.Add(new ArAgingInvoiceDto { InvoiceId = inv.Id, InvoiceNumber = inv.InvoiceNumber, InvoiceDate = inv.InvoiceDate, DueDate = inv.DueDate, BalanceDue = balance, DaysOverdue = daysOverdue });
|
||||
switch (AgingBucket(daysOverdue)) { case "current": custDto.TotalCurrent += balance; break; case "1-30": custDto.Total1to30 += balance; break; case "31-60": custDto.Total31to60 += balance; break; case "61-90": custDto.Total61to90 += balance; break; default: custDto.TotalOver90 += balance; break; }
|
||||
}
|
||||
customers.Add(custDto);
|
||||
}
|
||||
|
||||
var dto = new ArAgingReportDto
|
||||
{
|
||||
AsOf = asOfDate, CompanyName = companyName,
|
||||
Customers = customers.OrderByDescending(c => c.TotalBalance).ToList(),
|
||||
TotalCurrent = customers.Sum(c => c.TotalCurrent), Total1to30 = customers.Sum(c => c.Total1to30),
|
||||
Total31to60 = customers.Sum(c => c.Total31to60), Total61to90 = customers.Sum(c => c.Total61to90),
|
||||
TotalOver90 = customers.Sum(c => c.TotalOver90),
|
||||
};
|
||||
|
||||
var asOfDate = (asOf ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetArAgingAsync(companyId, asOfDate);
|
||||
var pdfBytes = await _pdfService.GenerateArAgingPdfAsync(dto);
|
||||
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"AR-Aging-{asOfDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
@@ -1678,48 +1116,10 @@ public class ReportsController : Controller
|
||||
// GET: /Reports/SalesAndIncomePdf
|
||||
public async Task<IActionResult> SalesAndIncomePdf(DateTime? from, DateTime? to, bool inline = false)
|
||||
{
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var toEnd = toDate.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
|
||||
var invoices = await _context.Invoices
|
||||
.Include(i => i.Customer).Include(i => i.Payments)
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toEnd)
|
||||
.OrderBy(i => i.InvoiceDate).ToListAsync();
|
||||
|
||||
var collectedInPeriod = await _context.Payments
|
||||
.Where(p => p.PaymentDate >= fromDate && p.PaymentDate <= toEnd)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
var byCustomer = invoices
|
||||
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() })
|
||||
.Select(g => new SalesByCustomerDto { CustomerId = g.Key.CustomerId, CustomerName = g.Key.Name, InvoiceCount = g.Count(), TotalInvoiced = g.Sum(i => i.Total), TotalPaid = g.Sum(i => i.AmountPaid), BalanceDue = g.Sum(i => i.BalanceDue) })
|
||||
.OrderByDescending(c => c.TotalInvoiced).ToList();
|
||||
|
||||
var byMonth = invoices
|
||||
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
|
||||
.Select(g => new SalesByMonthDto { Year = g.Key.Year, Month = g.Key.Month, Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"), TotalInvoiced = g.Sum(i => i.Total), TotalCollected = g.Sum(i => i.AmountPaid), InvoiceCount = g.Count() })
|
||||
.OrderBy(m => m.Year).ThenBy(m => m.Month).ToList();
|
||||
|
||||
var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto
|
||||
{
|
||||
InvoiceId = i.Id, InvoiceNumber = i.InvoiceNumber,
|
||||
CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
|
||||
InvoiceDate = i.InvoiceDate, DueDate = i.DueDate, Status = i.Status.ToString(),
|
||||
SubTotal = i.SubTotal, TaxAmount = i.TaxAmount, Total = i.Total, AmountPaid = i.AmountPaid, BalanceDue = i.BalanceDue,
|
||||
}).ToList();
|
||||
|
||||
var dto = new SalesIncomeReportDto
|
||||
{
|
||||
From = fromDate, To = toDate, CompanyName = companyName,
|
||||
TotalInvoiced = invoices.Sum(i => i.Total), TotalCollected = collectedInPeriod,
|
||||
TotalTax = invoices.Sum(i => i.TaxAmount), TotalDiscount = invoices.Sum(i => i.DiscountAmount),
|
||||
InvoiceCount = invoices.Count, CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(),
|
||||
ByCustomer = byCustomer, ByMonth = byMonth, Invoices = invoiceLines,
|
||||
};
|
||||
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetSalesAndIncomeAsync(companyId, fromDate, toDate);
|
||||
var pdfBytes = await _pdfService.GenerateSalesAndIncomePdfAsync(dto);
|
||||
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesAndIncome-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
@@ -1996,8 +1396,8 @@ public class ReportsController : Controller
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var now = DateTime.UtcNow;
|
||||
var today = DateTime.Today;
|
||||
var allBills = await _context.Bills.Include(b => b.Vendor).Include(b => b.Payments.Where(p => !p.IsDeleted)).Where(b => !b.IsDeleted && b.Status != BillStatus.Voided).ToListAsync();
|
||||
var allExpenses = await _context.Expenses.Include(e => e.ExpenseAccount).Where(e => !e.IsDeleted).ToListAsync();
|
||||
var allBills = await _operationalReports.GetActiveBillsAsync();
|
||||
var allExpenses = await _operationalReports.GetAllExpensesAsync();
|
||||
|
||||
var outstandingBills = allBills.Where(b => b.BalanceDue > 0).ToList();
|
||||
var apAgingBuckets = new List<AgingBucketItem> { new() { Label = "Current (0–30 days)" }, new() { Label = "31–60 days" }, new() { Label = "61–90 days" }, new() { Label = "Over 90 days" } };
|
||||
@@ -2116,7 +1516,7 @@ public class ReportsController : Controller
|
||||
var now = DateTime.UtcNow;
|
||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
|
||||
var allStatusHistory = await _context.JobStatusHistory.Include(h => h.FromStatus).Include(h => h.ToStatus).ToListAsync();
|
||||
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
|
||||
var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
|
||||
var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" };
|
||||
var statusTimings = new Dictionary<string, (string DisplayName, List<double> Days)>();
|
||||
@@ -2293,10 +1693,7 @@ public class ReportsController : Controller
|
||||
.Count(i => i.DueDate.HasValue && (today - i.DueDate.Value).TotalDays > 30);
|
||||
|
||||
// Expenses by account
|
||||
var allExpenses = await _context.Expenses
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Where(e => !e.IsDeleted)
|
||||
.ToListAsync();
|
||||
var allExpenses = await _operationalReports.GetAllExpensesAsync();
|
||||
var expensesByCategory = allExpenses
|
||||
.Where(e => e.ExpenseAccount != null)
|
||||
.GroupBy(e => e.ExpenseAccount!.Name)
|
||||
@@ -2306,7 +1703,7 @@ public class ReportsController : Controller
|
||||
var totalExpenses = allExpenses.Sum(e => e.Amount);
|
||||
|
||||
// Also include bills paid as expenses
|
||||
var allBills = await _context.Bills.Where(b => !b.IsDeleted).ToListAsync();
|
||||
var allBills = await _operationalReports.GetActiveBillsAsync();
|
||||
var billsPaid = allBills.Sum(b => b.AmountPaid);
|
||||
totalExpenses += billsPaid;
|
||||
|
||||
@@ -2400,11 +1797,9 @@ public class ReportsController : Controller
|
||||
}).ToList();
|
||||
|
||||
// Open AP bills
|
||||
var openBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => !b.IsDeleted && b.AmountPaid < b.Total
|
||||
&& b.Status != BillStatus.Voided)
|
||||
.ToListAsync();
|
||||
var openBills = (await _operationalReports.GetActiveBillsAsync())
|
||||
.Where(b => b.AmountPaid < b.Total)
|
||||
.ToList();
|
||||
|
||||
var apItems = openBills.Select(b => new CashFlowApItem
|
||||
{
|
||||
@@ -2415,13 +1810,11 @@ public class ReportsController : Controller
|
||||
}).ToList();
|
||||
|
||||
// Active job pipeline (non-terminal jobs not yet invoiced)
|
||||
var activeJobs = await _context.Jobs
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => !j.IsDeleted && j.JobStatus != null && !j.JobStatus.IsTerminalStatus)
|
||||
var activeJobs = (await _unitOfWork.Jobs.GetBoardJobsAsync())
|
||||
.Where(j => j.JobStatus != null && !j.JobStatus.IsTerminalStatus)
|
||||
.OrderByDescending(j => j.FinalPrice > 0 ? j.FinalPrice : j.QuotedPrice)
|
||||
.Take(30)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var jobItems = activeJobs.Select(j => new CashFlowJobItem
|
||||
{
|
||||
@@ -2484,12 +1877,9 @@ public class ReportsController : Controller
|
||||
var startOfThisMonth = new DateTime(today.Year, today.Month, 1);
|
||||
var startOfLastMonth = startOfThisMonth.AddMonths(-1);
|
||||
|
||||
// Recent bills (last 90 days)
|
||||
var recentBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => !b.IsDeleted && b.BillDate >= ninetyDaysAgo)
|
||||
.OrderByDescending(b => b.BillDate)
|
||||
.ToListAsync();
|
||||
// All active bills — used for both recent-bill candidates and all-time vendor history
|
||||
var allBills = await _operationalReports.GetActiveBillsAsync();
|
||||
var recentBills = allBills.Where(b => b.BillDate >= ninetyDaysAgo).OrderByDescending(b => b.BillDate).ToList();
|
||||
|
||||
var billSummaries = recentBills.Select(b => new AnomalyBillSummary
|
||||
{
|
||||
@@ -2501,12 +1891,6 @@ public class ReportsController : Controller
|
||||
VendorInvoiceNumber = b.VendorInvoiceNumber
|
||||
}).ToList();
|
||||
|
||||
// Vendor history (all time, for averages)
|
||||
var allBills = await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => !b.IsDeleted && b.Status != BillStatus.Voided)
|
||||
.ToListAsync();
|
||||
|
||||
var vendorHistory = allBills
|
||||
.Where(b => b.Vendor != null)
|
||||
.GroupBy(b => b.Vendor!.CompanyName)
|
||||
@@ -2525,10 +1909,7 @@ public class ReportsController : Controller
|
||||
}).ToList();
|
||||
|
||||
// Account spend trends (bills + expenses by account this month vs historical avg)
|
||||
var allExpenses = await _context.Expenses
|
||||
.Include(e => e.ExpenseAccount)
|
||||
.Where(e => !e.IsDeleted)
|
||||
.ToListAsync();
|
||||
var allExpenses = await _operationalReports.GetAllExpensesAsync();
|
||||
|
||||
var accountTrends = allExpenses
|
||||
.Where(e => e.ExpenseAccount != null)
|
||||
@@ -2581,7 +1962,7 @@ public class ReportsController : Controller
|
||||
var companyIdClaim = User.FindFirst("CompanyId")?.Value;
|
||||
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
|
||||
{
|
||||
var company = await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId);
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
return company?.CompanyName ?? "Your Company";
|
||||
}
|
||||
return "Your Company";
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// directly (bypassing the UoW) because it queries across company boundaries and
|
||||
/// joins plan configs — a pattern that would require multiple unrelated repositories.
|
||||
/// </summary>
|
||||
// Intentional exception: cross-tenant MRR/ARR metrics joining Company + SubscriptionPlanConfig; same pattern as CompanyHealthController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class RevenueController : Controller
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -20,7 +19,6 @@ public class SetupWizardController : Controller
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ISeedDataService _seedDataService;
|
||||
private readonly ILogger<SetupWizardController> _logger;
|
||||
|
||||
@@ -28,14 +26,12 @@ public class SetupWizardController : Controller
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ApplicationDbContext context,
|
||||
ISeedDataService seedDataService,
|
||||
ILogger<SetupWizardController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_context = context;
|
||||
_seedDataService = seedDataService;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -70,14 +66,14 @@ public class SetupWizardController : Controller
|
||||
if (company.Preferences == null)
|
||||
{
|
||||
company.Preferences = new CompanyPreferences { CompanyId = companyId };
|
||||
_context.Set<CompanyPreferences>().Add(company.Preferences);
|
||||
await _unitOfWork.CompanyPreferences.AddAsync(company.Preferences);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
if (company.OperatingCosts == null)
|
||||
{
|
||||
company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId };
|
||||
_context.Set<CompanyOperatingCosts>().Add(company.OperatingCosts);
|
||||
await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
@@ -315,31 +311,7 @@ public class SetupWizardController : Controller
|
||||
ShopCapabilityTier = costs.ShopCapabilityTier
|
||||
}),
|
||||
4 => await BuildStep4ViewAsync(GetCompanyId()),
|
||||
5 => View("Step5", new WizardStep3Dto
|
||||
{
|
||||
QuoteNumberPrefix = prefs.QuoteNumberPrefix,
|
||||
JobNumberPrefix = prefs.JobNumberPrefix,
|
||||
InvoiceNumberPrefix = !string.IsNullOrWhiteSpace(prefs.InvoiceNumberPrefix) ? prefs.InvoiceNumberPrefix : "INV",
|
||||
QtAccentColor = prefs.QtAccentColor,
|
||||
InAccentColor = prefs.InAccentColor,
|
||||
WoAccentColor = prefs.WoAccentColor
|
||||
}),
|
||||
6 => View("Step6", new WizardStep5Dto
|
||||
{
|
||||
DefaultJobPriority = prefs.DefaultJobPriority,
|
||||
RequireCustomerPO = prefs.RequireCustomerPO,
|
||||
AllowCustomerApproval = prefs.AllowCustomerApproval,
|
||||
}),
|
||||
7 => View("Step7", new WizardStep4Dto
|
||||
{
|
||||
DefaultPaymentTerms = prefs.DefaultPaymentTerms,
|
||||
DefaultQuoteValidityDays = prefs.DefaultQuoteValidityDays,
|
||||
DefaultTurnaroundDays = prefs.DefaultTurnaroundDays,
|
||||
QtDefaultTerms = prefs.QtDefaultTerms,
|
||||
QtFooterNote = prefs.QtFooterNote
|
||||
}),
|
||||
8 => await BuildStep8ViewAsync(GetCompanyId()),
|
||||
9 => View("Step9", new WizardStep7Dto
|
||||
5 => View("Step9", new WizardStep7Dto
|
||||
{
|
||||
EmailNotificationsEnabled = prefs.EmailNotificationsEnabled,
|
||||
EmailFromAddress = prefs.EmailFromAddress,
|
||||
@@ -355,7 +327,6 @@ public class SetupWizardController : Controller
|
||||
DueDateWarningDays = prefs.DueDateWarningDays,
|
||||
MaintenanceAlertDays = prefs.MaintenanceAlertDays
|
||||
}),
|
||||
10 => await BuildStep10ViewAsync(GetCompanyId()),
|
||||
_ => RedirectToAction("Step", new { step = 1 })
|
||||
};
|
||||
}
|
||||
@@ -409,53 +380,6 @@ public class SetupWizardController : Controller
|
||||
return View("Step4", dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the view model for Step 8 (Pricing Tiers) by loading existing tiers and serializing
|
||||
/// them as camelCase JSON for the client-side tier management table.
|
||||
/// CamelCase serialization is required here because the JavaScript that reads this JSON expects
|
||||
/// camelCase property names (e.g., <c>tierName</c> not <c>TierName</c>), unlike the oven step
|
||||
/// which uses PascalCase — a discrepancy inherited from different JS widget implementations.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> BuildStep8ViewAsync(int companyId)
|
||||
{
|
||||
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && !t.IsDeleted);
|
||||
var camelCase = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
var dto = new WizardPricingTiersStepDto
|
||||
{
|
||||
TiersJson = existing.Any()
|
||||
? JsonSerializer.Serialize(existing.OrderBy(t => t.Id).Select(t => new WizardPricingTierDto
|
||||
{
|
||||
Id = t.Id,
|
||||
TierName = t.TierName,
|
||||
Description = t.Description,
|
||||
DiscountPercent = t.DiscountPercent
|
||||
}), camelCase)
|
||||
: null
|
||||
};
|
||||
return View("Step8", dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the view model for Step 10 (Team Members) by loading existing non-admin users so they
|
||||
/// can be displayed as read-only in the view.
|
||||
/// Only non-admin company users are shown because the wizard's team-member step is designed for
|
||||
/// adding shop workers and managers; the CompanyAdmin who is running the wizard is already
|
||||
/// implied. Showing existing members prevents the wizard user from accidentally creating
|
||||
/// duplicates of accounts that were added outside the wizard flow.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> BuildStep10ViewAsync(int companyId)
|
||||
{
|
||||
// Load existing non-admin team members so they're shown as read-only in the view
|
||||
var existingUsers = await _userManager.Users
|
||||
.Where(u => u.CompanyId == companyId && u.IsActive
|
||||
&& u.CompanyRole != AppConstants.CompanyRoles.CompanyAdmin)
|
||||
.OrderBy(u => u.LastName).ThenBy(u => u.FirstName)
|
||||
.Select(u => new { u.FirstName, u.LastName, u.Email, u.CompanyRole })
|
||||
.ToListAsync();
|
||||
ViewBag.ExistingTeamMembers = existingUsers;
|
||||
return View("Step10", new WizardStep9Dto());
|
||||
}
|
||||
|
||||
// ─── POST Steps ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -679,138 +603,15 @@ public class SetupWizardController : Controller
|
||||
return RedirectToStep(5);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostStep5(WizardStep3Dto model)
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 5;
|
||||
|
||||
if (!ModelState.IsValid) return View("Step5", model);
|
||||
|
||||
prefs.QuoteNumberPrefix = model.QuoteNumberPrefix;
|
||||
prefs.JobNumberPrefix = model.JobNumberPrefix;
|
||||
prefs.InvoiceNumberPrefix = model.InvoiceNumberPrefix;
|
||||
prefs.QtAccentColor = model.QtAccentColor;
|
||||
prefs.InAccentColor = model.InAccentColor;
|
||||
prefs.WoAccentColor = model.WoAccentColor;
|
||||
|
||||
MarkDone(prefs, 5);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToStep(6);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostStep6(WizardStep5Dto model)
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 6;
|
||||
|
||||
if (!ModelState.IsValid) return View("Step6", model);
|
||||
|
||||
prefs.DefaultJobPriority = model.DefaultJobPriority;
|
||||
prefs.RequireCustomerPO = model.RequireCustomerPO;
|
||||
prefs.AllowCustomerApproval = model.AllowCustomerApproval;
|
||||
|
||||
MarkDone(prefs, 6);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToStep(7);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostStep7(WizardStep4Dto model)
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 7;
|
||||
|
||||
if (!ModelState.IsValid) return View("Step7", model);
|
||||
|
||||
prefs.DefaultPaymentTerms = model.DefaultPaymentTerms;
|
||||
prefs.DefaultQuoteValidityDays = model.DefaultQuoteValidityDays;
|
||||
prefs.DefaultTurnaroundDays = model.DefaultTurnaroundDays;
|
||||
prefs.QtDefaultTerms = model.QtDefaultTerms;
|
||||
prefs.QtFooterNote = model.QtFooterNote;
|
||||
|
||||
MarkDone(prefs, 7);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToStep(8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists pricing tiers from Step 8, using the same upsert-and-soft-delete pattern as
|
||||
/// <see cref="PostStep4"/>: existing tiers updated in place, new ones inserted, removed ones
|
||||
/// soft-deleted. Tiers with a blank <c>TierName</c> are silently ignored so the client-side
|
||||
/// table's empty placeholder rows do not produce invalid records. JsonException is caught and
|
||||
/// logged rather than thrown so a malformed JSON payload (e.g., from a broken browser extension)
|
||||
/// still advances the wizard rather than stopping the admin from completing setup.
|
||||
/// Saves notification preferences from Step 5 (the final step). Marks the wizard complete
|
||||
/// and hands off to the Guided Activation flow.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostStep8(WizardPricingTiersStepDto model)
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
var companyId = GetCompanyId();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(model.TiersJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var tiers = JsonSerializer.Deserialize<List<WizardPricingTierDto>>(model.TiersJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
if (tiers != null)
|
||||
{
|
||||
var validTiers = tiers.Where(t => !string.IsNullOrWhiteSpace(t.TierName)).ToList();
|
||||
if (validTiers.Count > 0)
|
||||
{
|
||||
var existing = (await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && !t.IsDeleted))
|
||||
.ToDictionary(t => t.Id);
|
||||
var submittedIds = validTiers.Where(t => t.Id > 0).Select(t => t.Id).ToHashSet();
|
||||
|
||||
// Soft-delete tiers that were removed from the list
|
||||
foreach (var e in existing.Values.Where(e => !submittedIds.Contains(e.Id)))
|
||||
await _unitOfWork.PricingTiers.SoftDeleteAsync(e.Id);
|
||||
|
||||
foreach (var t in validTiers)
|
||||
{
|
||||
if (t.Id > 0 && existing.TryGetValue(t.Id, out var record))
|
||||
{
|
||||
// Update in place
|
||||
record.TierName = t.TierName.Trim();
|
||||
record.Description = t.Description?.Trim();
|
||||
record.DiscountPercent = t.DiscountPercent;
|
||||
await _unitOfWork.PricingTiers.UpdateAsync(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _unitOfWork.PricingTiers.AddAsync(new PricingTier
|
||||
{
|
||||
CompanyId = companyId,
|
||||
TierName = t.TierName.Trim(),
|
||||
Description = t.Description?.Trim(),
|
||||
DiscountPercent = t.DiscountPercent,
|
||||
IsActive = true
|
||||
});
|
||||
}
|
||||
}
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize pricing tiers JSON in wizard step 8");
|
||||
}
|
||||
}
|
||||
|
||||
MarkDone(prefs, 8);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToStep(9);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostStep9(WizardStep7Dto model)
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 9;
|
||||
ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 5;
|
||||
|
||||
if (!ModelState.IsValid) return View("Step9", model);
|
||||
|
||||
@@ -828,83 +629,9 @@ public class SetupWizardController : Controller
|
||||
prefs.DueDateWarningDays = model.DueDateWarningDays;
|
||||
prefs.MaintenanceAlertDays = model.MaintenanceAlertDays;
|
||||
|
||||
MarkDone(prefs, 9);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToStep(10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates team member accounts from Step 10 (Invite Team), assigns each user a company role,
|
||||
/// and also maps them to the legacy ASP.NET Identity role system for policy-based authorization.
|
||||
/// The dual-role assignment (CompanyRole + Identity role) is required because authorization
|
||||
/// policies in this app evaluate both the legacy role claim and the <c>CompanyRole</c> property.
|
||||
/// Users with emails that already exist are silently skipped so re-submitting the wizard after
|
||||
/// a partial failure does not attempt to create duplicates. Setting <c>SetupWizardCompleted = true</c>
|
||||
/// here hides the wizard prompt from the dashboard going forward.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostStep10(WizardStep9Dto model)
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
var companyId = GetCompanyId();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(model.MembersJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var members = JsonSerializer.Deserialize<List<WizardTeamMemberDto>>(model.MembersJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
if (members != null)
|
||||
{
|
||||
foreach (var m in members.Where(m => !string.IsNullOrWhiteSpace(m.Email)
|
||||
&& !string.IsNullOrWhiteSpace(m.Password)))
|
||||
{
|
||||
var existing = await _userManager.FindByEmailAsync(m.Email);
|
||||
if (existing != null) continue;
|
||||
|
||||
var validRoles = new[] { AppConstants.CompanyRoles.CompanyAdmin, AppConstants.CompanyRoles.Manager, AppConstants.CompanyRoles.Worker, AppConstants.CompanyRoles.Viewer };
|
||||
var companyRole = validRoles.Contains(m.CompanyRole) ? m.CompanyRole : AppConstants.CompanyRoles.Worker;
|
||||
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = m.Email, Email = m.Email, EmailConfirmed = true,
|
||||
FirstName = m.FirstName, LastName = m.LastName,
|
||||
CompanyId = companyId, CompanyRole = companyRole, IsActive = true,
|
||||
CanManageJobs = true, CanManageCustomers = true, CanCreateQuotes = true,
|
||||
CanManageCalendar = true, CanViewCalendar = true, CanViewProducts = true
|
||||
};
|
||||
|
||||
var result = await _userManager.CreateAsync(user, m.Password);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
var legacyRole = companyRole switch
|
||||
{
|
||||
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
|
||||
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
|
||||
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
|
||||
_ => AppConstants.Roles.ReadOnly
|
||||
};
|
||||
await _userManager.AddToRoleAsync(user, legacyRole);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to create wizard user {Email}: {Errors}",
|
||||
m.Email, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize team members JSON in wizard step 10");
|
||||
}
|
||||
}
|
||||
|
||||
MarkDone(prefs, 10);
|
||||
MarkDone(prefs, 5);
|
||||
prefs.SetupWizardCompleted = true;
|
||||
|
||||
// Record who completed the wizard and when so SuperAdmins can see completion status per-user.
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
prefs.SetupWizardCompletedAt = DateTime.UtcNow;
|
||||
prefs.SetupWizardCompletedByUserId = currentUser?.Id;
|
||||
@@ -913,7 +640,7 @@ public class SetupWizardController : Controller
|
||||
: User.Identity?.Name;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Complete));
|
||||
return RedirectToAction("Start", "GuidedActivation");
|
||||
}
|
||||
|
||||
// ─── Skip ─────────────────────────────────────────────────────────────────
|
||||
@@ -931,6 +658,18 @@ public class SetupWizardController : Controller
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
MarkSkipped(prefs, step);
|
||||
|
||||
if (step >= WizardProgressDto.TotalSteps)
|
||||
{
|
||||
prefs.SetupWizardCompleted = true;
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
prefs.SetupWizardCompletedAt = DateTime.UtcNow;
|
||||
prefs.SetupWizardCompletedByUserId = currentUser?.Id;
|
||||
prefs.SetupWizardCompletedByName = currentUser != null
|
||||
? $"{currentUser.FirstName} {currentUser.LastName}".Trim()
|
||||
: User.Identity?.Name;
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
int next = step >= WizardProgressDto.TotalSteps ? 0 : step + 1;
|
||||
return RedirectToStep(next == 0 ? WizardProgressDto.TotalSteps + 1 : next);
|
||||
@@ -979,6 +718,7 @@ public class SetupWizardController : Controller
|
||||
{
|
||||
var (_, prefs, _) = await LoadCompanyDataAsync();
|
||||
ViewBag.Progress = BuildProgress(prefs);
|
||||
ViewBag.ShowGuidedActivationCta = prefs.SetupWizardCompleted && !prefs.FirstWorkflowCompleted;
|
||||
return View();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.Text;
|
||||
|
||||
@@ -15,12 +14,12 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class SmsConsentAuditController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<SmsConsentAuditController> _logger;
|
||||
|
||||
public SmsConsentAuditController(ApplicationDbContext context, ILogger<SmsConsentAuditController> logger)
|
||||
public SmsConsentAuditController(IUnitOfWork unitOfWork, ILogger<SmsConsentAuditController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -31,14 +30,12 @@ public class SmsConsentAuditController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = _context.Customers
|
||||
.AsNoTracking()
|
||||
.Where(c => !c.IsDeleted);
|
||||
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var s = search.Trim().ToLower();
|
||||
query = query.Where(c =>
|
||||
allCustomers = allCustomers.Where(c =>
|
||||
(c.ContactFirstName != null && c.ContactFirstName.ToLower().Contains(s)) ||
|
||||
(c.ContactLastName != null && c.ContactLastName.ToLower().Contains(s)) ||
|
||||
(c.CompanyName != null && c.CompanyName.ToLower().Contains(s)) ||
|
||||
@@ -46,43 +43,25 @@ public class SmsConsentAuditController : Controller
|
||||
(c.Phone != null && c.Phone.Contains(s)));
|
||||
}
|
||||
|
||||
var customers = await query
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.CompanyName,
|
||||
c.ContactFirstName,
|
||||
c.ContactLastName,
|
||||
c.IsCommercial,
|
||||
c.Phone,
|
||||
c.MobilePhone,
|
||||
c.NotifyBySms,
|
||||
c.SmsConsentedAt,
|
||||
c.SmsConsentMethod,
|
||||
c.SmsOptedOutAt
|
||||
})
|
||||
var allRows = allCustomers
|
||||
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
|
||||
.ToListAsync();
|
||||
.Select(c => new SmsConsentRow
|
||||
{
|
||||
CustomerId = c.Id,
|
||||
CustomerName = GetDisplayName(c.IsCommercial, c.CompanyName, c.ContactFirstName, c.ContactLastName),
|
||||
Phone = c.Phone,
|
||||
MobilePhone = c.MobilePhone,
|
||||
NotifyBySms = c.NotifyBySms,
|
||||
ConsentedAt = c.SmsConsentedAt,
|
||||
ConsentMethod = c.SmsConsentMethod,
|
||||
OptedOutAt = c.SmsOptedOutAt,
|
||||
SmsStatus = ResolveSmsStatus(c.NotifyBySms, c.SmsConsentedAt, c.SmsOptedOutAt)
|
||||
}).ToList();
|
||||
|
||||
var allRows = customers.Select(c => new SmsConsentRow
|
||||
{
|
||||
CustomerId = c.Id,
|
||||
CustomerName = GetDisplayName(c.IsCommercial, c.CompanyName, c.ContactFirstName, c.ContactLastName),
|
||||
Phone = c.Phone,
|
||||
MobilePhone = c.MobilePhone,
|
||||
NotifyBySms = c.NotifyBySms,
|
||||
ConsentedAt = c.SmsConsentedAt,
|
||||
ConsentMethod = c.SmsConsentMethod,
|
||||
OptedOutAt = c.SmsOptedOutAt,
|
||||
SmsStatus = ResolveSmsStatus(c.NotifyBySms, c.SmsConsentedAt, c.SmsOptedOutAt)
|
||||
}).ToList();
|
||||
var optedIn = allRows.Count(r => r.SmsStatus == "active");
|
||||
var optedOut = allRows.Count(r => r.SmsStatus == "opted-out");
|
||||
var never = allRows.Count(r => r.SmsStatus == "never");
|
||||
|
||||
// Stat counts across unfiltered set
|
||||
var optedIn = allRows.Count(r => r.SmsStatus == "active");
|
||||
var optedOut = allRows.Count(r => r.SmsStatus == "opted-out");
|
||||
var never = allRows.Count(r => r.SmsStatus == "never");
|
||||
|
||||
// Apply filter
|
||||
var filtered = filter switch
|
||||
{
|
||||
"opted-in" => allRows.Where(r => r.SmsStatus == "active").ToList(),
|
||||
@@ -119,25 +98,9 @@ public class SmsConsentAuditController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var customers = await _context.Customers
|
||||
.AsNoTracking()
|
||||
.Where(c => !c.IsDeleted)
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.CompanyName,
|
||||
c.ContactFirstName,
|
||||
c.ContactLastName,
|
||||
c.IsCommercial,
|
||||
c.Phone,
|
||||
c.MobilePhone,
|
||||
c.NotifyBySms,
|
||||
c.SmsConsentedAt,
|
||||
c.SmsConsentMethod,
|
||||
c.SmsOptedOutAt
|
||||
})
|
||||
var customers = (await _unitOfWork.Customers.GetAllAsync())
|
||||
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Customer Name,Phone,Mobile Phone,SMS Status,Consented At (UTC),Consent Method,Opted Out At (UTC)");
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// delivery, and view the raw JSON payload Stripe sent. Restricted to SuperAdmin because the raw
|
||||
/// event payloads may contain sensitive subscription and billing information.
|
||||
/// </summary>
|
||||
// Intentional exception: StripeWebhookEvents is a platform infrastructure table (not a business entity); same reasoning as StripeWebhookController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class StripeEventsController : Controller
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// because subscription management is a platform-level concern, not a tenant-domain concern,
|
||||
/// and requires direct access to the Company entity which lives outside the tenant data layer.
|
||||
/// </summary>
|
||||
// Intentional exception: cross-tenant Company management with raw SQL audit log writes that bypass the tenant pipeline; platform-level concern outside IUnitOfWork scope. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class SubscriptionManagementController : Controller
|
||||
{
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -15,12 +14,12 @@ namespace PowderCoating.Web.Controllers;
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Public)]
|
||||
public class UnsubscribeController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<UnsubscribeController> _logger;
|
||||
|
||||
public UnsubscribeController(ApplicationDbContext context, ILogger<UnsubscribeController> logger)
|
||||
public UnsubscribeController(IUnitOfWork unitOfWork, ILogger<UnsubscribeController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -38,11 +37,10 @@ public class UnsubscribeController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
// Bypass global query filters so we can find the customer by token
|
||||
// ignoreQueryFilters=true so we can find the customer by token
|
||||
// regardless of company context (the user clicking is not authenticated)
|
||||
var customer = await _context.Customers
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.UnsubscribeToken == token && !c.IsDeleted);
|
||||
var customer = await _unitOfWork.Customers.FirstOrDefaultAsync(
|
||||
c => c.UnsubscribeToken == token && !c.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
if (customer == null)
|
||||
{
|
||||
@@ -52,7 +50,6 @@ public class UnsubscribeController : Controller
|
||||
|
||||
if (!customer.NotifyByEmail)
|
||||
{
|
||||
// Already unsubscribed — show success page anyway (idempotent)
|
||||
ViewBag.CustomerName = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
|
||||
ViewBag.AlreadyUnsubscribed = true;
|
||||
return View("EmailConfirm");
|
||||
@@ -60,7 +57,7 @@ public class UnsubscribeController : Controller
|
||||
|
||||
customer.NotifyByEmail = false;
|
||||
customer.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Customer {CustomerId} unsubscribed from email notifications via link", customer.Id);
|
||||
|
||||
@@ -88,9 +85,8 @@ public class UnsubscribeController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
var company = await _context.Companies
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.MarketingUnsubscribeToken == token && !c.IsDeleted);
|
||||
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
|
||||
c => c.MarketingUnsubscribeToken == token && !c.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
if (company == null)
|
||||
{
|
||||
@@ -105,7 +101,7 @@ public class UnsubscribeController : Controller
|
||||
{
|
||||
company.MarketingEmailOptOut = true;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
_logger.LogInformation("Company {CompanyId} opted out of broadcast emails via link", company.Id);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// efficient bulk-count GROUP BY queries in parallel rather than loading
|
||||
/// each company's data through separate repository calls.
|
||||
/// </summary>
|
||||
// Intentional exception: cross-tenant bulk GROUP BY quota queries that would require O(n) repository round-trips if routed through IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class UsageQuotaController : Controller
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Text;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
// Intentional exception: queries ASP.NET Identity ApplicationUser across all tenants with Include(u => u.Company); Identity entities live outside IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class UserActivityController : Controller
|
||||
{
|
||||
|
||||
@@ -10,7 +10,6 @@ using PowderCoating.Application.DTOs.Vendor;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -28,20 +27,17 @@ public class VendorsController : Controller
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<VendorsController> _logger;
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public VendorsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMapper mapper,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<VendorsController> logger,
|
||||
ApplicationDbContext context)
|
||||
ILogger<VendorsController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -163,7 +159,7 @@ public class VendorsController : Controller
|
||||
var vendorDto = _mapper.Map<VendorDto>(vendor);
|
||||
if (vendor.DefaultExpenseAccountId.HasValue)
|
||||
{
|
||||
var acct = await _context.Accounts.FindAsync(vendor.DefaultExpenseAccountId.Value);
|
||||
var acct = await _unitOfWork.Accounts.GetByIdAsync(vendor.DefaultExpenseAccountId.Value);
|
||||
vendorDto.DefaultExpenseAccountName = acct != null ? $"{acct.AccountNumber} – {acct.Name}" : null;
|
||||
}
|
||||
return View(vendorDto);
|
||||
@@ -395,14 +391,13 @@ public class VendorsController : Controller
|
||||
/// </summary>
|
||||
private async Task PopulateExpenseAccountsAsync()
|
||||
{
|
||||
var accounts = await _context.Accounts
|
||||
.Where(a => !a.IsDeleted && a.IsActive &&
|
||||
(a.AccountType == AccountType.Expense ||
|
||||
a.AccountType == AccountType.CostOfGoods ||
|
||||
a.AccountType == AccountType.Asset))
|
||||
var accounts = (await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive && (a.AccountType == AccountType.Expense ||
|
||||
a.AccountType == AccountType.CostOfGoods ||
|
||||
a.AccountType == AccountType.Asset)))
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
accounts.Insert(0, new SelectListItem("— None —", ""));
|
||||
ViewBag.ExpenseAccounts = accounts;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user