Merge dev: powder usage logging, time entry fix, Data Protection keys
- Fix time entry 500: parseInt destroyed GUID user IDs - Inventory ledger: show edit pencil for Adjustment rows (scan-without-job) - Inventory ledger: scan-based logs now appear in Powder Usage By Job tab - Store Data Protection keys in SQL Server (non-production); migration AddDataProtectionKeys - Fix mojibake characters across multiple views - Fix subscription grace period tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -258,6 +258,8 @@ public class PowderUsageLogDto
|
|||||||
public decimal VarianceLbs { get; set; }
|
public decimal VarianceLbs { get; set; }
|
||||||
public DateTime RecordedAt { get; set; }
|
public DateTime RecordedAt { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
/// <summary>Set only for rows synthesized from a scan-based InventoryTransaction (no PowderUsageLog record).</summary>
|
||||||
|
public int? SourceTransactionId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class InventoryLedgerViewModel
|
public class InventoryLedgerViewModel
|
||||||
|
|||||||
@@ -404,9 +404,8 @@ public class JobTimeEntryDto
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
public int ShopWorkerId { get; set; }
|
public string? UserId { get; set; }
|
||||||
public string WorkerName { get; set; } = string.Empty;
|
public string WorkerName { get; set; } = string.Empty;
|
||||||
public string WorkerRole { get; set; } = string.Empty;
|
|
||||||
public DateTime WorkDate { get; set; }
|
public DateTime WorkDate { get; set; }
|
||||||
public decimal HoursWorked { get; set; }
|
public decimal HoursWorked { get; set; }
|
||||||
public string? Stage { get; set; }
|
public string? Stage { get; set; }
|
||||||
@@ -417,7 +416,7 @@ public class JobTimeEntryDto
|
|||||||
public class CreateJobTimeEntryDto
|
public class CreateJobTimeEntryDto
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
public int ShopWorkerId { get; set; }
|
public string UserId { get; set; } = string.Empty;
|
||||||
public DateTime WorkDate { get; set; }
|
public DateTime WorkDate { get; set; }
|
||||||
public decimal HoursWorked { get; set; }
|
public decimal HoursWorked { get; set; }
|
||||||
public string? Stage { get; set; }
|
public string? Stage { get; set; }
|
||||||
@@ -427,7 +426,7 @@ public class CreateJobTimeEntryDto
|
|||||||
public class UpdateJobTimeEntryDto
|
public class UpdateJobTimeEntryDto
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int ShopWorkerId { get; set; }
|
public string UserId { get; set; } = string.Empty;
|
||||||
public DateTime WorkDate { get; set; }
|
public DateTime WorkDate { get; set; }
|
||||||
public decimal HoursWorked { get; set; }
|
public decimal HoursWorked { get; set; }
|
||||||
public string? Stage { get; set; }
|
public string? Stage { get; set; }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public interface IPlatformSettingsService
|
|||||||
{
|
{
|
||||||
Task<string?> GetAsync(string key);
|
Task<string?> GetAsync(string key);
|
||||||
Task<bool> GetBoolAsync(string key, bool defaultValue = false);
|
Task<bool> GetBoolAsync(string key, bool defaultValue = false);
|
||||||
|
Task<int> GetIntAsync(string key, int defaultValue);
|
||||||
Task SetAsync(string key, string? value, string? updatedBy = null);
|
Task SetAsync(string key, string? value, string? updatedBy = null);
|
||||||
Task<IReadOnlyList<PlatformSetting>> GetAllAsync();
|
Task<IReadOnlyList<PlatformSetting>> GetAllAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ public class JobProfile : Profile
|
|||||||
|
|
||||||
// JobTimeEntry → JobTimeEntryDto
|
// JobTimeEntry → JobTimeEntryDto
|
||||||
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
||||||
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src => src.Worker != null ? src.Worker.Name : string.Empty))
|
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
|
||||||
.ForMember(dest => dest.WorkerRole, opt => opt.MapFrom(src => src.Worker != null ? FormatEnumName(src.Worker.Role.ToString()) : string.Empty));
|
src.UserDisplayName ?? (src.Worker != null ? src.Worker.Name : string.Empty)));
|
||||||
|
|
||||||
// CreateJobDto to Job
|
// CreateJobDto to Job
|
||||||
CreateMap<CreateJobDto, Job>()
|
CreateMap<CreateJobDto, Job>()
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ namespace PowderCoating.Core.Entities;
|
|||||||
public class JobTimeEntry : BaseEntity
|
public class JobTimeEntry : BaseEntity
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
public int ShopWorkerId { get; set; }
|
public int? ShopWorkerId { get; set; } // legacy — kept for entries created before user migration
|
||||||
|
public string? UserId { get; set; } // FK to AspNetUsers
|
||||||
|
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
|
||||||
public DateTime WorkDate { get; set; }
|
public DateTime WorkDate { get; set; }
|
||||||
public decimal HoursWorked { get; set; }
|
public decimal HoursWorked { get; set; }
|
||||||
public string? Stage { get; set; } // e.g. "Sandblasting", "Coating", "Masking" — free text
|
public string? Stage { get; set; } // e.g. "Sandblasting", "Coating", "Masking" — free text
|
||||||
@@ -11,5 +13,5 @@ public class JobTimeEntry : BaseEntity
|
|||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public virtual Job Job { get; set; } = null!;
|
public virtual Job Job { get; set; } = null!;
|
||||||
public virtual ShopWorker Worker { get; set; } = null!;
|
public virtual ShopWorker? Worker { get; set; } // nullable — only populated for legacy entries
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,6 @@ public static class PlatformSettingKeys
|
|||||||
public const string MaxTenants = "MaxTenants";
|
public const string MaxTenants = "MaxTenants";
|
||||||
public const string SmsEnabled = "SmsEnabled";
|
public const string SmsEnabled = "SmsEnabled";
|
||||||
public const string AiCatalogPriceCheckEnabled = "AiCatalogPriceCheckEnabled";
|
public const string AiCatalogPriceCheckEnabled = "AiCatalogPriceCheckEnabled";
|
||||||
|
public const string GracePeriodDays = "GracePeriodDays";
|
||||||
|
public const string GracePeriodAppliesToTrials = "GracePeriodAppliesToTrials";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -25,7 +26,7 @@ namespace PowderCoating.Infrastructure.Data;
|
|||||||
/// to repository methods) — reserved for SuperAdmin operations and document-number generation.
|
/// to repository methods) — reserved for SuperAdmin operations and document-number generation.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataProtectionKeyContext
|
||||||
{
|
{
|
||||||
private readonly IHttpContextAccessor? _httpContextAccessor;
|
private readonly IHttpContextAccessor? _httpContextAccessor;
|
||||||
private readonly IServiceProvider? _serviceProvider;
|
private readonly IServiceProvider? _serviceProvider;
|
||||||
@@ -371,6 +372,12 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<PlatformSetting> PlatformSettings { get; set; }
|
public DbSet<PlatformSetting> PlatformSettings { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ASP.NET Core Data Protection key ring — required by <see cref="IDataProtectionKeyContext"/>.
|
||||||
|
/// Keys stored here survive deploys and IIS app pool recycles.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// IP address ban list. Login attempts from a matching active entry are rejected
|
/// IP address ban list. Login attempts from a matching active entry are rejected
|
||||||
/// before Identity even checks credentials. No tenant filter; SuperAdmin-managed only.
|
/// before Identity even checks credentials. No tenant filter; SuperAdmin-managed only.
|
||||||
|
|||||||
Generated
+9496
File diff suppressed because it is too large
Load Diff
+73
@@ -0,0 +1,73 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGracePeriodDaysSetting : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM [PlatformSettings] WHERE [Key] = N'GracePeriodDays')
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO [PlatformSettings] ([Key], [Value], [Label], [Description], [GroupName])
|
||||||
|
VALUES (N'GracePeriodDays', N'14', N'Grace Period (days)',
|
||||||
|
N'Number of days a company can continue to log in after their subscription expires before being fully locked out.',
|
||||||
|
N'Subscriptions');
|
||||||
|
END
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8281));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8287));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8289));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("DELETE FROM [PlatformSettings] WHERE [Key] = N'GracePeriodDays'");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5184));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5189));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5191));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
src/PowderCoating.Infrastructure/Migrations/20260505191333_AddGracePeriodAppliesToTrials.Designer.cs
Generated
+9496
File diff suppressed because it is too large
Load Diff
+73
@@ -0,0 +1,73 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGracePeriodAppliesToTrials : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM [PlatformSettings] WHERE [Key] = N'GracePeriodAppliesToTrials')
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO [PlatformSettings] ([Key], [Value], [Label], [Description], [GroupName])
|
||||||
|
VALUES (N'GracePeriodAppliesToTrials', N'false', N'Grace Period Applies to Trials',
|
||||||
|
N'When false (default), trial accounts (no Stripe subscription) are locked out immediately when their trial expires — no grace period. Enable to give trial accounts the same grace period as paid accounts.',
|
||||||
|
N'Subscriptions');
|
||||||
|
END
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4011));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4019));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4021));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("DELETE FROM [PlatformSettings] WHERE [Key] = N'GracePeriodAppliesToTrials'");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8281));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8287));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8289));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+9500
File diff suppressed because it is too large
Load Diff
+122
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MigrateTimeEntriesToUserId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
|
||||||
|
table: "JobTimeEntries");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "ShopWorkerId",
|
||||||
|
table: "JobTimeEntries",
|
||||||
|
type: "int",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "int");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "UserDisplayName",
|
||||||
|
table: "JobTimeEntries",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "UserId",
|
||||||
|
table: "JobTimeEntries",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8603));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8610));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8612));
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
|
||||||
|
table: "JobTimeEntries",
|
||||||
|
column: "ShopWorkerId",
|
||||||
|
principalTable: "ShopWorkers",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
|
||||||
|
table: "JobTimeEntries");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UserDisplayName",
|
||||||
|
table: "JobTimeEntries");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UserId",
|
||||||
|
table: "JobTimeEntries");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "ShopWorkerId",
|
||||||
|
table: "JobTimeEntries",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "int",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4011));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4019));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4021));
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
|
||||||
|
table: "JobTimeEntries",
|
||||||
|
column: "ShopWorkerId",
|
||||||
|
principalTable: "ShopWorkers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+9519
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDataProtectionKeys : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DataProtectionKeys",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
FriendlyName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
Xml = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2199));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2206));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2208));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DataProtectionKeys");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8603));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8610));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8612));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,25 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("FriendlyName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Xml")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("DataProtectionKeys");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -5000,7 +5019,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int>("ShopWorkerId")
|
b.Property<int?>("ShopWorkerId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("Stage")
|
b.Property<string>("Stage")
|
||||||
@@ -5012,6 +5031,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("UpdatedBy")
|
b.Property<string>("UpdatedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("UserDisplayName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<DateTime>("WorkDate")
|
b.Property<DateTime>("WorkDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -6028,7 +6053,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5184),
|
CreatedAt = new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2199),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6039,7 +6064,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5189),
|
CreatedAt = new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2206),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6050,7 +6075,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5191),
|
CreatedAt = new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2208),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -8694,9 +8719,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
|
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
|
||||||
.WithMany("TimeEntries")
|
.WithMany("TimeEntries")
|
||||||
.HasForeignKey("ShopWorkerId")
|
.HasForeignKey("ShopWorkerId");
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Job");
|
b.Navigation("Job");
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Anthropic.SDK" Version="4.0.0" />
|
<PackageReference Include="Anthropic.SDK" Version="4.0.0" />
|
||||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class InventoryTransactionRepository : Repository<InventoryTransaction>,
|
|||||||
.Include(t => t.InventoryItem)
|
.Include(t => t.InventoryItem)
|
||||||
.Include(t => t.PurchaseOrder)
|
.Include(t => t.PurchaseOrder)
|
||||||
.Include(t => t.Job)
|
.Include(t => t.Job)
|
||||||
|
.ThenInclude(j => j!.Customer)
|
||||||
.Where(t => !t.IsDeleted);
|
.Where(t => !t.IsDeleted);
|
||||||
|
|
||||||
if (itemId.HasValue)
|
if (itemId.HasValue)
|
||||||
|
|||||||
@@ -834,6 +834,10 @@ Rules:
|
|||||||
// machine-readable price, SKU, and product info that would otherwise be lost.
|
// machine-readable price, SKU, and product info that would otherwise be lost.
|
||||||
var structuredData = ExtractJsonLdData(html);
|
var structuredData = ExtractJsonLdData(html);
|
||||||
|
|
||||||
|
// Extract visible price text BEFORE stripping HTML as a fallback when
|
||||||
|
// JSON-LD is absent or incomplete (e.g. JS-rendered stores).
|
||||||
|
var htmlPriceSnippet = ExtractHtmlPriceSnippet(html);
|
||||||
|
|
||||||
// Remove script/style blocks
|
// Remove script/style blocks
|
||||||
html = System.Text.RegularExpressions.Regex.Replace(
|
html = System.Text.RegularExpressions.Regex.Replace(
|
||||||
html, @"<(script|style)[^>]*>[\s\S]*?</(script|style)>", "",
|
html, @"<(script|style)[^>]*>[\s\S]*?</(script|style)>", "",
|
||||||
@@ -851,10 +855,11 @@ Rules:
|
|||||||
if (text.Length > maxChars)
|
if (text.Length > maxChars)
|
||||||
text = text[..maxChars] + "…";
|
text = text[..maxChars] + "…";
|
||||||
|
|
||||||
// Prepend structured data + document links — Claude treats these as high-confidence
|
// Prepend structured data + document links + price fallback — Claude treats these as high-confidence
|
||||||
var header = new StringBuilder();
|
var header = new StringBuilder();
|
||||||
if (!string.IsNullOrWhiteSpace(structuredData)) header.Append(structuredData);
|
if (!string.IsNullOrWhiteSpace(structuredData)) header.Append(structuredData);
|
||||||
if (!string.IsNullOrWhiteSpace(docLinks)) header.Append(docLinks);
|
if (!string.IsNullOrWhiteSpace(htmlPriceSnippet)) header.Append(htmlPriceSnippet);
|
||||||
|
if (!string.IsNullOrWhiteSpace(docLinks)) header.Append(docLinks);
|
||||||
if (header.Length > 0) text = header + "\n" + text;
|
if (header.Length > 0) text = header + "\n" + text;
|
||||||
|
|
||||||
_logger.LogInformation("Fetched {Chars} chars from {Url} (structured data: {HasData}, image: {HasImage})",
|
_logger.LogInformation("Fetched {Chars} chars from {Url} (structured data: {HasData}, image: {HasImage})",
|
||||||
@@ -897,6 +902,46 @@ Rules:
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scans raw HTML for visible price elements — common WooCommerce/Shopify price class
|
||||||
|
/// names and itemprop="price" microdata — and returns them as a "[Page Price]" header
|
||||||
|
/// line. This runs on the full, untruncated HTML before tag stripping so prices that
|
||||||
|
/// would fall past the 3,500-char page-text cutoff are still surfaced to Claude.
|
||||||
|
/// Only used when JSON-LD structured data didn't already yield a price.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ExtractHtmlPriceSnippet(string html)
|
||||||
|
{
|
||||||
|
// itemprop="price" microdata (e.g. <span itemprop="price">12.99</span>)
|
||||||
|
var microprice = System.Text.RegularExpressions.Regex.Match(
|
||||||
|
html,
|
||||||
|
@"itemprop=[""']price[""'][^>]*content=[""']([0-9]+\.?[0-9]*)[""']|" +
|
||||||
|
@"itemprop=[""']price[""'][^>]*>[\s]*\$?([0-9]+\.?[0-9]*)",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
if (microprice.Success)
|
||||||
|
{
|
||||||
|
var val = (microprice.Groups[1].Value.Trim().Length > 0
|
||||||
|
? microprice.Groups[1].Value
|
||||||
|
: microprice.Groups[2].Value).Trim();
|
||||||
|
if (!string.IsNullOrEmpty(val))
|
||||||
|
return $"[Page Price] Price found in page microdata: ${val}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// WooCommerce / common e-commerce price class names
|
||||||
|
var classPricePattern = System.Text.RegularExpressions.Regex.Match(
|
||||||
|
html,
|
||||||
|
@"class=[""'][^""']*(?:price|woocommerce-Price-amount|product-price|bdi)[^""']*[""'][^>]*>" +
|
||||||
|
@"[\s\S]{0,60}?\$\s*([0-9]+\.[0-9]{2})",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
if (classPricePattern.Success)
|
||||||
|
{
|
||||||
|
var val = classPricePattern.Groups[1].Value.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(val))
|
||||||
|
return $"[Page Price] Price found in page HTML: ${val}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scans raw HTML for anchor tags linking to SDS or TDS documents and returns them as
|
/// Scans raw HTML for anchor tags linking to SDS or TDS documents and returns them as
|
||||||
/// "[Structured Data]" lines that Claude can read and echo back in its JSON response.
|
/// "[Structured Data]" lines that Claude can read and echo back in its JSON response.
|
||||||
@@ -959,6 +1004,12 @@ Rules:
|
|||||||
/// Extracts product name, SKU, and price from JSON-LD structured data blocks.
|
/// Extracts product name, SKU, and price from JSON-LD structured data blocks.
|
||||||
/// Many e-commerce sites (Shopify, WooCommerce, etc.) embed this in the page HTML
|
/// Many e-commerce sites (Shopify, WooCommerce, etc.) embed this in the page HTML
|
||||||
/// even when the visible price is rendered by JavaScript.
|
/// even when the visible price is rendered by JavaScript.
|
||||||
|
/// <para>
|
||||||
|
/// Handles three common JSON-LD shapes:
|
||||||
|
/// - Single Product object: <c>{"@type":"Product",...}</c>
|
||||||
|
/// - Top-level array: <c>[{"@type":"Product",...},...]</c>
|
||||||
|
/// - WooCommerce/Yoast @graph wrapper: <c>{"@graph":[{"@type":"Product",...},...]}</c>
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string? ExtractJsonLdData(string html)
|
private static string? ExtractJsonLdData(string html)
|
||||||
{
|
{
|
||||||
@@ -976,12 +1027,7 @@ Rules:
|
|||||||
using var doc = JsonDocument.Parse(jsonText);
|
using var doc = JsonDocument.Parse(jsonText);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
// Handle both single-object and @graph array
|
foreach (var node in FlattenJsonLdNodes(root))
|
||||||
IEnumerable<JsonElement> nodes = root.ValueKind == JsonValueKind.Array
|
|
||||||
? root.EnumerateArray()
|
|
||||||
: new[] { root };
|
|
||||||
|
|
||||||
foreach (var node in nodes)
|
|
||||||
{
|
{
|
||||||
if (!node.TryGetProperty("@type", out var typeEl)) continue;
|
if (!node.TryGetProperty("@type", out var typeEl)) continue;
|
||||||
|
|
||||||
@@ -1013,6 +1059,34 @@ Rules:
|
|||||||
return sb.Length > 0 ? sb.ToString() : null;
|
return sb.Length > 0 ? sb.ToString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recursively flattens a JSON-LD element into individual nodes for processing.
|
||||||
|
/// Handles a top-level array, a single object, and the WooCommerce/Yoast @graph
|
||||||
|
/// wrapper pattern where the root object has no @type but contains an "@graph" array.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<JsonElement> FlattenJsonLdNodes(JsonElement el)
|
||||||
|
{
|
||||||
|
if (el.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in el.EnumerateArray())
|
||||||
|
foreach (var n in FlattenJsonLdNodes(item))
|
||||||
|
yield return n;
|
||||||
|
}
|
||||||
|
else if (el.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
// @graph wrapper: {"@graph": [...]} — recurse into the array
|
||||||
|
if (el.TryGetProperty("@graph", out var graph) && graph.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var n in FlattenJsonLdNodes(graph))
|
||||||
|
yield return n;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
yield return el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dispatches JSON-LD offer extraction for both the single-object form
|
/// Dispatches JSON-LD offer extraction for both the single-object form
|
||||||
/// (<c>"offers": { ... }</c>) and the array form (<c>"offers": [ ... ]</c>)
|
/// (<c>"offers": { ... }</c>) and the array form (<c>"offers": [ ... ]</c>)
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ public class PlatformSettingsService : IPlatformSettingsService
|
|||||||
return bool.TryParse(value, out var result) ? result : defaultValue;
|
return bool.TryParse(value, out var result) ? result : defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a platform setting as an integer. Returns <paramref name="defaultValue"/> when
|
||||||
|
/// the key is missing or the stored value cannot be parsed as an integer.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> GetIntAsync(string key, int defaultValue)
|
||||||
|
{
|
||||||
|
var value = await GetAsync(key);
|
||||||
|
if (value == null) return defaultValue;
|
||||||
|
return int.TryParse(value, out var result) ? result : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates or updates the platform setting identified by <paramref name="key"/>.
|
/// Creates or updates the platform setting identified by <paramref name="key"/>.
|
||||||
/// Records <paramref name="updatedBy"/> (typically the SuperAdmin's username) and
|
/// Records <paramref name="updatedBy"/> (typically the SuperAdmin's username) and
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
@@ -27,11 +28,13 @@ public class SubscriptionService : ISubscriptionService
|
|||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly IPlatformSettingsService _platformSettings;
|
||||||
|
|
||||||
public SubscriptionService(IUnitOfWork unitOfWork, ApplicationDbContext context)
|
public SubscriptionService(IUnitOfWork unitOfWork, ApplicationDbContext context, IPlatformSettingsService platformSettings)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_platformSettings = platformSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -203,7 +206,8 @@ public class SubscriptionService : ISubscriptionService
|
|||||||
if (daysUntil == null || daysUntil > 0)
|
if (daysUntil == null || daysUntil > 0)
|
||||||
return SubscriptionStatus.Active;
|
return SubscriptionStatus.Active;
|
||||||
|
|
||||||
if (daysUntil >= -AppConstants.SubscriptionConstants.GracePeriodDays)
|
var graceDays = await GetEffectiveGracePeriodDaysAsync(company);
|
||||||
|
if (daysUntil >= -graceDays)
|
||||||
return SubscriptionStatus.GracePeriod;
|
return SubscriptionStatus.GracePeriod;
|
||||||
|
|
||||||
return SubscriptionStatus.Expired;
|
return SubscriptionStatus.Expired;
|
||||||
@@ -226,6 +230,25 @@ public class SubscriptionService : ISubscriptionService
|
|||||||
return (int)(expiry - today).TotalDays;
|
return (int)(expiry - today).TotalDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the effective grace period in days for a company. Trial companies (no Stripe subscription)
|
||||||
|
/// get 0 days unless the <c>GracePeriodAppliesToTrials</c> platform setting is explicitly enabled.
|
||||||
|
/// Paid companies always use the configured <c>GracePeriodDays</c> value.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> GetEffectiveGracePeriodDaysAsync(Company company)
|
||||||
|
{
|
||||||
|
var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId);
|
||||||
|
if (isTrial)
|
||||||
|
{
|
||||||
|
var appliesToTrials = await _platformSettings.GetBoolAsync(
|
||||||
|
PlatformSettingKeys.GracePeriodAppliesToTrials, defaultValue: false);
|
||||||
|
if (!appliesToTrials) return 0;
|
||||||
|
}
|
||||||
|
return await _platformSettings.GetIntAsync(
|
||||||
|
PlatformSettingKeys.GracePeriodDays,
|
||||||
|
AppConstants.SubscriptionConstants.GracePeriodDays);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the total catalog item count (including soft-deleted) and the plan maximum.
|
/// Returns the total catalog item count (including soft-deleted) and the plan maximum.
|
||||||
/// Returns a max of -1 when the plan config is absent (unlimited by default).
|
/// Returns a max of -1 when the plan config is absent (unlimited by default).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace PowderCoating.Shared.Constants;
|
|||||||
|
|
||||||
public static class AppConstants
|
public static class AppConstants
|
||||||
{
|
{
|
||||||
public const string ApplicationName = "Powder Coating Management System";
|
public const string ApplicationName = "Powder Coating Logix";
|
||||||
public const string Version = "1.0.0";
|
public const string Version = "1.0.0";
|
||||||
|
|
||||||
/// <summary>Set to true to enable SMS features throughout the UI.</summary>
|
/// <summary>Set to true to enable SMS features throughout the UI.</summary>
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
|
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
|
||||||
var adminNotification = scope.ServiceProvider.GetRequiredService<IAdminNotificationService>();
|
var adminNotification = scope.ServiceProvider.GetRequiredService<IAdminNotificationService>();
|
||||||
|
var platformSettings = scope.ServiceProvider.GetRequiredService<IPlatformSettingsService>();
|
||||||
|
var gracePeriodDays = await platformSettings.GetIntAsync(
|
||||||
|
PlatformSettingKeys.GracePeriodDays,
|
||||||
|
AppConstants.SubscriptionConstants.GracePeriodDays);
|
||||||
|
var gracePeriodAppliesToTrials = await platformSettings.GetBoolAsync(
|
||||||
|
PlatformSettingKeys.GracePeriodAppliesToTrials, defaultValue: false);
|
||||||
|
|
||||||
var today = DateTime.UtcNow.Date;
|
var today = DateTime.UtcNow.Date;
|
||||||
|
|
||||||
@@ -100,7 +106,9 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
foreach (var company in companies)
|
foreach (var company in companies)
|
||||||
{
|
{
|
||||||
if (ct.IsCancellationRequested) break;
|
if (ct.IsCancellationRequested) break;
|
||||||
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, ct);
|
var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId);
|
||||||
|
var effectiveGraceDays = isTrial && !gracePeriodAppliesToTrials ? 0 : gracePeriodDays;
|
||||||
|
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, effectiveGraceDays, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
@@ -124,10 +132,10 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
IAdminNotificationService adminNotification,
|
IAdminNotificationService adminNotification,
|
||||||
PowderCoating.Core.Entities.Company company,
|
PowderCoating.Core.Entities.Company company,
|
||||||
DateTime today,
|
DateTime today,
|
||||||
|
int gracePeriodDays,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var endDate = company.SubscriptionEndDate!.Value.Date;
|
var endDate = company.SubscriptionEndDate!.Value.Date;
|
||||||
var gracePeriodDays = AppConstants.SubscriptionConstants.GracePeriodDays;
|
|
||||||
var expiredDate = endDate.AddDays(gracePeriodDays);
|
var expiredDate = endDate.AddDays(gracePeriodDays);
|
||||||
|
|
||||||
// ── Status transitions ────────────────────────────────────────────
|
// ── Status transitions ────────────────────────────────────────────
|
||||||
@@ -167,6 +175,7 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
||||||
NotificationType.SubscriptionExpiryReminder,
|
NotificationType.SubscriptionExpiryReminder,
|
||||||
daysBeforeExpiry: 0,
|
daysBeforeExpiry: 0,
|
||||||
|
gracePeriodDays,
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
// Notify platform admin
|
// Notify platform admin
|
||||||
@@ -184,6 +193,7 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
||||||
NotificationType.SubscriptionExpiryReminder,
|
NotificationType.SubscriptionExpiryReminder,
|
||||||
daysBeforeExpiry: daysUntilExpiry,
|
daysBeforeExpiry: daysUntilExpiry,
|
||||||
|
gracePeriodDays,
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,6 +213,7 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
DateTime today,
|
DateTime today,
|
||||||
NotificationType notificationType,
|
NotificationType notificationType,
|
||||||
int daysBeforeExpiry,
|
int daysBeforeExpiry,
|
||||||
|
int gracePeriodDays,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(company.PrimaryContactEmail)) return;
|
if (string.IsNullOrEmpty(company.PrimaryContactEmail)) return;
|
||||||
@@ -223,7 +234,7 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
|
|
||||||
if (alreadySent) return;
|
if (alreadySent) return;
|
||||||
|
|
||||||
var (subject, plain, html) = BuildEmail(company, daysBeforeExpiry);
|
var (subject, plain, html) = BuildEmail(company, daysBeforeExpiry, gracePeriodDays);
|
||||||
|
|
||||||
var (success, error) = await emailService.SendEmailAsync(
|
var (success, error) = await emailService.SendEmailAsync(
|
||||||
company.PrimaryContactEmail,
|
company.PrimaryContactEmail,
|
||||||
@@ -255,14 +266,15 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static (string subject, string plain, string html) BuildEmail(
|
private static (string subject, string plain, string html) BuildEmail(
|
||||||
PowderCoating.Core.Entities.Company company,
|
PowderCoating.Core.Entities.Company company,
|
||||||
int daysBeforeExpiry)
|
int daysBeforeExpiry,
|
||||||
|
int gracePeriodDays)
|
||||||
{
|
{
|
||||||
var endDate = company.SubscriptionEndDate!.Value.Date;
|
var endDate = company.SubscriptionEndDate!.Value.Date;
|
||||||
|
|
||||||
if (daysBeforeExpiry == 0)
|
if (daysBeforeExpiry == 0)
|
||||||
{
|
{
|
||||||
// Grace period notice
|
// Grace period notice
|
||||||
var graceDays = AppConstants.SubscriptionConstants.GracePeriodDays;
|
var graceDays = gracePeriodDays;
|
||||||
var expiredOn = endDate.AddDays(graceDays).ToString("MMMM d, yyyy");
|
var expiredOn = endDate.AddDays(graceDays).ToString("MMMM d, yyyy");
|
||||||
var subject = $"[0d] Your Powder Coating Logix subscription has expired";
|
var subject = $"[0d] Your Powder Coating Logix subscription has expired";
|
||||||
var plain = $"Hi {company.CompanyName},\n\n" +
|
var plain = $"Hi {company.CompanyName},\n\n" +
|
||||||
|
|||||||
@@ -1424,8 +1424,8 @@ public class InventoryController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var userId = _userManager.GetUserId(User) ?? string.Empty;
|
var userId = _userManager.GetUserId(User) ?? string.Empty;
|
||||||
var txnType = jobId.HasValue ? InventoryTransactionType.JobUsage
|
// Scan-based logging always records as JobUsage; Adjustment is for manual stock corrections only
|
||||||
: (Enum.TryParse<InventoryTransactionType>(transactionType, out var parsed) ? parsed : InventoryTransactionType.Adjustment);
|
var txnType = InventoryTransactionType.JobUsage;
|
||||||
|
|
||||||
item.QuantityOnHand -= quantity;
|
item.QuantityOnHand -= quantity;
|
||||||
item.UpdatedAt = DateTime.UtcNow;
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -1610,6 +1610,62 @@ public class InventoryController : Controller
|
|||||||
if (inventoryItemId.HasValue)
|
if (inventoryItemId.HasValue)
|
||||||
selectedItem = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId.Value);
|
selectedItem = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId.Value);
|
||||||
|
|
||||||
|
// Synthesize powder-usage rows for scan-based JobUsage transactions not already linked to a PowderUsageLog
|
||||||
|
var linkedTxIds = usageLogs
|
||||||
|
.Where(u => u.InventoryTransactionId.HasValue)
|
||||||
|
.Select(u => u.InventoryTransactionId!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var powderUsageDtos = usageLogs.Select(u => new PowderUsageLogDto
|
||||||
|
{
|
||||||
|
Id = u.Id,
|
||||||
|
JobId = u.JobId,
|
||||||
|
JobNumber = u.Job?.JobNumber ?? string.Empty,
|
||||||
|
CustomerName = u.Job?.Customer?.CompanyName ?? $"{u.Job?.Customer?.ContactFirstName} {u.Job?.Customer?.ContactLastName}".Trim(),
|
||||||
|
InventoryItemId = u.InventoryItemId,
|
||||||
|
ItemName = u.InventoryItem?.Name,
|
||||||
|
SKU = u.InventoryItem?.SKU,
|
||||||
|
CoatColor = u.JobItemCoat?.ColorName,
|
||||||
|
ActualLbsUsed = u.ActualLbsUsed,
|
||||||
|
EstimatedLbs = u.EstimatedLbs,
|
||||||
|
VarianceLbs = u.VarianceLbs,
|
||||||
|
RecordedAt = u.RecordedAt,
|
||||||
|
Notes = u.Notes
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Scan-based JobUsage entries have a JobId on the transaction but no PowderUsageLog record;
|
||||||
|
// surface them in the "Powder Usage By Job" tab so they aren't invisible.
|
||||||
|
powderUsageDtos.AddRange(transactions
|
||||||
|
.Where(t => t.TransactionType == InventoryTransactionType.JobUsage
|
||||||
|
&& !linkedTxIds.Contains(t.Id)
|
||||||
|
&& (t.JobId.HasValue || (t.Reference != null && jobRefLookup.ContainsKey(t.Reference))))
|
||||||
|
.Select(t =>
|
||||||
|
{
|
||||||
|
var jobId = t.JobId ?? (t.Reference != null && jobRefLookup.TryGetValue(t.Reference, out var r) ? r.Id : 0);
|
||||||
|
var jobNumber = t.Job?.JobNumber ?? (t.Reference != null && jobRefLookup.ContainsKey(t.Reference) ? t.Reference : string.Empty);
|
||||||
|
var cust = t.Job?.Customer;
|
||||||
|
var custName = cust?.CompanyName ?? $"{cust?.ContactFirstName} {cust?.ContactLastName}".Trim();
|
||||||
|
return new PowderUsageLogDto
|
||||||
|
{
|
||||||
|
Id = 0,
|
||||||
|
SourceTransactionId = t.Id,
|
||||||
|
JobId = jobId,
|
||||||
|
JobNumber = jobNumber,
|
||||||
|
CustomerName = string.IsNullOrWhiteSpace(custName) ? null : custName,
|
||||||
|
InventoryItemId = t.InventoryItemId,
|
||||||
|
ItemName = t.InventoryItem?.Name,
|
||||||
|
SKU = t.InventoryItem?.SKU,
|
||||||
|
CoatColor = null,
|
||||||
|
ActualLbsUsed = Math.Abs(t.Quantity),
|
||||||
|
EstimatedLbs = 0,
|
||||||
|
VarianceLbs = 0,
|
||||||
|
RecordedAt = t.TransactionDate,
|
||||||
|
Notes = t.Notes
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
powderUsageDtos = [.. powderUsageDtos.OrderByDescending(u => u.RecordedAt)];
|
||||||
|
|
||||||
var vm = new InventoryLedgerViewModel
|
var vm = new InventoryLedgerViewModel
|
||||||
{
|
{
|
||||||
InventoryItemId = inventoryItemId,
|
InventoryItemId = inventoryItemId,
|
||||||
@@ -1638,22 +1694,7 @@ public class InventoryController : Controller
|
|||||||
JobId = t.JobId ?? (t.Reference != null && jobRefLookup.TryGetValue(t.Reference, out var resolved) ? resolved.Id : null),
|
JobId = t.JobId ?? (t.Reference != null && jobRefLookup.TryGetValue(t.Reference, out var resolved) ? resolved.Id : null),
|
||||||
JobNumber = t.Job?.JobNumber ?? (t.Reference != null && jobRefLookup.ContainsKey(t.Reference) ? t.Reference : null)
|
JobNumber = t.Job?.JobNumber ?? (t.Reference != null && jobRefLookup.ContainsKey(t.Reference) ? t.Reference : null)
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
PowderUsageLogs = usageLogs.Select(u => new PowderUsageLogDto
|
PowderUsageLogs = powderUsageDtos,
|
||||||
{
|
|
||||||
Id = u.Id,
|
|
||||||
JobId = u.JobId,
|
|
||||||
JobNumber = u.Job?.JobNumber ?? string.Empty,
|
|
||||||
CustomerName = u.Job?.Customer?.CompanyName ?? $"{u.Job?.Customer?.ContactFirstName} {u.Job?.Customer?.ContactLastName}".Trim(),
|
|
||||||
InventoryItemId = u.InventoryItemId,
|
|
||||||
ItemName = u.InventoryItem?.Name,
|
|
||||||
SKU = u.InventoryItem?.SKU,
|
|
||||||
CoatColor = u.JobItemCoat?.ColorName,
|
|
||||||
ActualLbsUsed = u.ActualLbsUsed,
|
|
||||||
EstimatedLbs = u.EstimatedLbs,
|
|
||||||
VarianceLbs = u.VarianceLbs,
|
|
||||||
RecordedAt = u.RecordedAt,
|
|
||||||
Notes = u.Notes
|
|
||||||
}).ToList(),
|
|
||||||
TotalPurchased = transactions
|
TotalPurchased = transactions
|
||||||
.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial)
|
.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial)
|
||||||
.Sum(t => t.Quantity),
|
.Sum(t => t.Quantity),
|
||||||
@@ -1667,6 +1708,91 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
return View(vm);
|
return View(vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the current values of a JobUsage InventoryTransaction plus a list of active
|
||||||
|
/// jobs so the edit modal can be pre-populated without a full page reload.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetUsageForEdit(int id)
|
||||||
|
{
|
||||||
|
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id, false,
|
||||||
|
t => t.Job, t => t.InventoryItem);
|
||||||
|
if (txn == null) return NotFound();
|
||||||
|
if (txn.TransactionType != InventoryTransactionType.JobUsage
|
||||||
|
&& txn.TransactionType != InventoryTransactionType.Adjustment)
|
||||||
|
return BadRequest("Only usage transactions can be edited here.");
|
||||||
|
|
||||||
|
var allJobs = await _unitOfWork.Jobs.FindAsync(
|
||||||
|
j => !j.JobStatus.IsTerminalStatus,
|
||||||
|
false,
|
||||||
|
j => j.Customer,
|
||||||
|
j => j.JobStatus);
|
||||||
|
|
||||||
|
var jobs = allJobs
|
||||||
|
.OrderByDescending(j => j.CreatedAt)
|
||||||
|
.Take(200)
|
||||||
|
.Select(j => new ScanJobOption
|
||||||
|
{
|
||||||
|
Id = j.Id,
|
||||||
|
JobNumber = j.JobNumber,
|
||||||
|
CustomerName = j.Customer != null
|
||||||
|
? (j.Customer.CompanyName ?? $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim())
|
||||||
|
: "No Customer"
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
transactionId = txn.Id,
|
||||||
|
jobId = txn.JobId,
|
||||||
|
notes = txn.Notes,
|
||||||
|
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
|
||||||
|
itemName = txn.InventoryItem?.Name,
|
||||||
|
jobs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves edits to a JobUsage InventoryTransaction's job assignment, notes, and date.
|
||||||
|
/// Quantity and balance are not changed.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate)
|
||||||
|
{
|
||||||
|
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id);
|
||||||
|
if (txn == null) return NotFound();
|
||||||
|
if (txn.TransactionType != InventoryTransactionType.JobUsage
|
||||||
|
&& txn.TransactionType != InventoryTransactionType.Adjustment)
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
if (jobId.HasValue)
|
||||||
|
{
|
||||||
|
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value);
|
||||||
|
txn.JobId = jobId.Value;
|
||||||
|
txn.Reference = job?.JobNumber;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
txn.JobId = null;
|
||||||
|
txn.Reference = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promote Adjustment→JobUsage when a job is assigned so it shows in Powder Usage By Job tab
|
||||||
|
if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment)
|
||||||
|
txn.TransactionType = InventoryTransactionType.JobUsage;
|
||||||
|
|
||||||
|
txn.Notes = notes?.Trim();
|
||||||
|
txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
|
||||||
|
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
|
||||||
|
txn.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await _unitOfWork.InventoryTransactions.UpdateAsync(txn);
|
||||||
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Helper projection used by the Scan action for job picker data.</summary>
|
/// <summary>Helper projection used by the Scan action for job picker data.</summary>
|
||||||
|
|||||||
@@ -404,10 +404,12 @@ public class JobsController : Controller
|
|||||||
// Workers dropdown for inline assignment
|
// Workers dropdown for inline assignment
|
||||||
await PopulateWorkersDropdown();
|
await PopulateWorkersDropdown();
|
||||||
|
|
||||||
// Shop workers for time entry
|
// Company users for time entry worker dropdown
|
||||||
var shopWorkers = (await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive, false))
|
var companyUsers = await _userManager.Users
|
||||||
.OrderBy(w => w.Name).ToList();
|
.Where(u => u.CompanyId == job.CompanyId && u.IsActive)
|
||||||
ViewBag.ShopWorkers = shopWorkers.Select(w => new { w.Id, w.Name, Role = System.Text.RegularExpressions.Regex.Replace(w.Role.ToString(), "([a-z])([A-Z])", "$1 $2") }).ToList();
|
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
||||||
|
.ToListAsync();
|
||||||
|
ViewBag.ShopWorkers = companyUsers.Select(u => new { Id = u.Id, Name = u.FullName }).ToList();
|
||||||
|
|
||||||
// Populate Edit Items wizard data (inline modal on Details page)
|
// Populate Edit Items wizard data (inline modal on Details page)
|
||||||
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
|
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
|
||||||
@@ -2127,9 +2129,12 @@ public class JobsController : Controller
|
|||||||
.ThenBy(j => j.JobNumber)
|
.ThenBy(j => j.JobNumber)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Workers for filter dropdown
|
// Company users for worker filter chips
|
||||||
var workers = await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive && !w.IsDeleted);
|
var mobileWorkers = await _userManager.Users
|
||||||
ViewBag.Workers = workers.OrderBy(w => w.Name).ToList();
|
.Where(u => u.CompanyId == companyId.Value && u.IsActive)
|
||||||
|
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
||||||
|
.ToListAsync();
|
||||||
|
ViewBag.Workers = mobileWorkers.Select(u => new { Id = u.Id, Name = u.FullName }).ToList();
|
||||||
ViewBag.CurrentWorkerId = workerId;
|
ViewBag.CurrentWorkerId = workerId;
|
||||||
ViewBag.AllStatuses = allStatuses;
|
ViewBag.AllStatuses = allStatuses;
|
||||||
|
|
||||||
@@ -3352,13 +3357,13 @@ public class JobsController : Controller
|
|||||||
{
|
{
|
||||||
var entries = await _unitOfWork.JobTimeEntries.FindAsync(
|
var entries = await _unitOfWork.JobTimeEntries.FindAsync(
|
||||||
e => e.JobId == jobId, false,
|
e => e.JobId == jobId, false,
|
||||||
e => e.Worker);
|
e => e.Worker); // Worker nav loaded for display of legacy entries that pre-date user migration
|
||||||
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
|
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
|
||||||
return Json(dtos);
|
return Json(dtos);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a time entry for a shop worker on a specific job.
|
/// Adds a time entry for a company user on a specific job.
|
||||||
/// Validates hours are in the reasonable range (0.1–24) before saving.
|
/// Validates hours are in the reasonable range (0.1–24) before saving.
|
||||||
/// Returns the new entry as JSON so the UI can append it to the list without a reload.
|
/// Returns the new entry as JSON so the UI can append it to the list without a reload.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3371,13 +3376,14 @@ public class JobsController : Controller
|
|||||||
var job = await _unitOfWork.Jobs.GetByIdAsync(dto.JobId);
|
var job = await _unitOfWork.Jobs.GetByIdAsync(dto.JobId);
|
||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
var worker = await _unitOfWork.ShopWorkers.GetByIdAsync(dto.ShopWorkerId);
|
var user = await _userManager.FindByIdAsync(dto.UserId);
|
||||||
if (worker == null) return BadRequest(new { error = "Worker not found." });
|
if (user == null) return BadRequest(new { error = "Worker not found." });
|
||||||
|
|
||||||
var entry = new JobTimeEntry
|
var entry = new JobTimeEntry
|
||||||
{
|
{
|
||||||
JobId = dto.JobId,
|
JobId = dto.JobId,
|
||||||
ShopWorkerId = dto.ShopWorkerId,
|
UserId = dto.UserId,
|
||||||
|
UserDisplayName = user.FullName,
|
||||||
WorkDate = dto.WorkDate.Date,
|
WorkDate = dto.WorkDate.Date,
|
||||||
HoursWorked = Math.Round(dto.HoursWorked, 2),
|
HoursWorked = Math.Round(dto.HoursWorked, 2),
|
||||||
Stage = string.IsNullOrWhiteSpace(dto.Stage) ? null : dto.Stage.Trim(),
|
Stage = string.IsNullOrWhiteSpace(dto.Stage) ? null : dto.Stage.Trim(),
|
||||||
@@ -3388,9 +3394,7 @@ public class JobsController : Controller
|
|||||||
await _unitOfWork.JobTimeEntries.AddAsync(entry);
|
await _unitOfWork.JobTimeEntries.AddAsync(entry);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// Reload with worker navigation for the response
|
return Json(_mapper.Map<JobTimeEntryDto>(entry));
|
||||||
var saved = await _unitOfWork.JobTimeEntries.GetByIdAsync(entry.Id, false, e => e.Worker);
|
|
||||||
return Json(_mapper.Map<JobTimeEntryDto>(saved));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Updates an existing time entry's hours, stage, and notes in place.</summary>
|
/// <summary>Updates an existing time entry's hours, stage, and notes in place.</summary>
|
||||||
@@ -3400,13 +3404,14 @@ public class JobsController : Controller
|
|||||||
if (dto.HoursWorked <= 0 || dto.HoursWorked > 24)
|
if (dto.HoursWorked <= 0 || dto.HoursWorked > 24)
|
||||||
return BadRequest(new { error = "Hours must be between 0.1 and 24." });
|
return BadRequest(new { error = "Hours must be between 0.1 and 24." });
|
||||||
|
|
||||||
var entry = await _unitOfWork.JobTimeEntries.GetByIdAsync(dto.Id, false, e => e.Worker);
|
var entry = await _unitOfWork.JobTimeEntries.GetByIdAsync(dto.Id);
|
||||||
if (entry == null) return NotFound();
|
if (entry == null) return NotFound();
|
||||||
|
|
||||||
var worker = await _unitOfWork.ShopWorkers.GetByIdAsync(dto.ShopWorkerId);
|
var user = await _userManager.FindByIdAsync(dto.UserId);
|
||||||
if (worker == null) return BadRequest(new { error = "Worker not found." });
|
if (user == null) return BadRequest(new { error = "Worker not found." });
|
||||||
|
|
||||||
entry.ShopWorkerId = dto.ShopWorkerId;
|
entry.UserId = dto.UserId;
|
||||||
|
entry.UserDisplayName = user.FullName;
|
||||||
entry.WorkDate = dto.WorkDate.Date;
|
entry.WorkDate = dto.WorkDate.Date;
|
||||||
entry.HoursWorked = Math.Round(dto.HoursWorked, 2);
|
entry.HoursWorked = Math.Round(dto.HoursWorked, 2);
|
||||||
entry.Stage = string.IsNullOrWhiteSpace(dto.Stage) ? null : dto.Stage.Trim();
|
entry.Stage = string.IsNullOrWhiteSpace(dto.Stage) ? null : dto.Stage.Trim();
|
||||||
@@ -3416,9 +3421,7 @@ public class JobsController : Controller
|
|||||||
await _unitOfWork.JobTimeEntries.UpdateAsync(entry);
|
await _unitOfWork.JobTimeEntries.UpdateAsync(entry);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// Reload for response
|
return Json(_mapper.Map<JobTimeEntryDto>(entry));
|
||||||
var saved = await _unitOfWork.JobTimeEntries.GetByIdAsync(entry.Id, false, e => e.Worker);
|
|
||||||
return Json(_mapper.Map<JobTimeEntryDto>(saved));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Soft-deletes a time entry. The hours are removed from the job's running total.</summary>
|
/// <summary>Soft-deletes a time entry. The hours are removed from the job's running total.</summary>
|
||||||
|
|||||||
@@ -74,9 +74,10 @@ public class RevenueController : Controller
|
|||||||
|
|
||||||
// ── Revenue metrics ──────────────────────────────────────────────
|
// ── Revenue metrics ──────────────────────────────────────────────
|
||||||
|
|
||||||
// Active paying companies
|
// Active paying companies — exclude trials (no StripeSubscriptionId = on trial)
|
||||||
var payingActive = companies
|
var payingActive = companies
|
||||||
.Where(c =>
|
.Where(c =>
|
||||||
|
!string.IsNullOrEmpty(c.StripeSubscriptionId) &&
|
||||||
(c.SubscriptionStatus == SubscriptionStatus.Active ||
|
(c.SubscriptionStatus == SubscriptionStatus.Active ||
|
||||||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod))
|
c.SubscriptionStatus == SubscriptionStatus.GracePeriod))
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -111,6 +112,7 @@ public class RevenueController : Controller
|
|||||||
// ── Plan distribution ────────────────────────────────────────────
|
// ── Plan distribution ────────────────────────────────────────────
|
||||||
var planDistribution = companies
|
var planDistribution = companies
|
||||||
.Where(c => !c.IsComped &&
|
.Where(c => !c.IsComped &&
|
||||||
|
!string.IsNullOrEmpty(c.StripeSubscriptionId) &&
|
||||||
(c.SubscriptionStatus == SubscriptionStatus.Active ||
|
(c.SubscriptionStatus == SubscriptionStatus.Active ||
|
||||||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod))
|
c.SubscriptionStatus == SubscriptionStatus.GracePeriod))
|
||||||
.GroupBy(c => c.SubscriptionPlan)
|
.GroupBy(c => c.SubscriptionPlan)
|
||||||
@@ -137,6 +139,7 @@ public class RevenueController : Controller
|
|||||||
var activeInMonth = companies
|
var activeInMonth = companies
|
||||||
.Where(c =>
|
.Where(c =>
|
||||||
!c.IsComped &&
|
!c.IsComped &&
|
||||||
|
!string.IsNullOrEmpty(c.StripeSubscriptionId) &&
|
||||||
c.CreatedAt < monthEnd &&
|
c.CreatedAt < monthEnd &&
|
||||||
(c.SubscriptionStatus == SubscriptionStatus.Active ||
|
(c.SubscriptionStatus == SubscriptionStatus.Active ||
|
||||||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod ||
|
c.SubscriptionStatus == SubscriptionStatus.GracePeriod ||
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
|
|
||||||
@@ -152,7 +154,16 @@ public class SubscriptionMiddleware
|
|||||||
var daysUntil = subscriptionService.DaysUntilExpiry(company);
|
var daysUntil = subscriptionService.DaysUntilExpiry(company);
|
||||||
if (daysUntil != null)
|
if (daysUntil != null)
|
||||||
{
|
{
|
||||||
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
|
var platformSettings = context.RequestServices.GetRequiredService<IPlatformSettingsService>();
|
||||||
|
var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId);
|
||||||
|
var graceDays = 0;
|
||||||
|
if (!isTrial || await platformSettings.GetBoolAsync(PlatformSettingKeys.GracePeriodAppliesToTrials))
|
||||||
|
{
|
||||||
|
graceDays = await platformSettings.GetIntAsync(
|
||||||
|
PlatformSettingKeys.GracePeriodDays,
|
||||||
|
AppConstants.SubscriptionConstants.GracePeriodDays);
|
||||||
|
}
|
||||||
|
if (daysUntil < -graceDays)
|
||||||
{
|
{
|
||||||
// Past grace period — hard lockout
|
// Past grace period — hard lockout
|
||||||
context.Response.Redirect("/Billing/Expired");
|
context.Response.Redirect("/Billing/Expired");
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<PackageReference Include="Fido2" Version="4.0.1" />
|
<PackageReference Include="Fido2" Version="4.0.1" />
|
||||||
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
|
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
|
||||||
<PackageReference Include="Markdig" Version="0.40.0" />
|
<PackageReference Include="Markdig" Version="0.40.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
@@ -147,9 +148,10 @@ if (builder.Environment.IsProduction())
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var keysPath = Path.Combine(builder.Environment.ContentRootPath, "DataProtection-Keys");
|
// Non-production: store keys in SQL Server so they survive deploys and IIS app pool recycles
|
||||||
|
// without needing filesystem write permissions on the web root.
|
||||||
builder.Services.AddDataProtection()
|
builder.Services.AddDataProtection()
|
||||||
.PersistKeysToFileSystem(new DirectoryInfo(keysPath))
|
.PersistKeysToDbContext<ApplicationDbContext>()
|
||||||
.SetApplicationName("PowderCoatingApp");
|
.SetApplicationName("PowderCoatingApp");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ by editing this MSBuild file. In order to learn more about this please visit htt
|
|||||||
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||||
<ProjectGuid>f6a7b8c9-d0e1-4f5a-3b4c-5d6e7f8a9b0c</ProjectGuid>
|
<ProjectGuid>f6a7b8c9-d0e1-4f5a-3b4c-5d6e7f8a9b0c</ProjectGuid>
|
||||||
<PublishUrl>https://linuxpcl-cvatbmbch9cchmbe.scm.centralus-01.azurewebsites.net/</PublishUrl>
|
<PublishUrl>https://linuxpcl-cvatbmbch9cchmbe.scm.centralus-01.azurewebsites.net/</PublishUrl>
|
||||||
<UserName />
|
<UserName>$linuxpcl</UserName>
|
||||||
<_SavePWD>false</_SavePWD>
|
<_SavePWD>false</_SavePWD>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
},
|
},
|
||||||
"applicationUrl": "https://localhost:58461;http://localhost:58462"
|
"applicationUrl": "https://localhost:58461;http://localhost:58462;http://0.0.0.0:58462"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-footer">
|
<div class="mobile-card-footer">
|
||||||
<span class="btn btn-sm btn-outline-primary">Edit →</span>
|
<span class="btn btn-sm btn-outline-primary">Edit →</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-footer">
|
<div class="mobile-card-footer">
|
||||||
<span class="btn btn-sm btn-outline-primary">View →</span>
|
<span class="btn btn-sm btn-outline-primary">View →</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,7 +301,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-footer">
|
<div class="mobile-card-footer">
|
||||||
<span class="btn btn-sm btn-outline-primary">Edit →</span>
|
<span class="btn btn-sm btn-outline-primary">Edit →</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,7 +274,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-footer">
|
<div class="mobile-card-footer">
|
||||||
<span class="btn btn-sm btn-outline-primary">View →</span>
|
<span class="btn btn-sm btn-outline-primary">View →</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
<th class="text-end">Balance After</th>
|
<th class="text-end">Balance After</th>
|
||||||
<th>Reference</th>
|
<th>Reference</th>
|
||||||
<th>Notes</th>
|
<th>Notes</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -203,6 +204,16 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td><small class="text-muted">@t.Notes</small></td>
|
<td><small class="text-muted">@t.Notes</small></td>
|
||||||
|
<td>
|
||||||
|
@if (t.TransactionType == "JobUsage" || (t.TransactionType == "Adjustment" && t.PurchaseOrderId == null))
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1"
|
||||||
|
title="Edit usage record"
|
||||||
|
onclick="openUsageEdit(@t.Id)">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -241,6 +252,7 @@
|
|||||||
<th class="text-end">Actual (lbs)</th>
|
<th class="text-end">Actual (lbs)</th>
|
||||||
<th class="text-end">Variance</th>
|
<th class="text-end">Variance</th>
|
||||||
<th>Notes</th>
|
<th>Notes</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -250,10 +262,17 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="text-nowrap">@u.RecordedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy")</td>
|
<td class="text-nowrap">@u.RecordedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy")</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@u.JobId"
|
@if (u.JobId > 0)
|
||||||
class="text-decoration-none fw-semibold">
|
{
|
||||||
@u.JobNumber
|
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@u.JobId"
|
||||||
</a>
|
class="text-decoration-none fw-semibold">
|
||||||
|
@u.JobNumber
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted fst-italic">No job assigned</span>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>@u.CustomerName</td>
|
<td>@u.CustomerName</td>
|
||||||
@if (!Model.InventoryItemId.HasValue)
|
@if (!Model.InventoryItemId.HasValue)
|
||||||
@@ -279,6 +298,16 @@
|
|||||||
@(variance > 0 ? "+" : "")@variance.ToString("N3")
|
@(variance > 0 ? "+" : "")@variance.ToString("N3")
|
||||||
</td>
|
</td>
|
||||||
<td><small class="text-muted">@u.Notes</small></td>
|
<td><small class="text-muted">@u.Notes</small></td>
|
||||||
|
<td>
|
||||||
|
@if (u.SourceTransactionId.HasValue)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1"
|
||||||
|
title="Edit usage record"
|
||||||
|
onclick="openUsageEdit(@u.SourceTransactionId.Value)">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -291,6 +320,7 @@
|
|||||||
@(Model.PowderUsageLogs.Sum(u => u.VarianceLbs) > 0 ? "+" : "")@Model.PowderUsageLogs.Sum(u => u.VarianceLbs).ToString("N3")
|
@(Model.PowderUsageLogs.Sum(u => u.VarianceLbs) > 0 ? "+" : "")@Model.PowderUsageLogs.Sum(u => u.VarianceLbs).ToString("N3")
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
@@ -302,14 +332,63 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* ── Edit Usage Modal ─────────────────────────────────────────────── *@
|
||||||
|
<div class="modal fade" id="editUsageModal" tabindex="-1" aria-labelledby="editUsageModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="editUsageModalLabel">
|
||||||
|
<i class="bi bi-pencil me-2"></i>Edit Usage Record
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="editUsageLoading" class="text-center py-4">
|
||||||
|
<div class="spinner-border spinner-border-sm me-2"></div>Loading…
|
||||||
|
</div>
|
||||||
|
<form id="editUsageForm" class="d-none">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" id="euTxnId" name="id" />
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Powder Item</label>
|
||||||
|
<p id="euItemName" class="form-control-plaintext text-muted"></p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="euJobId" class="form-label fw-semibold">Job <span class="text-muted fw-normal">(optional)</span></label>
|
||||||
|
<select id="euJobId" name="jobId" class="form-select">
|
||||||
|
<option value="">— No job —</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Select the job this powder was used on.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="euDate" class="form-label fw-semibold">Date / Time</label>
|
||||||
|
<input type="datetime-local" id="euDate" name="transactionDate" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="euNotes" class="form-label fw-semibold">Notes</label>
|
||||||
|
<textarea id="euNotes" name="notes" class="form-control" rows="2" maxlength="500"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="euSaveBtn" disabled>
|
||||||
|
<span id="euSaveBtnText">Save Changes</span>
|
||||||
|
<span id="euSaveBtnSpinner" class="spinner-border spinner-border-sm ms-1 d-none"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
|
<script src="~/js/inventory-ledger.js" asp-append-version="true"></script>
|
||||||
<script>
|
<script>
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
document.getElementById('tab-transactions').classList.toggle('d-none', tab !== 'transactions');
|
document.getElementById('tab-transactions').classList.toggle('d-none', tab !== 'transactions');
|
||||||
document.getElementById('tab-usage').classList.toggle('d-none', tab !== 'usage');
|
document.getElementById('tab-usage').classList.toggle('d-none', tab !== 'usage');
|
||||||
document.querySelectorAll('#ledgerTabs .nav-link').forEach(el => el.classList.remove('active'));
|
document.querySelectorAll('#ledgerTabs .nav-link').forEach(el => el.classList.remove('active'));
|
||||||
event.currentTarget.classList.add('active');
|
event.currentTarget.classList.add('active');
|
||||||
// Update hidden tab field in filter form
|
|
||||||
document.querySelector('input[name="tab"]').value = tab;
|
document.querySelector('input[name="tab"]').value = tab;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@model PowderCoating.Application.DTOs.Job.JobDto
|
@model PowderCoating.Application.DTOs.Job.JobDto
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = $"Job {Model.JobNumber}";
|
ViewData["Title"] = $"Job {Model.JobNumber}";
|
||||||
@@ -730,7 +730,6 @@
|
|||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Worker</th>
|
<th>Worker</th>
|
||||||
<th>Role</th>
|
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th class="text-end">Hours</th>
|
<th class="text-end">Hours</th>
|
||||||
<th>Stage</th>
|
<th>Stage</th>
|
||||||
@@ -1311,7 +1310,7 @@
|
|||||||
<a asp-action="Intake" asp-route-id="@Model.Id"
|
<a asp-action="Intake" asp-route-id="@Model.Id"
|
||||||
class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")"
|
class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")"
|
||||||
title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")">
|
title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")">
|
||||||
<i class="bi bi-box-seam me-2"></i>@(Model.IntakeDate.HasValue ? "Intake ✓" : "Intake")
|
<i class=”bi bi-box-seam me-2”></i>@(Model.IntakeDate.HasValue ? "Intake ✓" : "Intake")
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@{
|
@{
|
||||||
@@ -2198,7 +2197,7 @@
|
|||||||
<option value="">— Select worker —</option>
|
<option value="">— Select worker —</option>
|
||||||
@foreach (var w in (ViewBag.ShopWorkers as IEnumerable<dynamic> ?? []))
|
@foreach (var w in (ViewBag.ShopWorkers as IEnumerable<dynamic> ?? []))
|
||||||
{
|
{
|
||||||
<option value="@w.Id">@w.Name (@w.Role)</option>
|
<option value="@w.Id">@w.Name</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -2682,8 +2681,8 @@
|
|||||||
|
|
||||||
// Notes
|
// Notes
|
||||||
const notes = [];
|
const notes = [];
|
||||||
if (!d.hasPowderData) notes.push('âš Add powder cost per lb on coat records to include material cost.');
|
if (!d.hasPowderData) notes.push('⚠ Add powder cost per lb on coat records to include material cost.');
|
||||||
if (!d.hasLaborData) notes.push('âš Log time entries to include labor cost.');
|
if (!d.hasLaborData) notes.push('⚠ Log time entries to include labor cost.');
|
||||||
if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.');
|
if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.');
|
||||||
document.getElementById('costingNotes').innerHTML = notes.map(n => `<div class="text-muted">${n}</div>`).join('');
|
document.getElementById('costingNotes').innerHTML = notes.map(n => `<div class="text-muted">${n}</div>`).join('');
|
||||||
|
|
||||||
@@ -2748,7 +2747,6 @@
|
|||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td class="fw-semibold">${esc(e.workerName)}</td>
|
<td class="fw-semibold">${esc(e.workerName)}</td>
|
||||||
<td class="text-muted small">${esc(e.workerRole)}</td>
|
|
||||||
<td class="small">${d}</td>
|
<td class="small">${d}</td>
|
||||||
<td class="text-end fw-semibold">${e.hoursWorked.toFixed(2)}</td>
|
<td class="text-end fw-semibold">${e.hoursWorked.toFixed(2)}</td>
|
||||||
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">—</span>'}</td>
|
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">—</span>'}</td>
|
||||||
@@ -2786,7 +2784,7 @@
|
|||||||
if (!e) return;
|
if (!e) return;
|
||||||
document.getElementById('timeEntryModalTitle').textContent = 'Edit Time Entry';
|
document.getElementById('timeEntryModalTitle').textContent = 'Edit Time Entry';
|
||||||
document.getElementById('teEntryId').value = e.id;
|
document.getElementById('teEntryId').value = e.id;
|
||||||
document.getElementById('teWorkerId').value = e.shopWorkerId;
|
document.getElementById('teWorkerId').value = e.userId ?? '';
|
||||||
document.getElementById('teWorkDate').value = new Date(e.workDate).toISOString().slice(0, 10);
|
document.getElementById('teWorkDate').value = new Date(e.workDate).toISOString().slice(0, 10);
|
||||||
document.getElementById('teHoursWorked').value = e.hoursWorked;
|
document.getElementById('teHoursWorked').value = e.hoursWorked;
|
||||||
document.getElementById('teStage').value = e.stage ?? '';
|
document.getElementById('teStage').value = e.stage ?? '';
|
||||||
@@ -2797,7 +2795,7 @@
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
const id = parseInt(document.getElementById('teEntryId').value);
|
const id = parseInt(document.getElementById('teEntryId').value);
|
||||||
const workerId = parseInt(document.getElementById('teWorkerId').value);
|
const workerId = document.getElementById('teWorkerId').value;
|
||||||
const workDate = document.getElementById('teWorkDate').value;
|
const workDate = document.getElementById('teWorkDate').value;
|
||||||
const hours = parseFloat(document.getElementById('teHoursWorked').value);
|
const hours = parseFloat(document.getElementById('teHoursWorked').value);
|
||||||
const stage = document.getElementById('teStage').value.trim();
|
const stage = document.getElementById('teStage').value.trim();
|
||||||
@@ -2813,8 +2811,8 @@
|
|||||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||||
const url = id > 0 ? '/Jobs/UpdateTimeEntry' : '/Jobs/AddTimeEntry';
|
const url = id > 0 ? '/Jobs/UpdateTimeEntry' : '/Jobs/AddTimeEntry';
|
||||||
const body = id > 0
|
const body = id > 0
|
||||||
? { id, shopWorkerId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null }
|
? { id, userId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null }
|
||||||
: { jobId: jid, shopWorkerId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null };
|
: { jobId: jid, userId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch(url, {
|
const r = await fetch(url, {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
@model List<PowderCoating.Application.DTOs.Job.JobDailyPriorityDto>
|
@model List<PowderCoating.Application.DTOs.Job.JobDailyPriorityDto>
|
||||||
@using PowderCoating.Core.Entities
|
|
||||||
|
|
||||||
@{
|
@{
|
||||||
Layout = null;
|
Layout = null;
|
||||||
var workers = ViewBag.Workers as List<ShopWorker> ?? new();
|
var workers = (ViewBag.Workers as IEnumerable<dynamic>) ?? Array.Empty<dynamic>();
|
||||||
var currentWorkerId = ViewBag.CurrentWorkerId as string;
|
var currentWorkerId = ViewBag.CurrentWorkerId as string;
|
||||||
var allStatuses = ViewBag.AllStatuses as List<JobStatusLookup> ?? new();
|
var allStatuses = ViewBag.AllStatuses as List<JobStatusLookup> ?? new();
|
||||||
var activeCount = Model.Count;
|
var activeCount = Model.Count;
|
||||||
|
|||||||
@@ -202,7 +202,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-footer">
|
<div class="mobile-card-footer">
|
||||||
<span class="btn btn-sm btn-outline-secondary">View →</span>
|
<span class="btn btn-sm btn-outline-secondary">View →</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<a tabindex="0" class="help-icon ms-1" role="button"
|
<a tabindex="0" class="help-icon ms-1" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right"
|
data-bs-toggle="popover" data-bs-placement="right"
|
||||||
data-bs-title="Quote Statuses"
|
data-bs-title="Quote Statuses"
|
||||||
data-bs-content="<strong>Draft</strong> — saved but not yet sent. Edit freely.<br><strong>Sent</strong> — delivered to the customer, awaiting response.<br><strong>Approved</strong> — customer accepted. You can convert this to a Job.<br><strong>Rejected</strong> — customer declined.<br><strong>Expired</strong> — validity period has passed. Edit to extend it.<br><strong>Converted</strong> — a job has been created from this quote.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
data-bs-content="<strong>Draft</strong> — saved but not yet sent. Edit freely.<br><strong>Sent</strong> — delivered to the customer, awaiting response.<br><strong>Approved</strong> — customer accepted. You can convert this to a Job.<br><strong>Rejected</strong> — customer declined.<br><strong>Expired</strong> — validity period has passed. Edit to extend it.<br><strong>Converted</strong> — a job has been created from this quote.<br><br><a href='/Help/Quotes#quote-statuses' target='_blank'>Learn more →</a>">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -273,7 +273,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-footer">
|
<div class="mobile-card-footer">
|
||||||
<span class="btn btn-sm btn-outline-primary">Manage →</span>
|
<span class="btn btn-sm btn-outline-primary">Manage →</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-footer">
|
<div class="mobile-card-footer">
|
||||||
<span class="btn btn-sm btn-outline-secondary">View Details →</span>
|
<span class="btn btn-sm btn-outline-secondary">View Details →</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// inventory-ledger.js — Edit Usage Record modal logic
|
||||||
|
|
||||||
|
async function openUsageEdit(transactionId) {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('editUsageModal'));
|
||||||
|
const loading = document.getElementById('editUsageLoading');
|
||||||
|
const form = document.getElementById('editUsageForm');
|
||||||
|
const saveBtn = document.getElementById('euSaveBtn');
|
||||||
|
|
||||||
|
loading.classList.remove('d-none');
|
||||||
|
form.classList.add('d-none');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/Inventory/GetUsageForEdit?id=${transactionId}`);
|
||||||
|
if (!resp.ok) throw new Error('Failed to load usage record.');
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
document.getElementById('euTxnId').value = data.transactionId;
|
||||||
|
document.getElementById('euItemName').textContent = data.itemName || '—';
|
||||||
|
document.getElementById('euDate').value = data.transactionDate;
|
||||||
|
document.getElementById('euNotes').value = data.notes || '';
|
||||||
|
|
||||||
|
const jobSel = document.getElementById('euJobId');
|
||||||
|
jobSel.innerHTML = '<option value="">— No job —</option>';
|
||||||
|
(data.jobs || []).forEach(j => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = j.id;
|
||||||
|
opt.textContent = `${j.jobNumber} — ${j.customerName}`;
|
||||||
|
if (j.id === data.jobId) opt.selected = true;
|
||||||
|
jobSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
loading.classList.add('d-none');
|
||||||
|
form.classList.remove('d-none');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
} catch (e) {
|
||||||
|
loading.innerHTML = `<div class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('euSaveBtn').addEventListener('click', async () => {
|
||||||
|
const form = document.getElementById('editUsageForm');
|
||||||
|
if (!form.reportValidity()) return;
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById('euSaveBtn');
|
||||||
|
const spinner = document.getElementById('euSaveBtnSpinner');
|
||||||
|
const btnText = document.getElementById('euSaveBtnText');
|
||||||
|
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
spinner.classList.remove('d-none');
|
||||||
|
btnText.textContent = 'Saving…';
|
||||||
|
|
||||||
|
const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
id: document.getElementById('euTxnId').value,
|
||||||
|
jobId: document.getElementById('euJobId').value,
|
||||||
|
notes: document.getElementById('euNotes').value,
|
||||||
|
transactionDate: document.getElementById('euDate').value,
|
||||||
|
__RequestVerificationToken: token || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/Inventory/EditUsageTransaction', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString()
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
if (result.success) {
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('editUsageModal')).hide();
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
throw new Error('Save failed.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
spinner.classList.add('d-none');
|
||||||
|
btnText.textContent = 'Save Changes';
|
||||||
|
alert('Error saving changes: ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Infrastructure.Data;
|
using PowderCoating.Infrastructure.Data;
|
||||||
@@ -23,7 +24,7 @@ public class SubscriptionServiceTests
|
|||||||
new ApplicationUser { Id = "u2", CompanyId = 7, UserName = "u2", Email = "u2@example.com", FirstName = "B", LastName = "Two", IsActive = true });
|
new ApplicationUser { Id = "u2", CompanyId = 7, UserName = "u2", Email = "u2@example.com", FirstName = "B", LastName = "Two", IsActive = true });
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
|
||||||
|
|
||||||
var (used, max) = await service.GetUserCountAsync(7);
|
var (used, max) = await service.GetUserCountAsync(7);
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ public class SubscriptionServiceTests
|
|||||||
new Job { Id = 3, CompanyId = 8, JobNumber = "JOB-3", CustomerId = 1, Description = "Delivered", JobStatusId = 3, JobPriorityId = 1 });
|
new Job { Id = 3, CompanyId = 8, JobNumber = "JOB-3", CustomerId = 1, Description = "Delivered", JobStatusId = 3, JobPriorityId = 1 });
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
|
||||||
|
|
||||||
var (used, max) = await service.GetJobCountAsync(8);
|
var (used, max) = await service.GetJobCountAsync(8);
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ public class SubscriptionServiceTests
|
|||||||
oldQuote.CreatedAt = DateTime.UtcNow.AddMonths(-1);
|
oldQuote.CreatedAt = DateTime.UtcNow.AddMonths(-1);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
|
||||||
|
|
||||||
var (used, max) = await service.GetQuoteCountAsync(9);
|
var (used, max) = await service.GetQuoteCountAsync(9);
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ public class SubscriptionServiceTests
|
|||||||
context.Customers.Add(new Customer { Id = 1, CompanyId = 10, CompanyName = "Customer A" });
|
context.Customers.Add(new Customer { Id = 1, CompanyId = 10, CompanyName = "Customer A" });
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
|
||||||
|
|
||||||
var allowed = await service.CanAddCustomerAsync(10);
|
var allowed = await service.CanAddCustomerAsync(10);
|
||||||
|
|
||||||
@@ -109,7 +110,7 @@ public class SubscriptionServiceTests
|
|||||||
context.AiItemPredictions.Add(new AiItemPrediction { Id = 1, CompanyId = 11, CreatedAt = DateTime.UtcNow.AddDays(-1) });
|
context.AiItemPredictions.Add(new AiItemPrediction { Id = 1, CompanyId = 11, CreatedAt = DateTime.UtcNow.AddDays(-1) });
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
|
||||||
|
|
||||||
var allowed = await service.CanUseAiPhotoQuoteAsync(11);
|
var allowed = await service.CanUseAiPhotoQuoteAsync(11);
|
||||||
|
|
||||||
@@ -123,7 +124,7 @@ public class SubscriptionServiceTests
|
|||||||
SeedCompanyAndPlan(context, companyId: 12, plan: 6, maxAiPhotoQuotesPerMonth: 10, allowAiPhotoQuotes: false);
|
SeedCompanyAndPlan(context, companyId: 12, plan: 6, maxAiPhotoQuotesPerMonth: 10, allowAiPhotoQuotes: false);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
|
||||||
|
|
||||||
var allowed = await service.CanUseAiPhotoQuoteAsync(12);
|
var allowed = await service.CanUseAiPhotoQuoteAsync(12);
|
||||||
|
|
||||||
@@ -143,11 +144,12 @@ public class SubscriptionServiceTests
|
|||||||
PrimaryContactEmail = "grace@example.com",
|
PrimaryContactEmail = "grace@example.com",
|
||||||
SubscriptionStatus = SubscriptionStatus.Active,
|
SubscriptionStatus = SubscriptionStatus.Active,
|
||||||
SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-5),
|
SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-5),
|
||||||
|
StripeSubscriptionId = "sub_grace_test", // paid sub — qualifies for grace period
|
||||||
IsActive = true
|
IsActive = true
|
||||||
});
|
});
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
|
||||||
|
|
||||||
var status = await service.GetStatusAsync(20);
|
var status = await service.GetStatusAsync(20);
|
||||||
|
|
||||||
@@ -167,11 +169,12 @@ public class SubscriptionServiceTests
|
|||||||
PrimaryContactEmail = "expired@example.com",
|
PrimaryContactEmail = "expired@example.com",
|
||||||
SubscriptionStatus = SubscriptionStatus.Active,
|
SubscriptionStatus = SubscriptionStatus.Active,
|
||||||
SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-15),
|
SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-15),
|
||||||
|
StripeSubscriptionId = "sub_expired_test", // paid sub — 15 days > 14-day grace window
|
||||||
IsActive = true
|
IsActive = true
|
||||||
});
|
});
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
|
||||||
|
|
||||||
var status = await service.GetStatusAsync(21);
|
var status = await service.GetStatusAsync(21);
|
||||||
|
|
||||||
@@ -196,7 +199,7 @@ public class SubscriptionServiceTests
|
|||||||
});
|
});
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
|
||||||
|
|
||||||
var status = await service.GetStatusAsync(22);
|
var status = await service.GetStatusAsync(22);
|
||||||
|
|
||||||
@@ -212,7 +215,7 @@ public class SubscriptionServiceTests
|
|||||||
company!.AiInventoryAssistEnabled = false;
|
company!.AiInventoryAssistEnabled = false;
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
|
||||||
|
|
||||||
var enabled = await service.IsAiInventoryAssistEnabledAsync(13);
|
var enabled = await service.IsAiInventoryAssistEnabledAsync(13);
|
||||||
|
|
||||||
@@ -250,7 +253,7 @@ public class SubscriptionServiceTests
|
|||||||
});
|
});
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
|
||||||
|
|
||||||
var (used, max) = await service.GetJobPhotoCountAsync(14, 100);
|
var (used, max) = await service.GetJobPhotoCountAsync(14, 100);
|
||||||
|
|
||||||
@@ -290,7 +293,7 @@ public class SubscriptionServiceTests
|
|||||||
});
|
});
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
|
||||||
|
|
||||||
var (used, max) = await service.GetQuotePhotoCountAsync(15, 200);
|
var (used, max) = await service.GetQuotePhotoCountAsync(15, 200);
|
||||||
|
|
||||||
@@ -308,7 +311,7 @@ public class SubscriptionServiceTests
|
|||||||
new AiItemPrediction { Id = 2, CompanyId = 16, CreatedAt = DateTime.UtcNow.AddDays(-2) });
|
new AiItemPrediction { Id = 2, CompanyId = 16, CreatedAt = DateTime.UtcNow.AddDays(-2) });
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var service = new SubscriptionService(new UnitOfWork(context), context);
|
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
|
||||||
|
|
||||||
var allowed = await service.CanUseAiPhotoQuoteAsync(16);
|
var allowed = await service.CanUseAiPhotoQuoteAsync(16);
|
||||||
|
|
||||||
@@ -410,3 +413,13 @@ public class SubscriptionServiceTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>No-op stub for IPlatformSettingsService — returns defaults for all queries.</summary>
|
||||||
|
file sealed class NullPlatformSettingsService : IPlatformSettingsService
|
||||||
|
{
|
||||||
|
public Task<string?> GetAsync(string key) => Task.FromResult<string?>(null);
|
||||||
|
public Task<bool> GetBoolAsync(string key, bool defaultValue = false) => Task.FromResult(defaultValue);
|
||||||
|
public Task<int> GetIntAsync(string key, int defaultValue) => Task.FromResult(defaultValue);
|
||||||
|
public Task SetAsync(string key, string? value, string? updatedBy = null) => Task.CompletedTask;
|
||||||
|
public Task<IReadOnlyList<PlatformSetting>> GetAllAsync() => Task.FromResult<IReadOnlyList<PlatformSetting>>(Array.Empty<PlatformSetting>().ToList());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user