Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10f668fd73 | |||
| 19b7a9a473 | |||
| e443457139 | |||
| edf56c1164 | |||
| b9cd693421 | |||
| d77b3778ac | |||
| a7bf97a2df | |||
| 05935b110a | |||
| 64a9c1531b | |||
| f018653c18 | |||
| b7ab85ff92 | |||
| 15b070398b | |||
| 14f220347b | |||
| baec0b33f7 | |||
| ce7b00b68c | |||
| dfb1d34af3 |
@@ -20,7 +20,7 @@ public class EquipmentDto
|
|||||||
public string StatusDisplay { get; set; } = string.Empty;
|
public string StatusDisplay { get; set; } = string.Empty;
|
||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
|
|
||||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||||
public DateTime? LastMaintenanceDate { get; set; }
|
public DateTime? LastMaintenanceDate { get; set; }
|
||||||
public DateTime? NextScheduledMaintenance { get; set; }
|
public DateTime? NextScheduledMaintenance { get; set; }
|
||||||
public int? DaysUntilMaintenance { get; set; }
|
public int? DaysUntilMaintenance { get; set; }
|
||||||
@@ -101,7 +101,7 @@ public class CreateEquipmentDto
|
|||||||
|
|
||||||
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
|
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
|
||||||
[Display(Name = "Recommended Maintenance Interval (Days)")]
|
[Display(Name = "Recommended Maintenance Interval (Days)")]
|
||||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Last Maintenance Date")]
|
[Display(Name = "Last Maintenance Date")]
|
||||||
public DateTime? LastMaintenanceDate { get; set; }
|
public DateTime? LastMaintenanceDate { get; set; }
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ public class InventoryListDto
|
|||||||
public string? CategoryName { get; set; }
|
public string? CategoryName { get; set; }
|
||||||
public string Category { get; set; } = string.Empty; // Legacy field
|
public string Category { get; set; } = string.Empty; // Legacy field
|
||||||
public string? ColorName { get; set; }
|
public string? ColorName { get; set; }
|
||||||
|
public string? Location { get; set; }
|
||||||
public decimal QuantityOnHand { get; set; }
|
public decimal QuantityOnHand { get; set; }
|
||||||
public string UnitOfMeasure { get; set; } = "lbs";
|
public string UnitOfMeasure { get; set; } = "lbs";
|
||||||
public decimal ReorderPoint { get; set; }
|
public decimal ReorderPoint { get; set; }
|
||||||
|
|||||||
@@ -486,6 +486,7 @@ public class ReworkRecordDto
|
|||||||
public decimal ActualReworkCost { get; set; }
|
public decimal ActualReworkCost { get; set; }
|
||||||
public bool IsBillableToCustomer { get; set; }
|
public bool IsBillableToCustomer { get; set; }
|
||||||
public string? BillingNotes { get; set; }
|
public string? BillingNotes { get; set; }
|
||||||
|
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
|
|
||||||
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
|
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
|
||||||
public string StatusDisplay { get; set; } = string.Empty;
|
public string StatusDisplay { get; set; } = string.Empty;
|
||||||
@@ -511,6 +512,11 @@ public class CreateReworkRecordDto
|
|||||||
public decimal EstimatedReworkCost { get; set; }
|
public decimal EstimatedReworkCost { get; set; }
|
||||||
public bool IsBillableToCustomer { get; set; }
|
public bool IsBillableToCustomer { get; set; }
|
||||||
public string? BillingNotes { get; set; }
|
public string? BillingNotes { get; set; }
|
||||||
|
|
||||||
|
// Rework job creation (opt-in)
|
||||||
|
public bool CreateReworkJob { get; set; }
|
||||||
|
public List<int>? ReworkJobItemIds { get; set; } // null = not creating a job
|
||||||
|
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateReworkRecordDto
|
public class UpdateReworkRecordDto
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ public class CreateVendorDto
|
|||||||
|
|
||||||
[Display(Name = "Default Expense Account")]
|
[Display(Name = "Default Expense Account")]
|
||||||
public int? DefaultExpenseAccountId { get; set; }
|
public int? DefaultExpenseAccountId { get; set; }
|
||||||
|
|
||||||
|
public List<int> CategoryIds { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -209,4 +211,6 @@ public class UpdateVendorDto
|
|||||||
|
|
||||||
[Display(Name = "Default Expense Account")]
|
[Display(Name = "Default Expense Account")]
|
||||||
public int? DefaultExpenseAccountId { get; set; }
|
public int? DefaultExpenseAccountId { get; set; }
|
||||||
|
|
||||||
|
public List<int> CategoryIds { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,7 +196,9 @@ public class JobProfile : Profile
|
|||||||
.ForMember(dest => dest.JobItemDescription,
|
.ForMember(dest => dest.JobItemDescription,
|
||||||
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
|
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
|
||||||
.ForMember(dest => dest.ReworkJobNumber,
|
.ForMember(dest => dest.ReworkJobNumber,
|
||||||
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null));
|
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null))
|
||||||
|
.ForMember(dest => dest.ReworkPricingType,
|
||||||
|
opt => opt.MapFrom(src => src.ReworkPricingType));
|
||||||
|
|
||||||
// Job → JobDto (rework fields)
|
// Job → JobDto (rework fields)
|
||||||
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
|
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public class Equipment : BaseEntity
|
|||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
|
|
||||||
// Maintenance Information
|
// Maintenance Information
|
||||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||||
public DateTime? LastMaintenanceDate { get; set; }
|
public DateTime? LastMaintenanceDate { get; set; }
|
||||||
public DateTime? NextScheduledMaintenance { get; set; }
|
public DateTime? NextScheduledMaintenance { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ public class InventoryCategoryLookup : BaseEntity
|
|||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||||
|
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ public class ReworkRecord : BaseEntity
|
|||||||
public bool IsBillableToCustomer { get; set; }
|
public bool IsBillableToCustomer { get; set; }
|
||||||
public string? BillingNotes { get; set; }
|
public string? BillingNotes { get; set; }
|
||||||
|
|
||||||
|
// Pricing attribution for the linked rework job (null on pre-existing records)
|
||||||
|
public ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
|
|
||||||
// ── Resolution ────────────────────────────────────────────────────────────
|
// ── Resolution ────────────────────────────────────────────────────────────
|
||||||
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
|
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
|
||||||
public ReworkResolution? Resolution { get; set; }
|
public ReworkResolution? Resolution { get; set; }
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public class Vendor : BaseEntity
|
|||||||
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
|
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
|
||||||
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
|
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
|
||||||
public virtual Account? DefaultExpenseAccount { get; set; }
|
public virtual Account? DefaultExpenseAccount { get; set; }
|
||||||
|
public virtual ICollection<InventoryCategoryLookup> Categories { get; set; } = new List<InventoryCategoryLookup>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class InventoryTransaction : BaseEntity
|
public class InventoryTransaction : BaseEntity
|
||||||
|
|||||||
@@ -144,6 +144,14 @@ public enum ReworkResolution
|
|||||||
NoActionRequired = 4
|
NoActionRequired = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Who bears the cost of the rework job, recorded at the time the rework is logged.</summary>
|
||||||
|
public enum ReworkPricingType
|
||||||
|
{
|
||||||
|
ShopFault = 0, // Redo is on the shop — rework job items priced at $0
|
||||||
|
CustomerReduced = 1, // Customer caused it; we're helping — prices copied, user edits
|
||||||
|
CustomerFull = 2 // Customer caused it; full original pricing applies
|
||||||
|
}
|
||||||
|
|
||||||
public enum BugReportStatus
|
public enum BugReportStatus
|
||||||
{
|
{
|
||||||
New = 0,
|
New = 0,
|
||||||
|
|||||||
@@ -92,4 +92,10 @@ public interface IJobRepository : IRepository<Job>
|
|||||||
/// were never completed and rolled past their scheduled day.
|
/// were never completed and rolled past their scheduled day.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<Job>> GetOverdueScheduledJobsAsync();
|
Task<List<Job>> GetOverdueScheduledJobsAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
|
||||||
|
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> GetReworkJobCountAsync(int originalJobId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -809,6 +809,15 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|||||||
.HasForeignKey(s => s.DefaultExpenseAccountId)
|
.HasForeignKey(s => s.DefaultExpenseAccountId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
// Vendor ↔ InventoryCategoryLookup (many-to-many supply categories)
|
||||||
|
modelBuilder.Entity<Vendor>()
|
||||||
|
.HasMany(v => v.Categories)
|
||||||
|
.WithMany(c => c.Vendors)
|
||||||
|
.UsingEntity<Dictionary<string, object>>(
|
||||||
|
"VendorInventoryCategories",
|
||||||
|
j => j.HasOne<InventoryCategoryLookup>().WithMany().HasForeignKey("InventoryCategoryLookupId"),
|
||||||
|
j => j.HasOne<Vendor>().WithMany().HasForeignKey("VendorId"));
|
||||||
|
|
||||||
// Bill → APAccount (no cascade to avoid cycles)
|
// Bill → APAccount (no cascade to avoid cycles)
|
||||||
modelBuilder.Entity<Bill>()
|
modelBuilder.Entity<Bill>()
|
||||||
.HasOne(b => b.APAccount)
|
.HasOne(b => b.APAccount)
|
||||||
|
|||||||
Generated
+10642
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddReworkPricingType : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ReworkPricingType",
|
||||||
|
table: "ReworkRecords",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ReworkPricingType",
|
||||||
|
table: "ReworkRecords");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10672
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddVendorCategories : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "VendorInventoryCategories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
InventoryCategoryLookupId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
VendorId = table.Column<int>(type: "int", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_VendorInventoryCategories", x => new { x.InventoryCategoryLookupId, x.VendorId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VendorInventoryCategories_InventoryCategoryLookups_InventoryCategoryLookupId",
|
||||||
|
column: x => x.InventoryCategoryLookupId,
|
||||||
|
principalTable: "InventoryCategoryLookups",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VendorInventoryCategories_Vendors_VendorId",
|
||||||
|
column: x => x.VendorId,
|
||||||
|
principalTable: "Vendors",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VendorInventoryCategories_VendorId",
|
||||||
|
table: "VendorInventoryCategories",
|
||||||
|
column: "VendorId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "VendorInventoryCategories");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10672
File diff suppressed because it is too large
Load Diff
+79
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MakeMaintenanceIntervalNullable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "RecommendedMaintenanceIntervalDays",
|
||||||
|
table: "Equipment",
|
||||||
|
type: "int",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "int");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "RecommendedMaintenanceIntervalDays",
|
||||||
|
table: "Equipment",
|
||||||
|
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, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3045,7 +3045,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("PurchasePrice")
|
b.Property<decimal>("PurchasePrice")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int>("RecommendedMaintenanceIntervalDays")
|
b.Property<int?>("RecommendedMaintenanceIntervalDays")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("SerialNumber")
|
b.Property<string>("SerialNumber")
|
||||||
@@ -6711,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
|
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6722,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
|
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6733,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
|
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7990,6 +7990,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int?>("ReworkJobId")
|
b.Property<int?>("ReworkJobId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("ReworkPricingType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int>("ReworkType")
|
b.Property<int>("ReworkType")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -8631,6 +8634,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("YearEndCloses");
|
b.ToTable("YearEndCloses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("VendorInventoryCategories", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("InventoryCategoryLookupId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("VendorId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("InventoryCategoryLookupId", "VendorId");
|
||||||
|
|
||||||
|
b.HasIndex("VendorId");
|
||||||
|
|
||||||
|
b.ToTable("VendorInventoryCategories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||||
@@ -10369,6 +10387,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("JournalEntry");
|
b.Navigation("JournalEntry");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("VendorInventoryCategories", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.InventoryCategoryLookup", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InventoryCategoryLookupId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Vendor", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("VendorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("BillLineItems");
|
b.Navigation("BillLineItems");
|
||||||
|
|||||||
@@ -187,6 +187,14 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<int> GetReworkJobCountAsync(int originalJobId)
|
||||||
|
{
|
||||||
|
return await _context.Jobs
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.CountAsync(j => j.OriginalJobId == originalJobId);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<Job>> GetOverdueScheduledJobsAsync()
|
public async Task<List<Job>> GetOverdueScheduledJobsAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1209,6 +1209,15 @@ Rules:
|
|||||||
sb.AppendLine("Page content:");
|
sb.AppendLine("Page content:");
|
||||||
sb.AppendLine(pageContent);
|
sb.AppendLine(pageContent);
|
||||||
}
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(fetchUrl))
|
||||||
|
{
|
||||||
|
// Page content unavailable (fetch failed or blocked) — still surface the URL so Claude
|
||||||
|
// can use its training knowledge of the manufacturer URL structure (e.g. Prismatic SKU
|
||||||
|
// in the path) to infer product identity rather than returning all-null fields.
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Product URL (page content could not be fetched): {fetchUrl}");
|
||||||
|
sb.AppendLine("Use your training knowledge of this manufacturer and the URL to fill in as many fields as possible.");
|
||||||
|
}
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public class InventoryController : Controller
|
|||||||
public async Task<IActionResult> Index(
|
public async Task<IActionResult> Index(
|
||||||
string? searchTerm,
|
string? searchTerm,
|
||||||
string? category,
|
string? category,
|
||||||
|
string? location,
|
||||||
string? sortColumn,
|
string? sortColumn,
|
||||||
string sortDirection = "asc",
|
string sortDirection = "asc",
|
||||||
bool lowStockOnly = false,
|
bool lowStockOnly = false,
|
||||||
@@ -87,50 +88,64 @@ public class InventoryController : Controller
|
|||||||
};
|
};
|
||||||
gridRequest.Validate();
|
gridRequest.Validate();
|
||||||
|
|
||||||
// Build search and category filter
|
// Build filter — compose search, category, location, and low-stock predicates
|
||||||
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
|
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
|
||||||
|
|
||||||
if (lowStockOnly && !string.IsNullOrWhiteSpace(searchTerm))
|
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm);
|
||||||
{
|
var hasCategory = !string.IsNullOrWhiteSpace(category);
|
||||||
var search = searchTerm.ToLower();
|
var hasLocation = !string.IsNullOrWhiteSpace(location);
|
||||||
|
|
||||||
|
var search = searchTerm?.ToLower() ?? "";
|
||||||
|
var cat = category ?? "";
|
||||||
|
var loc = location ?? "";
|
||||||
|
|
||||||
|
if (lowStockOnly && hasSearch && hasLocation)
|
||||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
||||||
&& (i.SKU.ToLower().Contains(search)
|
&& (i.Location != null && i.Location.ToLower() == loc.ToLower())
|
||||||
|| i.Name.ToLower().Contains(search)
|
&& (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
|
||||||
}
|
else if (lowStockOnly && hasSearch)
|
||||||
|
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
||||||
|
&& (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
|
||||||
|
else if (lowStockOnly && hasLocation)
|
||||||
|
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
||||||
|
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||||
else if (lowStockOnly)
|
else if (lowStockOnly)
|
||||||
{
|
|
||||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint;
|
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint;
|
||||||
}
|
else if (hasSearch && hasCategory && hasLocation)
|
||||||
else if (!string.IsNullOrWhiteSpace(searchTerm) && !string.IsNullOrWhiteSpace(category))
|
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
{
|
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||||
// Both search and category filter
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
var search = searchTerm.ToLower();
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
||||||
var cat = category;
|
&& i.Category.ToLower() == cat.ToLower()
|
||||||
filter = i => (i.SKU.ToLower().Contains(search)
|
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||||
|| i.Name.ToLower().Contains(search)
|
else if (hasSearch && hasCategory)
|
||||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
||||||
&& i.Category.ToLower() == cat.ToLower();
|
&& i.Category.ToLower() == cat.ToLower();
|
||||||
}
|
else if (hasSearch && hasLocation)
|
||||||
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
{
|
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||||
// Search only
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
var search = searchTerm.ToLower();
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
||||||
filter = i => i.SKU.ToLower().Contains(search)
|
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||||
|| i.Name.ToLower().Contains(search)
|
else if (hasSearch)
|
||||||
|
filter = i => i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search));
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search));
|
||||||
}
|
else if (hasCategory && hasLocation)
|
||||||
else if (!string.IsNullOrWhiteSpace(category))
|
filter = i => i.Category.ToLower() == cat.ToLower()
|
||||||
{
|
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||||
// Category filter only
|
else if (hasCategory)
|
||||||
var cat = category;
|
|
||||||
filter = i => i.Category.ToLower() == cat.ToLower();
|
filter = i => i.Category.ToLower() == cat.ToLower();
|
||||||
}
|
else if (hasLocation)
|
||||||
|
filter = i => i.Location != null && i.Location.ToLower() == loc.ToLower();
|
||||||
|
|
||||||
// Build orderBy function
|
// Build orderBy function
|
||||||
Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch
|
Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch
|
||||||
@@ -159,19 +174,21 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
|
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
|
||||||
|
|
||||||
// Load all items once to compute sidebar stats and category list in memory
|
// Load all items once to compute sidebar stats and dropdown option lists in memory
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||||
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
||||||
|
ViewBag.Locations = allItems.Select(i => i.Location).Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().OrderBy(l => l).ToList();
|
||||||
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||||
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
||||||
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
||||||
|
|
||||||
// Set ViewBag for sorting and filters
|
// Set ViewBag for sorting and filters
|
||||||
ViewBag.SearchTerm = searchTerm;
|
ViewBag.SearchTerm = searchTerm;
|
||||||
ViewBag.Category = category;
|
ViewBag.Category = category;
|
||||||
|
ViewBag.Location = location;
|
||||||
ViewBag.LowStockOnly = lowStockOnly;
|
ViewBag.LowStockOnly = lowStockOnly;
|
||||||
ViewBag.SortColumn = gridRequest.SortColumn;
|
ViewBag.SortColumn = gridRequest.SortColumn;
|
||||||
ViewBag.SortDirection = gridRequest.SortDirection;
|
ViewBag.SortDirection = gridRequest.SortDirection;
|
||||||
|
|
||||||
return View(pagedResult);
|
return View(pagedResult);
|
||||||
@@ -184,6 +201,26 @@ public class InventoryController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a print-optimised list of all active inventory items in a given bin/location.
|
||||||
|
/// Renders without the site chrome (no layout) so the browser print dialog produces a
|
||||||
|
/// clean page. Items are sorted by name within the bin.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> PrintBin(string location)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(location))
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
|
||||||
|
var loc = location.Trim();
|
||||||
|
var items = await _unitOfWork.InventoryItems.FindAsync(
|
||||||
|
i => i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||||
|
|
||||||
|
var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
|
||||||
|
ViewBag.Location = loc;
|
||||||
|
ViewBag.PrintedAt = DateTime.Now;
|
||||||
|
return View(dtos);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders the inventory item detail page. The primary vendor name is looked up
|
/// Renders the inventory item detail page. The primary vendor name is looked up
|
||||||
/// separately because the repository does not eager-load Vendor by default, avoiding
|
/// separately because the repository does not eager-load Vendor by default, avoiding
|
||||||
@@ -897,10 +934,31 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(qrUrl))
|
if (!string.IsNullOrWhiteSpace(qrUrl))
|
||||||
{
|
{
|
||||||
// QR path: fetch the product page; LookupByUrlAsync now maps all identity + spec fields
|
// QR path: try to extract product identity from the URL using manufacturer patterns.
|
||||||
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
|
// Prismatic Powders URLs embed both the SKU and color slug in the path
|
||||||
if (aiResult.Success && aiResult.SpecPageUrl == null)
|
// (e.g. /shop/powder-coating-colors/PMB-6906/fire-red), so we can feed the full
|
||||||
aiResult.SpecPageUrl = qrUrl;
|
// LookupAsync pipeline (Serper + direct URL + Claude) with real context instead of
|
||||||
|
// relying solely on a page fetch that may fail in production.
|
||||||
|
var activePatterns = (await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true))
|
||||||
|
.Where(p => p.IsActive).ToList();
|
||||||
|
var (urlMfr, urlColor, urlPart) = TryParseManufacturerUrl(qrUrl, activePatterns);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(urlMfr))
|
||||||
|
{
|
||||||
|
aiResult = await _aiLookupService.LookupAsync(urlMfr, urlColor, null, urlPart);
|
||||||
|
// The scanned QR URL is always the authoritative product page link — it came
|
||||||
|
// directly from the manufacturer's bag and is always fully-qualified. Overwrite
|
||||||
|
// whatever LookupAsync returned (which may be a scheme-less path from the template).
|
||||||
|
if (aiResult.Success)
|
||||||
|
aiResult.SpecPageUrl = qrUrl;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No pattern match — fall back to URL-based lookup
|
||||||
|
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
|
||||||
|
if (aiResult.Success && aiResult.SpecPageUrl == null)
|
||||||
|
aiResult.SpecPageUrl = qrUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrWhiteSpace(imageBase64))
|
else if (!string.IsNullOrWhiteSpace(imageBase64))
|
||||||
{
|
{
|
||||||
@@ -1272,6 +1330,72 @@ public class InventoryController : Controller
|
|||||||
return transferEfficiency ?? DefaultTransferEfficiency;
|
return transferEfficiency ?? DefaultTransferEfficiency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reverse-parses a scanned QR URL against known manufacturer URL templates to extract
|
||||||
|
/// product identity (manufacturer name, color name, part number). For example, a Prismatic
|
||||||
|
/// Powders URL like /shop/powder-coating-colors/PMB-6906/fire-red yields manufacturer
|
||||||
|
/// "Prismatic Powders", partNumber "PMB-6906", and colorName "Fire Red". Returns nulls
|
||||||
|
/// when no active pattern matches the URL domain.
|
||||||
|
/// </summary>
|
||||||
|
private static (string? manufacturer, string? colorName, string? partNumber) TryParseManufacturerUrl(
|
||||||
|
string url, IEnumerable<Core.Entities.ManufacturerLookupPattern> patterns)
|
||||||
|
{
|
||||||
|
Uri? parsed;
|
||||||
|
try { parsed = new Uri(url); } catch { return (null, null, null); }
|
||||||
|
|
||||||
|
var host = parsed.Host.Replace("www.", "", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var pattern in patterns.Where(p => !string.IsNullOrEmpty(p.Domain) && !string.IsNullOrEmpty(p.ProductUrlTemplate)))
|
||||||
|
{
|
||||||
|
if (!host.Equals(pattern.Domain, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
var template = pattern.ProductUrlTemplate!;
|
||||||
|
var placeholderKeys = new[] { "{partNumber}", "{slug}", "{colorName}", "{colorCode}" };
|
||||||
|
|
||||||
|
// Find the first placeholder to split off the static prefix
|
||||||
|
var firstIdx = placeholderKeys
|
||||||
|
.Select(ph => template.IndexOf(ph, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(i => i >= 0)
|
||||||
|
.DefaultIfEmpty(-1)
|
||||||
|
.Min();
|
||||||
|
|
||||||
|
if (firstIdx < 0) continue;
|
||||||
|
|
||||||
|
var staticPrefix = template[..firstIdx].TrimEnd('/');
|
||||||
|
var fullUrl = url.TrimEnd('/');
|
||||||
|
if (!fullUrl.StartsWith(staticPrefix, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
var templateSegments = template[firstIdx..].Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var urlSegments = fullUrl[staticPrefix.Length..].Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
var extracted = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
bool matched = true;
|
||||||
|
for (int i = 0; i < templateSegments.Length && i < urlSegments.Length; i++)
|
||||||
|
{
|
||||||
|
var seg = templateSegments[i];
|
||||||
|
if (seg.StartsWith("{") && seg.EndsWith("}"))
|
||||||
|
extracted[seg[1..^1]] = urlSegments[i];
|
||||||
|
else if (!seg.Equals(urlSegments[i], StringComparison.OrdinalIgnoreCase))
|
||||||
|
{ matched = false; break; }
|
||||||
|
}
|
||||||
|
if (!matched) continue;
|
||||||
|
|
||||||
|
extracted.TryGetValue("partNumber", out var partNumber);
|
||||||
|
var slug = extracted.TryGetValue("slug", out var s) ? s
|
||||||
|
: extracted.TryGetValue("colorName", out var cn) ? cn
|
||||||
|
: extracted.TryGetValue("colorCode", out var cc) ? cc : null;
|
||||||
|
|
||||||
|
// Convert URL slug to display name: "fire-red" → "Fire Red"
|
||||||
|
string? colorName = slug == null ? null
|
||||||
|
: string.Join(" ", slug.Split(new[] { '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(w => w.Length > 0 ? char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant() : w));
|
||||||
|
|
||||||
|
return (pattern.ManufacturerName, colorName, partNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Normalizes a string to title-case using the current culture's TextInfo. Applied to
|
/// Normalizes a string to title-case using the current culture's TextInfo. Applied to
|
||||||
/// inventory item names on create and edit so the list view is consistently formatted
|
/// inventory item names on create and edit so the list view is consistently formatted
|
||||||
@@ -1371,8 +1495,20 @@ public class InventoryController : Controller
|
|||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
|
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
|
||||||
|
|
||||||
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
|
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive, false, v => v.Categories))
|
||||||
ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
|
.OrderBy(v => v.CompanyName).ToList();
|
||||||
|
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
|
||||||
|
|
||||||
|
// Build {categoryId: [vendorId, ...]} so the inventory form can filter vendors by category
|
||||||
|
var categoryVendorMap = new Dictionary<string, List<int>>();
|
||||||
|
foreach (var v in vendors)
|
||||||
|
foreach (var cat in v.Categories)
|
||||||
|
{
|
||||||
|
var key = cat.Id.ToString();
|
||||||
|
if (!categoryVendorMap.ContainsKey(key)) categoryVendorMap[key] = new List<int>();
|
||||||
|
categoryVendorMap[key].Add(v.Id);
|
||||||
|
}
|
||||||
|
ViewBag.CategoryVendorMapJson = System.Text.Json.JsonSerializer.Serialize(categoryVendorMap);
|
||||||
|
|
||||||
// Load categories from lookup table
|
// Load categories from lookup table
|
||||||
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
@@ -1499,11 +1635,12 @@ public class InventoryController : Controller
|
|||||||
/// Renders a print-optimised label for the inventory item containing the QR code,
|
/// Renders a print-optimised label for the inventory item containing the QR code,
|
||||||
/// item name, SKU, and colour. Designed to be printed directly from the browser.
|
/// item name, SKU, and colour. Designed to be printed directly from the browser.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Label(int? id)
|
public async Task<IActionResult> Label(int? id, bool embed = false)
|
||||||
{
|
{
|
||||||
if (id == null) return NotFound();
|
if (id == null) return NotFound();
|
||||||
var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value);
|
var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value);
|
||||||
if (item == null) return NotFound();
|
if (item == null) return NotFound();
|
||||||
|
ViewBag.IsEmbed = embed;
|
||||||
return View(_mapper.Map<InventoryItemDto>(item));
|
return View(_mapper.Map<InventoryItemDto>(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2407,6 +2407,28 @@ public class JobsController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a rework job reaches a terminal status, close out the linked ReworkRecord
|
||||||
|
// on the original job so the shop doesn't have to do it manually.
|
||||||
|
// Cancelled → WrittenOff; any other terminal → Resolved.
|
||||||
|
if (newStatus?.IsTerminalStatus == true && job.IsReworkJob)
|
||||||
|
{
|
||||||
|
var linkedRecords = await _unitOfWork.ReworkRecords.FindAsync(
|
||||||
|
r => r.ReworkJobId == job.Id && r.CompanyId == job.CompanyId, false);
|
||||||
|
foreach (var rr in linkedRecords)
|
||||||
|
{
|
||||||
|
if (rr.Status == ReworkStatus.Resolved || rr.Status == ReworkStatus.WrittenOff)
|
||||||
|
continue;
|
||||||
|
rr.Status = newStatus.StatusCode == AppConstants.StatusCodes.Job.Cancelled
|
||||||
|
? ReworkStatus.WrittenOff
|
||||||
|
: ReworkStatus.Resolved;
|
||||||
|
rr.ResolvedDate ??= DateTime.UtcNow;
|
||||||
|
rr.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.ReworkRecords.UpdateAsync(rr);
|
||||||
|
}
|
||||||
|
if (linkedRecords.Any())
|
||||||
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// Notify customer on status change (only if user opted in)
|
// Notify customer on status change (only if user opted in)
|
||||||
if (request.SendEmail && newStatus != null)
|
if (request.SendEmail && newStatus != null)
|
||||||
{
|
{
|
||||||
@@ -3528,10 +3550,13 @@ public class JobsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records a rework event against a job item (e.g. defect found during QC).
|
/// Records a rework event against a job. Optionally creates a linked rework job so the
|
||||||
/// Automatically creates a new linked rework Job so the repair work can be tracked
|
/// repair can flow through the full shop lifecycle. When creating a rework job:
|
||||||
/// through the same job lifecycle. The rework job inherits the original job's customer,
|
/// - Job number uses sub-number format: {parentNumber}-R{n} (e.g. JOB-2605-0007-R1)
|
||||||
/// oven, and items so the shop has a complete specification to work from.
|
/// - Only items selected by the user are copied (partial rework support)
|
||||||
|
/// - Pricing obeys the ReworkPricingType: ShopFault zeros all item prices;
|
||||||
|
/// CustomerReduced/CustomerFull copy prices as-is (user edits after if needed)
|
||||||
|
/// - Job starts at the first non-Pending status in the company's workflow
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
|
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
|
||||||
@@ -3540,95 +3565,207 @@ public class JobsController : Controller
|
|||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
var companyId = job.CompanyId;
|
var companyId = job.CompanyId;
|
||||||
|
Job? reworkJob = null;
|
||||||
|
|
||||||
// Generate rework job number
|
if (dto.CreateReworkJob && dto.ReworkJobItemIds != null && dto.ReworkJobItemIds.Count > 0 && dto.ReworkPricingType.HasValue)
|
||||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
|
||||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
|
||||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
|
||||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
|
||||||
|
|
||||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
|
|
||||||
var year = DateTime.Now.ToString("yy");
|
|
||||||
var month = DateTime.Now.ToString("MM");
|
|
||||||
var prefix = $"JOB-{year}{month}-";
|
|
||||||
var maxNum = allJobs
|
|
||||||
.Where(j => j.JobNumber.StartsWith(prefix))
|
|
||||||
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
|
|
||||||
.DefaultIfEmpty(0).Max();
|
|
||||||
|
|
||||||
var reworkJob = pendingStatus != null ? new Job
|
|
||||||
{
|
{
|
||||||
JobNumber = $"{prefix}{(maxNum + 1):D4}",
|
var typeLabel = dto.ReworkType switch
|
||||||
CustomerId = job.CustomerId,
|
|
||||||
Description = $"REWORK: {job.Description}",
|
|
||||||
JobStatusId = pendingStatus.Id,
|
|
||||||
JobPriorityId = normalPriority.Id,
|
|
||||||
IsReworkJob = true,
|
|
||||||
OriginalJobId = job.Id,
|
|
||||||
SpecialInstructions = $"Rework of {job.JobNumber}.",
|
|
||||||
CompanyId = companyId,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
if (reworkJob != null)
|
|
||||||
{
|
|
||||||
await _unitOfWork.Jobs.AddAsync(reworkJob);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
// Copy items: specific item if flagged, otherwise all items
|
|
||||||
var itemsToCopy = dto.JobItemId.HasValue
|
|
||||||
? job.JobItems.Where(i => i.Id == dto.JobItemId.Value).ToList()
|
|
||||||
: job.JobItems.ToList();
|
|
||||||
|
|
||||||
foreach (var item in itemsToCopy)
|
|
||||||
{
|
{
|
||||||
var createdAtUtc = DateTime.UtcNow;
|
ReworkType.InternalDefect => "Internal Defect",
|
||||||
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
|
ReworkType.CustomerWarranty => "Customer Warranty",
|
||||||
|
ReworkType.CustomerDamage => "Customer Damage",
|
||||||
|
_ => dto.ReworkType.ToString()
|
||||||
|
};
|
||||||
|
var reasonLabel = dto.Reason switch
|
||||||
|
{
|
||||||
|
ReworkReason.AdhesionFailure => "Adhesion Failure",
|
||||||
|
ReworkReason.Contamination => "Contamination",
|
||||||
|
ReworkReason.ColorMismatch => "Color Mismatch",
|
||||||
|
ReworkReason.RunsSags => "Runs / Sags",
|
||||||
|
ReworkReason.SurfacePrepFailure => "Surface Prep Failure",
|
||||||
|
ReworkReason.OvenIssue => "Oven Issue",
|
||||||
|
ReworkReason.InsufficientCoverage => "Insufficient Coverage",
|
||||||
|
ReworkReason.HandlingDamage => "Handling Damage",
|
||||||
|
_ => "Other"
|
||||||
|
};
|
||||||
|
var pricingLabel = dto.ReworkPricingType.Value switch
|
||||||
|
{
|
||||||
|
ReworkPricingType.ShopFault => "Shop Fault — no charge",
|
||||||
|
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
|
||||||
|
ReworkPricingType.CustomerFull => "Customer responsible — full price",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
var defect = string.IsNullOrWhiteSpace(dto.DefectDescription) ? "" : $": {dto.DefectDescription}";
|
||||||
|
var reworkDescription = $"REWORK ({typeLabel} / {reasonLabel}){defect}. Pricing: {pricingLabel}.";
|
||||||
|
|
||||||
await _unitOfWork.JobItems.AddAsync(newItem);
|
var currentUserId = _userManager.GetUserId(User);
|
||||||
await _unitOfWork.CompleteAsync();
|
reworkJob = await BuildReworkJobAsync(job, dto.ReworkJobItemIds, dto.ReworkPricingType.Value, companyId, reworkDescription, currentUserId);
|
||||||
|
|
||||||
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
|
|
||||||
{
|
|
||||||
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
|
|
||||||
{
|
|
||||||
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var record = new ReworkRecord
|
var record = new ReworkRecord
|
||||||
{
|
{
|
||||||
JobId = dto.JobId,
|
JobId = dto.JobId,
|
||||||
JobItemId = dto.JobItemId,
|
JobItemId = dto.JobItemId,
|
||||||
ReworkType = dto.ReworkType,
|
ReworkType = dto.ReworkType,
|
||||||
Reason = dto.Reason,
|
Reason = dto.Reason,
|
||||||
DefectDescription = dto.DefectDescription,
|
DefectDescription = dto.DefectDescription,
|
||||||
DiscoveredBy = dto.DiscoveredBy,
|
DiscoveredBy = dto.DiscoveredBy,
|
||||||
DiscoveredDate = dto.DiscoveredDate,
|
DiscoveredDate = dto.DiscoveredDate,
|
||||||
ReportedByName = dto.ReportedByName,
|
ReportedByName = dto.ReportedByName,
|
||||||
EstimatedReworkCost = dto.EstimatedReworkCost,
|
EstimatedReworkCost = dto.EstimatedReworkCost,
|
||||||
IsBillableToCustomer = dto.IsBillableToCustomer,
|
IsBillableToCustomer = dto.IsBillableToCustomer,
|
||||||
BillingNotes = dto.BillingNotes,
|
BillingNotes = dto.BillingNotes,
|
||||||
ReworkJobId = reworkJob?.Id,
|
ReworkPricingType = dto.ReworkPricingType,
|
||||||
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
|
ReworkJobId = reworkJob?.Id,
|
||||||
CompanyId = companyId,
|
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
|
||||||
CreatedAt = DateTime.UtcNow
|
CompanyId = companyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
await _unitOfWork.ReworkRecords.AddAsync(record);
|
await _unitOfWork.ReworkRecords.AddAsync(record);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// Reload with navigation for response
|
|
||||||
var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob);
|
var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob);
|
||||||
return Json(_mapper.Map<ReworkRecordDto>(saved.First()));
|
return Json(_mapper.Map<ReworkRecordDto>(saved.First()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a linked rework Job from an existing rework record that was saved without one.
|
||||||
|
/// Uses sub-number format and applies the specified pricing attribution.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
|
||||||
|
{
|
||||||
|
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
|
||||||
|
if (reworkRecord == null) return NotFound();
|
||||||
|
|
||||||
|
var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId);
|
||||||
|
if (originalJob == null) return NotFound();
|
||||||
|
|
||||||
|
var companyId = originalJob.CompanyId;
|
||||||
|
var itemIds = req.ItemIds ?? originalJob.JobItems.Select(i => i.Id).ToList();
|
||||||
|
var pricingType = req.ReworkPricingType ?? ReworkPricingType.ShopFault;
|
||||||
|
|
||||||
|
var pricingLabel = pricingType switch
|
||||||
|
{
|
||||||
|
ReworkPricingType.ShopFault => "Shop Fault — no charge",
|
||||||
|
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
|
||||||
|
ReworkPricingType.CustomerFull => "Customer responsible — full price",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
var notes = string.IsNullOrWhiteSpace(req.Notes) ? "" : $" Notes: {req.Notes}";
|
||||||
|
var reworkDescription = $"REWORK: {pricingLabel}.{notes}";
|
||||||
|
var currentUserId = _userManager.GetUserId(User);
|
||||||
|
var reworkJob = await BuildReworkJobAsync(originalJob, itemIds, pricingType, companyId, reworkDescription, currentUserId);
|
||||||
|
|
||||||
|
reworkRecord.ReworkJobId = reworkJob.Id;
|
||||||
|
reworkRecord.ReworkPricingType = pricingType;
|
||||||
|
reworkRecord.Status = ReworkStatus.InProgress;
|
||||||
|
reworkRecord.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared helper that creates and persists a rework Job with sub-numbered job number,
|
||||||
|
/// copies the specified items (with coats and prep services), applies pricing attribution,
|
||||||
|
/// sets descriptive job description from the rework record data, and auto-records intake
|
||||||
|
/// (parts are already on hand when rework is logged).
|
||||||
|
/// Called by both AddReworkRecord and CreateReworkJob.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Job> BuildReworkJobAsync(
|
||||||
|
Job originalJob,
|
||||||
|
List<int> itemIds,
|
||||||
|
ReworkPricingType pricingType,
|
||||||
|
int companyId,
|
||||||
|
string reworkDescription,
|
||||||
|
string? checkedByUserId)
|
||||||
|
{
|
||||||
|
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||||
|
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
||||||
|
|
||||||
|
// First non-Pending status by workflow order
|
||||||
|
var firstActiveStatus = statuses
|
||||||
|
.Where(s => s.StatusCode != AppConstants.StatusCodes.Job.Pending)
|
||||||
|
.OrderBy(s => s.DisplayOrder)
|
||||||
|
.First();
|
||||||
|
|
||||||
|
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
||||||
|
|
||||||
|
// Sub-number: {parentJobNumber}-R{n+1}
|
||||||
|
var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id);
|
||||||
|
var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}";
|
||||||
|
|
||||||
|
var reworkJob = new Job
|
||||||
|
{
|
||||||
|
JobNumber = reworkNumber,
|
||||||
|
CustomerId = originalJob.CustomerId,
|
||||||
|
Description = reworkDescription,
|
||||||
|
JobStatusId = firstActiveStatus.Id,
|
||||||
|
JobPriorityId = normalPriority.Id,
|
||||||
|
IsReworkJob = true,
|
||||||
|
OriginalJobId = originalJob.Id,
|
||||||
|
SpecialInstructions = $"Rework of {originalJob.JobNumber}.",
|
||||||
|
// Auto-intake: parts are already on hand when rework is logged
|
||||||
|
IntakeDate = DateTime.UtcNow,
|
||||||
|
IntakeConditionNotes = $"Parts auto-checked in as rework from {originalJob.JobNumber}.",
|
||||||
|
IntakeCheckedByUserId = checkedByUserId,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.Jobs.AddAsync(reworkJob);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
var itemsToCopy = originalJob.JobItems.Where(i => itemIds.Contains(i.Id)).ToList();
|
||||||
|
var createdAtUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
foreach (var item in itemsToCopy)
|
||||||
|
{
|
||||||
|
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
|
||||||
|
|
||||||
|
// Shop-fault rework jobs are done at no charge
|
||||||
|
if (pricingType == ReworkPricingType.ShopFault)
|
||||||
|
{
|
||||||
|
newItem.UnitPrice = 0;
|
||||||
|
newItem.ManualUnitPrice = 0;
|
||||||
|
newItem.TotalPrice = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _unitOfWork.JobItems.AddAsync(newItem);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
|
||||||
|
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
||||||
|
|
||||||
|
foreach (var prep in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
|
||||||
|
await _unitOfWork.JobItemPrepServices.AddAsync(prep);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set intake part count now that items are known
|
||||||
|
reworkJob.IntakePartCount = (int)Math.Ceiling(itemsToCopy.Sum(i => i.Quantity));
|
||||||
|
|
||||||
|
// Write a pricing snapshot so the Details page and inline edit both work correctly
|
||||||
|
var itemsSubtotal = pricingType == ReworkPricingType.ShopFault
|
||||||
|
? 0m
|
||||||
|
: itemsToCopy.Sum(i => i.TotalPrice);
|
||||||
|
reworkJob.FinalPrice = itemsSubtotal;
|
||||||
|
reworkJob.PricingBreakdownJson = System.Text.Json.JsonSerializer.Serialize(new QuotePricingBreakdownDto
|
||||||
|
{
|
||||||
|
ItemsSubtotal = itemsSubtotal,
|
||||||
|
SubtotalBeforeDiscount = itemsSubtotal,
|
||||||
|
SubtotalAfterDiscount = itemsSubtotal,
|
||||||
|
Total = itemsSubtotal
|
||||||
|
});
|
||||||
|
|
||||||
|
await _unitOfWork.Jobs.UpdateAsync(reworkJob);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return reworkJob;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates a rework record's status, resolution notes, cost, and billability.
|
/// Updates a rework record's status, resolution notes, cost, and billability.
|
||||||
/// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set).
|
/// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set).
|
||||||
@@ -3680,66 +3817,6 @@ public class JobsController : Controller
|
|||||||
return Json(new { success = true });
|
return Json(new { success = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new rework Job from an existing rework record and links them.
|
|
||||||
/// The rework job is a lightweight clone of the original job — same customer, description, and
|
|
||||||
/// oven — but starts fresh with Pending status so it goes through the full workflow again.
|
|
||||||
/// The ReworkJob FK on the rework record is updated so the Detail view can link to it.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
|
|
||||||
{
|
|
||||||
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
|
|
||||||
if (reworkRecord == null) return NotFound();
|
|
||||||
|
|
||||||
var originalJob = reworkRecord.Job;
|
|
||||||
var companyId = originalJob.CompanyId;
|
|
||||||
|
|
||||||
// Load status lookups to find Pending status
|
|
||||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
|
||||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
|
||||||
if (pendingStatus == null) return Json(new { success = false, message = "Could not find Pending status." });
|
|
||||||
|
|
||||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
|
||||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
|
||||||
|
|
||||||
// Generate job number
|
|
||||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
|
|
||||||
var year = DateTime.Now.ToString("yy");
|
|
||||||
var month = DateTime.Now.ToString("MM");
|
|
||||||
var prefix = $"JOB-{year}{month}-";
|
|
||||||
var maxNum = allJobs
|
|
||||||
.Where(j => j.JobNumber.StartsWith(prefix))
|
|
||||||
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
|
|
||||||
.DefaultIfEmpty(0).Max();
|
|
||||||
|
|
||||||
var reworkJob = new Job
|
|
||||||
{
|
|
||||||
JobNumber = $"{prefix}{(maxNum + 1):D4}",
|
|
||||||
CustomerId = originalJob.CustomerId,
|
|
||||||
Description = $"REWORK: {originalJob.Description}",
|
|
||||||
JobStatusId = pendingStatus.Id,
|
|
||||||
JobPriorityId = normalPriority.Id,
|
|
||||||
IsReworkJob = true,
|
|
||||||
OriginalJobId = originalJob.Id,
|
|
||||||
SpecialInstructions = $"Rework of {originalJob.JobNumber}. {req.Notes}".Trim().TrimEnd('.') + ".",
|
|
||||||
CompanyId = companyId,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
await _unitOfWork.Jobs.AddAsync(reworkJob);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
// Link rework record to new job
|
|
||||||
reworkRecord.ReworkJobId = reworkJob.Id;
|
|
||||||
reworkRecord.Status = ReworkStatus.InProgress;
|
|
||||||
reworkRecord.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Quote-Changed Banner Actions ──────────────────────────────────────────
|
// ── Quote-Changed Banner Actions ──────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -4311,7 +4388,13 @@ public class LogMaterialRequest
|
|||||||
public string TransactionType { get; set; } = "JobUsage";
|
public string TransactionType { get; set; } = "JobUsage";
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
}
|
}
|
||||||
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
|
public class CreateReworkJobRequest
|
||||||
|
{
|
||||||
|
public int ReworkRecordId { get; set; }
|
||||||
|
public List<int>? ItemIds { get; set; }
|
||||||
|
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class UpdateWorkerAssignmentRequest
|
public class UpdateWorkerAssignmentRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ public class MaintenanceController : Controller
|
|||||||
// Calculate next scheduled maintenance
|
// Calculate next scheduled maintenance
|
||||||
if (equipment.RecommendedMaintenanceIntervalDays > 0)
|
if (equipment.RecommendedMaintenanceIntervalDays > 0)
|
||||||
{
|
{
|
||||||
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays);
|
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
equipment.UpdatedAt = DateTime.UtcNow;
|
equipment.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ public class VendorsController : Controller
|
|||||||
public async Task<IActionResult> Create(bool inline = false)
|
public async Task<IActionResult> Create(bool inline = false)
|
||||||
{
|
{
|
||||||
await PopulateExpenseAccountsAsync();
|
await PopulateExpenseAccountsAsync();
|
||||||
|
await PopulateVendorCategoriesAsync();
|
||||||
if (inline)
|
if (inline)
|
||||||
return PartialView(new CreateVendorDto());
|
return PartialView(new CreateVendorDto());
|
||||||
return View(new CreateVendorDto());
|
return View(new CreateVendorDto());
|
||||||
@@ -207,6 +208,7 @@ public class VendorsController : Controller
|
|||||||
return Json(new { success = false, errors });
|
return Json(new { success = false, errors });
|
||||||
}
|
}
|
||||||
await PopulateExpenseAccountsAsync();
|
await PopulateExpenseAccountsAsync();
|
||||||
|
await PopulateVendorCategoriesAsync(dto.CategoryIds);
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +218,12 @@ public class VendorsController : Controller
|
|||||||
var vendor = _mapper.Map<Vendor>(dto);
|
var vendor = _mapper.Map<Vendor>(dto);
|
||||||
vendor.CompanyId = currentUser!.CompanyId;
|
vendor.CompanyId = currentUser!.CompanyId;
|
||||||
|
|
||||||
|
if (dto.CategoryIds.Any())
|
||||||
|
{
|
||||||
|
var cats = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => dto.CategoryIds.Contains(c.Id));
|
||||||
|
vendor.Categories = cats.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
await _unitOfWork.Vendors.AddAsync(vendor);
|
await _unitOfWork.Vendors.AddAsync(vendor);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
@@ -247,14 +255,16 @@ public class VendorsController : Controller
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value);
|
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value, false, v => v.Categories);
|
||||||
if (vendor == null)
|
if (vendor == null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var dto = _mapper.Map<UpdateVendorDto>(vendor);
|
var dto = _mapper.Map<UpdateVendorDto>(vendor);
|
||||||
|
dto.CategoryIds = vendor.Categories.Select(c => c.Id).ToList();
|
||||||
await PopulateExpenseAccountsAsync();
|
await PopulateExpenseAccountsAsync();
|
||||||
|
await PopulateVendorCategoriesAsync(dto.CategoryIds);
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -282,18 +292,27 @@ public class VendorsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
await PopulateExpenseAccountsAsync();
|
await PopulateExpenseAccountsAsync();
|
||||||
|
await PopulateVendorCategoriesAsync(dto.CategoryIds);
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id);
|
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id, false, v => v.Categories);
|
||||||
if (vendor == null)
|
if (vendor == null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
_mapper.Map(dto, vendor);
|
_mapper.Map(dto, vendor);
|
||||||
|
|
||||||
|
vendor.Categories.Clear();
|
||||||
|
if (dto.CategoryIds.Any())
|
||||||
|
{
|
||||||
|
var cats = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => dto.CategoryIds.Contains(c.Id));
|
||||||
|
foreach (var cat in cats) vendor.Categories.Add(cat);
|
||||||
|
}
|
||||||
|
|
||||||
await _unitOfWork.Vendors.UpdateAsync(vendor);
|
await _unitOfWork.Vendors.UpdateAsync(vendor);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
@@ -413,6 +432,20 @@ public class VendorsController : Controller
|
|||||||
/// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so
|
/// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so
|
||||||
/// the field is optional — not every vendor needs a default account pre-set.
|
/// the field is optional — not every vendor needs a default account pre-set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Populates ViewBag.VendorCategories with active inventory categories for the checkbox list,
|
||||||
|
/// and ViewBag.SelectedCategoryIds with the IDs already assigned to the vendor being edited.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PopulateVendorCategoriesAsync(IEnumerable<int>? selectedIds = null)
|
||||||
|
{
|
||||||
|
var companyId = (await _userManager.GetUserAsync(User))!.CompanyId;
|
||||||
|
var cats = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId && c.IsActive))
|
||||||
|
.OrderBy(c => c.DisplayOrder)
|
||||||
|
.ToList();
|
||||||
|
ViewBag.VendorCategories = cats;
|
||||||
|
ViewBag.SelectedCategoryIds = (selectedIds ?? Enumerable.Empty<int>()).ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task PopulateExpenseAccountsAsync()
|
private async Task PopulateExpenseAccountsAsync()
|
||||||
{
|
{
|
||||||
var accounts = (await _unitOfWork.Accounts.FindAsync(
|
var accounts = (await _unitOfWork.Accounts.FindAsync(
|
||||||
|
|||||||
@@ -301,7 +301,18 @@ public static class HelpKnowledgeBase
|
|||||||
|
|
||||||
**Time Entries:** Track labor time on a job from the Details page.
|
**Time Entries:** Track labor time on a job from the Details page.
|
||||||
|
|
||||||
**Rework:** If a job needs to be redone, a rework record can be created from the Details page. Tracks rework type, reason, and resolution.
|
**Rework / Redo:** Rework and redo mean the same thing in this system. If a finished part fails QA or a customer brings it back damaged, open the original job's Details page and use the **Rework Log** section to record it.
|
||||||
|
|
||||||
|
Each rework entry captures: the type (internal defect, customer damage, warranty), the reason (adhesion failure, color mismatch, runs/sags, insufficient coverage, etc.), a defect description, who discovered it, and pricing responsibility.
|
||||||
|
|
||||||
|
**Pricing responsibility options:**
|
||||||
|
- **Shop Fault — no charge:** All rework job item prices are set to $0. Use this when the defect is the shop's fault.
|
||||||
|
- **Customer responsible — reduced rate:** Item prices are copied from the original job. Edit them down after the rework job is created.
|
||||||
|
- **Customer responsible — full price:** Item prices are copied from the original job as-is.
|
||||||
|
|
||||||
|
**Creating a rework job:** Toggle "Parts are back — create a Rework Job" in the log form. Select which items need to be redone and choose the pricing responsibility. The system creates a new job with a sub-number (e.g., JOB-2605-0001-R1), copies the selected items with their coats and prep services, and auto-records intake (parts are already on hand). The rework job description shows the defect type, reason, and pricing at the top of the job where it is easy to see.
|
||||||
|
|
||||||
|
**Rework job completion:** When the rework job reaches a terminal status (Completed, Delivered, etc.), the linked rework record on the original job is automatically marked as **Resolved**. If the rework job is Cancelled instead, the record is marked **Written Off**. No manual follow-up on the original job is needed.
|
||||||
|
|
||||||
**Job Templates:** [/JobTemplates](/JobTemplates) — Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items.
|
**Job Templates:** [/JobTemplates](/JobTemplates) — Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items.
|
||||||
|
|
||||||
@@ -455,13 +466,21 @@ public static class HelpKnowledgeBase
|
|||||||
Filter by item, date range, and transaction type. Summary pills show total lbs received, used, and net adjustments. Access from the sidebar ("Inventory Activity") or via "View Activity History" on any item's Details page (pre-filtered to that item).
|
Filter by item, date range, and transaction type. Summary pills show total lbs received, used, and net adjustments. Access from the sidebar ("Inventory Activity") or via "View Activity History" on any item's Details page (pre-filtered to that item).
|
||||||
|
|
||||||
**QR Code Labels & Mobile Usage Logging:**
|
**QR Code Labels & Mobile Usage Logging:**
|
||||||
- Print a QR label from any item's Details page → click "Print QR Label" → opens a standalone print page with the item name, SKU, colour, and QR code. Print and stick on the bag/bin.
|
- Print a QR label from any item's Details page OR from the QR icon in the Actions column on the Inventory list page → click "Print QR Label" → a preview modal opens with the label (item name, SKU, colour, finish, and QR code). Click "Print Label" inside the modal to send to your printer without opening a new tab.
|
||||||
|
- You can also print from the list page without opening the item: click the QR icon (first button in the action column) next to any row.
|
||||||
- Scan the QR code with a phone camera → opens the mobile-friendly Log Usage page at /Inventory/Scan/[item-id]
|
- Scan the QR code with a phone camera → opens the mobile-friendly Log Usage page at /Inventory/Scan/[item-id]
|
||||||
- On the scan page: select a job (My Jobs shows jobs assigned to the logged-in user; Other Jobs shows all open jobs; No Job logs without a reference), enter quantity used (live balance preview shown), choose reason (Job Usage / Waste / Correction / Transfer Out), add optional notes, tap Save
|
- On the scan page: select a job (My Jobs shows jobs assigned to the logged-in user; Other Jobs shows all open jobs; No Job logs without a reference), enter quantity used (live balance preview shown), choose reason (Job Usage / Waste / Correction / Transfer Out), add optional notes, tap Save
|
||||||
- After saving: success screen with "Log Another Item for This Job" (returns to scan with same job pre-selected) or "Back to Inventory" / "View Item Details"
|
- After saving: success screen with "Log Another Item for This Job" (returns to scan with same job pre-selected) or "Back to Inventory" / "View Item Details"
|
||||||
- Every scan log is recorded as a JobUsage or Adjustment InventoryTransaction and immediately reduces QuantityOnHand; visible in Inventory Activity ledger
|
- Every scan log is recorded as a JobUsage or Adjustment InventoryTransaction and immediately reduces QuantityOnHand; visible in Inventory Activity ledger
|
||||||
- First-time scan on a new device requires login; browser caches the session after that
|
- First-time scan on a new device requires login; browser caches the session after that
|
||||||
|
|
||||||
|
**Location / Bin filtering and printing:**
|
||||||
|
- Every inventory item has an optional **Location** field (e.g. "Shelf A", "Bin 3") set on the Create/Edit form.
|
||||||
|
- Once any item has a location, a **Location dropdown** appears in the Inventory list filter bar (next to Category). Selecting a location filters the list to only items stored there. Can be combined with keyword search and category filter simultaneously.
|
||||||
|
- Each location badge in the table is a clickable link that instantly filters to that bin.
|
||||||
|
- With a location filter active, a **Print Bin** button appears in the filter banner. Clicking it opens a printer-ready page (new tab) at /Inventory/PrintBin?location=... listing all items in that bin with line number, name, color, and SKU. No site chrome — prints cleanly on a standard sheet.
|
||||||
|
- The Location dropdown only appears when at least one item has a location value set. If a user doesn't see it, they need to fill in the Location field on at least one inventory item.
|
||||||
|
|
||||||
**Catalog Lookup & Label Scanner (when adding/editing inventory items):**
|
**Catalog Lookup & Label Scanner (when adding/editing inventory items):**
|
||||||
- When creating or editing an inventory item, click the **Lookup** button next to the SKU/Part Number field to search a built-in platform catalog of thousands of Prismatic Powders and other manufacturer SKUs. Select a match to auto-fill name, manufacturer, color code, finish, coverage rate, SDS/TDS links, and cure specs.
|
- When creating or editing an inventory item, click the **Lookup** button next to the SKU/Part Number field to search a built-in platform catalog of thousands of Prismatic Powders and other manufacturer SKUs. Select a match to auto-fill name, manufacturer, color code, finish, coverage rate, SDS/TDS links, and cure specs.
|
||||||
- The catalog only shows products not already in the company's inventory (prevents duplicates). When editing, the item's own catalog entry is always shown.
|
- The catalog only shows products not already in the company's inventory (prevents duplicates). When editing, the item's own catalog entry is always shown.
|
||||||
|
|||||||
@@ -180,6 +180,18 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
|||||||
.AddDefaultUI()
|
.AddDefaultUI()
|
||||||
.AddClaimsPrincipalFactory<ApplicationUserClaimsPrincipalFactory>();
|
.AddClaimsPrincipalFactory<ApplicationUserClaimsPrincipalFactory>();
|
||||||
|
|
||||||
|
// Configure the auth cookie to survive mobile browser suspensions (iOS Safari clears session
|
||||||
|
// cookies when it suspends a tab). Max-Age on the cookie itself makes it persistent regardless
|
||||||
|
// of whether the user checked "Remember me". SlidingExpiration renews the window on each request.
|
||||||
|
builder.Services.ConfigureApplicationCookie(options =>
|
||||||
|
{
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||||
|
options.SlidingExpiration = true;
|
||||||
|
options.Cookie.MaxAge = TimeSpan.FromDays(30);
|
||||||
|
options.Cookie.IsEssential = true;
|
||||||
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
});
|
||||||
|
|
||||||
// Register HttpContextAccessor for multi-tenancy
|
// Register HttpContextAccessor for multi-tenancy
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
@@ -530,6 +542,49 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
{
|
{
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
|
||||||
|
// Return a proper HTML body on 429 so mobile browsers (especially iOS Safari) don't try to
|
||||||
|
// download the empty response as a file. Without this, Safari shows "Login / data Zero KB".
|
||||||
|
options.OnRejected = async (context, ct) =>
|
||||||
|
{
|
||||||
|
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
|
||||||
|
var isAjax = context.HttpContext.Request.Headers.XRequestedWith == "XMLHttpRequest"
|
||||||
|
|| (context.HttpContext.Request.Headers.Accept.ToString().Contains("application/json"));
|
||||||
|
|
||||||
|
if (isAjax)
|
||||||
|
{
|
||||||
|
context.HttpContext.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
await context.HttpContext.Response.WriteAsync(
|
||||||
|
"""{"success":false,"error":"Too many requests. Please wait a moment and try again."}""", ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.HttpContext.Response.ContentType = "text/html; charset=utf-8";
|
||||||
|
await context.HttpContext.Response.WriteAsync("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Too Many Requests — Powder Coating Logix</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5}
|
||||||
|
.card{background:#fff;border-radius:8px;padding:2rem;max-width:420px;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.1)}
|
||||||
|
h2{margin-top:0;color:#333}p{color:#666}a{color:#0d6efd}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Too Many Requests</h2>
|
||||||
|
<p>You've made too many login attempts in a short period. Please wait a minute and try again.</p>
|
||||||
|
<a href="/Identity/Account/Login">Back to Login</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""", ct);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// login / password-reset — 10 per minute per IP
|
// login / password-reset — 10 per minute per IP
|
||||||
options.AddPolicy(AppConstants.RateLimitPolicies.Auth, ctx =>
|
options.AddPolicy(AppConstants.RateLimitPolicies.Auth, ctx =>
|
||||||
RateLimitPartition.GetSlidingWindowLimiter(
|
RateLimitPartition.GetSlidingWindowLimiter(
|
||||||
@@ -616,8 +671,8 @@ System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
|
|||||||
// SECURITY: Add security headers middleware
|
// SECURITY: Add security headers middleware
|
||||||
app.Use(async (context, next) =>
|
app.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
// Prevent clickjacking
|
// Prevent clickjacking — SAMEORIGIN so our own iframe embeds (QR labels, etc.) still work
|
||||||
context.Response.Headers.Append("X-Frame-Options", "DENY");
|
context.Response.Headers.Append("X-Frame-Options", "SAMEORIGIN");
|
||||||
|
|
||||||
// Prevent MIME type sniffing
|
// Prevent MIME type sniffing
|
||||||
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
|
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
|
||||||
@@ -644,7 +699,8 @@ app.Use(async (context, next) =>
|
|||||||
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " +
|
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " +
|
||||||
"img-src 'self' data: https:; " +
|
"img-src 'self' data: https:; " +
|
||||||
$"connect-src {cspConnectSrc}; " +
|
$"connect-src {cspConnectSrc}; " +
|
||||||
"frame-src https://js.stripe.com https://hooks.stripe.com");
|
"frame-src 'self' https://js.stripe.com https://hooks.stripe.com; " +
|
||||||
|
"frame-ancestors 'self'");
|
||||||
|
|
||||||
// Referrer Policy - control referrer information
|
// Referrer Policy - control referrer information
|
||||||
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
|
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="fw-bold">@c.ClosedYear</td>
|
<td class="fw-bold">@c.ClosedYear</td>
|
||||||
<td>@c.ClosedAt.ToLocalTime().ToString("MM/dd/yyyy h:mm tt")</td>
|
<td>@c.ClosedAt.ToLocalTime().ToString("MM/dd/yyyy h:mm tt")</td>
|
||||||
<td>@(c.ClosedBy ?? "—")</td>
|
<td>@Html.Raw(c.ClosedBy ?? "—")</td>
|
||||||
<td>
|
<td>
|
||||||
@if (c.JournalEntry != null)
|
@if (c.JournalEntry != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -207,16 +207,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center @(row.Today > 0 ? "fw-semibold" : "text-muted")">
|
<td class="text-center @(row.Today > 0 ? "fw-semibold" : "text-muted")">
|
||||||
@(row.Today > 0 ? row.Today.ToString("N0") : "—")
|
@Html.Raw(row.Today > 0 ? row.Today.ToString("N0") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center @(row.Last7Days > 0 ? "fw-semibold" : "text-muted")">
|
<td class="text-center @(row.Last7Days > 0 ? "fw-semibold" : "text-muted")">
|
||||||
@(row.Last7Days > 0 ? row.Last7Days.ToString("N0") : "—")
|
@Html.Raw(row.Last7Days > 0 ? row.Last7Days.ToString("N0") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
|
<td class="text-center @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
|
||||||
@(row.Last30Days > 0 ? row.Last30Days.ToString("N0") : "—")
|
@Html.Raw(row.Last30Days > 0 ? row.Last30Days.ToString("N0") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center @(row.AllTime > 0 ? "" : "text-muted")">
|
<td class="text-center @(row.AllTime > 0 ? "" : "text-muted")">
|
||||||
@(row.AllTime > 0 ? row.AllTime.ToString("N0") : "—")
|
@Html.Raw(row.AllTime > 0 ? row.AllTime.ToString("N0") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center @(row.PhotoCount > 0 ? "" : "text-muted")">
|
<td class="text-center @(row.PhotoCount > 0 ? "" : "text-muted")">
|
||||||
@if (row.PhotoCount > 0)
|
@if (row.PhotoCount > 0)
|
||||||
|
|||||||
@@ -46,19 +46,19 @@
|
|||||||
<dd class="col-7">@Model.EntityType</dd>
|
<dd class="col-7">@Model.EntityType</dd>
|
||||||
|
|
||||||
<dt class="col-5 text-muted">Entity ID</dt>
|
<dt class="col-5 text-muted">Entity ID</dt>
|
||||||
<dd class="col-7">@(Model.EntityId ?? "—")</dd>
|
<dd class="col-7">@Html.Raw(Model.EntityId ?? "—")</dd>
|
||||||
|
|
||||||
<dt class="col-5 text-muted">Description</dt>
|
<dt class="col-5 text-muted">Description</dt>
|
||||||
<dd class="col-7">@(Model.EntityDescription ?? "—")</dd>
|
<dd class="col-7">@Html.Raw(Model.EntityDescription ?? "—")</dd>
|
||||||
|
|
||||||
<dt class="col-5 text-muted">User</dt>
|
<dt class="col-5 text-muted">User</dt>
|
||||||
<dd class="col-7">@Model.UserName</dd>
|
<dd class="col-7">@Model.UserName</dd>
|
||||||
|
|
||||||
<dt class="col-5 text-muted">Company</dt>
|
<dt class="col-5 text-muted">Company</dt>
|
||||||
<dd class="col-7">@(Model.CompanyName ?? (Model.CompanyId?.ToString() ?? "—"))</dd>
|
<dd class="col-7">@Html.Raw(Model.CompanyName ?? (Model.CompanyId?.ToString() ?? "—"))</dd>
|
||||||
|
|
||||||
<dt class="col-5 text-muted">IP Address</dt>
|
<dt class="col-5 text-muted">IP Address</dt>
|
||||||
<dd class="col-7">@(Model.IpAddress ?? "—")</dd>
|
<dd class="col-7">@Html.Raw(Model.IpAddress ?? "—")</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,8 +95,8 @@
|
|||||||
var newVal = newData.ValueKind == JsonValueKind.Object && newData.TryGetProperty(key, out var nv) ? nv.ToString() : null;
|
var newVal = newData.ValueKind == JsonValueKind.Object && newData.TryGetProperty(key, out var nv) ? nv.ToString() : null;
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fw-medium">@key</td>
|
<td class="fw-medium">@key</td>
|
||||||
<td class="text-danger font-monospace">@(oldVal ?? "—")</td>
|
<td class="text-danger font-monospace">@Html.Raw(oldVal ?? "—")</td>
|
||||||
<td class="text-success font-monospace">@(newVal ?? "—")</td>
|
<td class="text-success font-monospace">@Html.Raw(newVal ?? "—")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,7 +248,7 @@
|
|||||||
{
|
{
|
||||||
<tr class="text-muted">
|
<tr class="text-muted">
|
||||||
<td><code>@ban.IpAddress</code></td>
|
<td><code>@ban.IpAddress</code></td>
|
||||||
<td><small>@(ban.Reason ?? "—")</small></td>
|
<td><small>@Html.Raw(ban.Reason ?? "—")</small></td>
|
||||||
<td><small>@ban.BannedAt.ToString("MMM dd, yyyy")</small></td>
|
<td><small>@ban.BannedAt.ToString("MMM dd, yyyy")</small></td>
|
||||||
<td>
|
<td>
|
||||||
@if (!ban.IsActive)
|
@if (!ban.IsActive)
|
||||||
|
|||||||
@@ -162,7 +162,7 @@
|
|||||||
<td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td>
|
<td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td>
|
||||||
<td class="text-end">@entry.Total.ToString("C")</td>
|
<td class="text-end">@entry.Total.ToString("C")</td>
|
||||||
<td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")">
|
<td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")">
|
||||||
@(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "—")
|
@Html.Raw(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@if (entry.EntryType == "Bill")
|
@if (entry.EntryType == "Bill")
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-sm table-borderless mb-0">
|
<table class="table table-sm table-borderless mb-0">
|
||||||
<tr><th style="width:40%">Company Name</th><td>@Model.CompanyName</td></tr>
|
<tr><th style="width:40%">Company Name</th><td>@Model.CompanyName</td></tr>
|
||||||
<tr><th>Code</th><td>@(Model.CompanyCode ?? "—")</td></tr>
|
<tr><th>Code</th><td>@Html.Raw(Model.CompanyCode ?? "—")</td></tr>
|
||||||
<tr><th>Status</th><td><span class="badge @(Model.IsActive ? "bg-success" : "bg-danger")">@(Model.IsActive ? "Active" : "Inactive")</span></td></tr>
|
<tr><th>Status</th><td><span class="badge @(Model.IsActive ? "bg-success" : "bg-danger")">@(Model.IsActive ? "Active" : "Inactive")</span></td></tr>
|
||||||
<tr><th>Time Zone</th><td>@(Model.TimeZone ?? "America/New_York")</td></tr>
|
<tr><th>Time Zone</th><td>@(Model.TimeZone ?? "America/New_York")</td></tr>
|
||||||
<tr><th>Created</th><td>@Model.CreatedAt.ToString("MMM d, yyyy h:mm tt")</td></tr>
|
<tr><th>Created</th><td>@Model.CreatedAt.ToString("MMM d, yyyy h:mm tt")</td></tr>
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
<table class="table table-sm table-borderless mb-0">
|
<table class="table table-sm table-borderless mb-0">
|
||||||
<tr><th style="width:40%">Contact Name</th><td>@Model.PrimaryContactName</td></tr>
|
<tr><th style="width:40%">Contact Name</th><td>@Model.PrimaryContactName</td></tr>
|
||||||
<tr><th>Email</th><td><a href="mailto:@Model.PrimaryContactEmail">@Model.PrimaryContactEmail</a></td></tr>
|
<tr><th>Email</th><td><a href="mailto:@Model.PrimaryContactEmail">@Model.PrimaryContactEmail</a></td></tr>
|
||||||
<tr><th>Phone</th><td>@(Model.Phone ?? "—")</td></tr>
|
<tr><th>Phone</th><td>@Html.Raw(Model.Phone ?? "—")</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,7 +283,7 @@
|
|||||||
}
|
}
|
||||||
else { <span class="text-muted">N/A</span> }
|
else { <span class="text-muted">N/A</span> }
|
||||||
</td>
|
</td>
|
||||||
<td>@(user.Department ?? "—")</td>
|
<td>@Html.Raw(user.Department ?? "—")</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge @(user.IsActive ? "bg-success" : "bg-danger")">
|
<span class="badge @(user.IsActive ? "bg-success" : "bg-danger")">
|
||||||
@(user.IsActive ? "Active" : "Inactive")
|
@(user.IsActive ? "Active" : "Inactive")
|
||||||
@@ -527,20 +527,20 @@
|
|||||||
@{
|
@{
|
||||||
var firstActivity = onboarding.FirstJobCreatedAt ?? onboarding.FirstQuoteCreatedAt;
|
var firstActivity = onboarding.FirstJobCreatedAt ?? onboarding.FirstQuoteCreatedAt;
|
||||||
}
|
}
|
||||||
@(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "—")
|
@Html.Raw(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "—")
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>First Invoice</th>
|
<th>First Invoice</th>
|
||||||
<td>@(onboarding.FirstInvoiceCreatedAt.HasValue ? onboarding.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "—")</td>
|
<td>@Html.Raw(onboarding.FirstInvoiceCreatedAt.HasValue ? onboarding.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "—")</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Workflow Completed</th>
|
<th>Workflow Completed</th>
|
||||||
<td>@(onboarding.FirstWorkflowCompletedAt.HasValue ? onboarding.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "—")</td>
|
<td>@Html.Raw(onboarding.FirstWorkflowCompletedAt.HasValue ? onboarding.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "—")</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Widget Dismissed</th>
|
<th>Widget Dismissed</th>
|
||||||
<td>@(onboarding.GuidedActivationDismissedAt.HasValue ? onboarding.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "—")</td>
|
<td>@Html.Raw(onboarding.GuidedActivationDismissedAt.HasValue ? onboarding.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "—")</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>@a.AppliedDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
|
<td>@a.AppliedDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
|
||||||
<td class="text-end fw-semibold text-success">@a.AmountApplied.ToString("C")</td>
|
<td class="text-end fw-semibold text-success">@a.AmountApplied.ToString("C")</td>
|
||||||
<td class="small text-muted">@(a.AppliedBy?.FullName ?? "—")</td>
|
<td class="small text-muted">@Html.Raw(a.AppliedBy?.FullName ?? "—")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ else
|
|||||||
</td>
|
</td>
|
||||||
<td>@m.IssueDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
|
<td>@m.IssueDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
|
||||||
<td class="@(expired ? "text-danger fw-semibold" : "")">
|
<td class="@(expired ? "text-danger fw-semibold" : "")">
|
||||||
@(m.ExpiryDate.HasValue ? m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yyyy") : "—")
|
@Html.Raw(m.ExpiryDate.HasValue ? m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yyyy") : "—")
|
||||||
@if (expired) { <small>(Expired)</small> }
|
@if (expired) { <small>(Expired)</small> }
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -224,7 +224,7 @@
|
|||||||
}
|
}
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Phone</span>
|
<span class="mobile-card-label">Phone</span>
|
||||||
<span class="mobile-card-value">@(customer.Phone ?? "—")</span>
|
<span class="mobile-card-value">@Html.Raw(customer.Phone ?? "—")</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Type</span>
|
<span class="mobile-card-label">Type</span>
|
||||||
|
|||||||
@@ -563,7 +563,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="2">Vendor Total</td>
|
<td colspan="2">Vendor Total</td>
|
||||||
<td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td>
|
<td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td>
|
||||||
<td class="text-end">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "—")</td>
|
<td class="text-end">@Html.Raw(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "—")</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
@@ -680,7 +680,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="2">Vendor Total</td>
|
<td colspan="2">Vendor Total</td>
|
||||||
<td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td>
|
<td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td>
|
||||||
<td class="text-end">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "—")</td>
|
<td class="text-end">@Html.Raw(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "—")</td>
|
||||||
<td colspan="2"></td>
|
<td colspan="2"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
else { <span class="text-muted">—</span> }
|
else { <span class="text-muted">—</span> }
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted">
|
<td class="text-muted">
|
||||||
@(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")
|
@Html.Raw(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<input type="checkbox" class="form-check-input entity-select"
|
<input type="checkbox" class="form-check-input entity-select"
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-title">
|
<div class="mobile-card-title">
|
||||||
<h6>@s.Label</h6>
|
<h6>@s.Label</h6>
|
||||||
<small>Oldest: @(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")</small>
|
<small>Oldest: @Html.Raw(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-body">
|
<div class="mobile-card-body">
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
<div class="small text-muted">@(string.IsNullOrWhiteSpace(row.RecipientEmail) ? "No primary contact email configured" : row.RecipientEmail)</div>
|
<div class="small text-muted">@(string.IsNullOrWhiteSpace(row.RecipientEmail) ? "No primary contact email configured" : row.RecipientEmail)</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div>@(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "—" : row.CompanyAdminName)</div>
|
<div>@Html.Raw(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "—" : row.CompanyAdminName)</div>
|
||||||
@if (!string.IsNullOrWhiteSpace(row.CompanyAdminEmail))
|
@if (!string.IsNullOrWhiteSpace(row.CompanyAdminEmail))
|
||||||
{
|
{
|
||||||
<div class="small text-muted">@row.CompanyAdminEmail</div>
|
<div class="small text-muted">@row.CompanyAdminEmail</div>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
<div class="fw-semibold">@company.CompanyName</div>
|
<div class="fw-semibold">@company.CompanyName</div>
|
||||||
<div class="small text-muted">#@company.CompanyId</div>
|
<div class="small text-muted">#@company.CompanyId</div>
|
||||||
</td>
|
</td>
|
||||||
<td>@(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "—" : company.PrimaryContactName)</td>
|
<td>@Html.Raw(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "—" : company.PrimaryContactName)</td>
|
||||||
<td>
|
<td>
|
||||||
@if (string.IsNullOrWhiteSpace(company.PrimaryContactEmail))
|
@if (string.IsNullOrWhiteSpace(company.PrimaryContactEmail))
|
||||||
{
|
{
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div>@(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "—" : company.CompanyAdminName)</div>
|
<div>@Html.Raw(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "—" : company.CompanyAdminName)</div>
|
||||||
@if (!string.IsNullOrWhiteSpace(company.CompanyAdminEmail))
|
@if (!string.IsNullOrWhiteSpace(company.CompanyAdminEmail))
|
||||||
{
|
{
|
||||||
<div class="small text-muted">@company.CompanyAdminEmail</div>
|
<div class="small text-muted">@company.CompanyAdminEmail</div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="text-muted small mb-1">Location</label>
|
<label class="text-muted small mb-1">Location</label>
|
||||||
<p class="mb-0">@(Model.Location ?? "—")</p>
|
<p class="mb-0">@Html.Raw(Model.Location ?? "—")</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="text-muted small mb-1">Status</label>
|
<label class="text-muted small mb-1">Status</label>
|
||||||
@@ -76,15 +76,15 @@
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="text-muted small mb-1">Manufacturer</label>
|
<label class="text-muted small mb-1">Manufacturer</label>
|
||||||
<p class="mb-0">@(Model.Manufacturer ?? "—")</p>
|
<p class="mb-0">@Html.Raw(Model.Manufacturer ?? "—")</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="text-muted small mb-1">Model</label>
|
<label class="text-muted small mb-1">Model</label>
|
||||||
<p class="mb-0">@(Model.Model ?? "—")</p>
|
<p class="mb-0">@Html.Raw(Model.Model ?? "—")</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="text-muted small mb-1">Serial Number</label>
|
<label class="text-muted small mb-1">Serial Number</label>
|
||||||
<p class="mb-0">@(Model.SerialNumber ?? "—")</p>
|
<p class="mb-0">@Html.Raw(Model.SerialNumber ?? "—")</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,15 +94,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="text-muted small mb-1">Manufacturer</label>
|
<label class="text-muted small mb-1">Manufacturer</label>
|
||||||
<p class="mb-0">@(Model.Manufacturer ?? "—")</p>
|
<p class="mb-0">@Html.Raw(Model.Manufacturer ?? "—")</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="text-muted small mb-1">Model</label>
|
<label class="text-muted small mb-1">Model</label>
|
||||||
<p class="mb-0">@(Model.Model ?? "—")</p>
|
<p class="mb-0">@Html.Raw(Model.Model ?? "—")</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="text-muted small mb-1">Serial Number</label>
|
<label class="text-muted small mb-1">Serial Number</label>
|
||||||
<p class="mb-0">@(Model.SerialNumber ?? "—")</p>
|
<p class="mb-0">@Html.Raw(Model.SerialNumber ?? "—")</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
<td>@cert.OriginalAmount.ToString("C")</td>
|
<td>@cert.OriginalAmount.ToString("C")</td>
|
||||||
<td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td>
|
<td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td>
|
||||||
<td>
|
<td>
|
||||||
@(cert.ExpiryDate.HasValue
|
@Html.Raw(cert.ExpiryDate.HasValue
|
||||||
? cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy")
|
? cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy")
|
||||||
: "—")
|
: "—")
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -73,6 +73,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="bin-filter" class="mb-5">
|
||||||
|
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||||
|
<i class="bi bi-geo-alt text-primary me-2"></i>Filtering by Location & Printing a Bin List
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Every inventory item has an optional <strong>Location</strong> field (for example "Shelf A",
|
||||||
|
"Bin 3", or "Back Wall") that you can set when creating or editing an item. Once locations
|
||||||
|
are filled in, the Inventory list gives you two shortcuts for physical counts and
|
||||||
|
stocktaking:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 class="h6 fw-semibold mt-3 mb-2">Filtering by location</h3>
|
||||||
|
<p>
|
||||||
|
When at least one item has a location set, a <strong>Location</strong> dropdown appears in
|
||||||
|
the filter bar at the top of the Inventory list, next to the Category filter. Select a
|
||||||
|
location from the dropdown to instantly narrow the list to only the items stored there.
|
||||||
|
You can combine the location filter with a keyword search or category filter at the same time.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Each location badge shown in the Location column of the table is also a direct link —
|
||||||
|
clicking it immediately filters to that bin without using the dropdown.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 class="h6 fw-semibold mt-4 mb-2">Printing a bin list</h3>
|
||||||
|
<p>
|
||||||
|
With a location filter active, a <strong>Print Bin</strong> button appears next to the
|
||||||
|
Clear Filters button. Click it to open a printer-ready page (in a new tab) listing every
|
||||||
|
item in that bin with its item number, name, color, and SKU. Click <strong>Print</strong>
|
||||||
|
on that page or use your browser’s print shortcut to send it to a printer or save
|
||||||
|
as PDF. The page has no site chrome — just the table — so it prints cleanly on a
|
||||||
|
standard sheet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||||
|
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
The Location dropdown only appears once at least one inventory item has a location set.
|
||||||
|
If you don’t see it, open any item, fill in the <strong>Location</strong> field on the
|
||||||
|
edit form, and save — the dropdown will appear on your next visit to the Inventory list.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="catalog-lookup" class="mb-5">
|
<section id="catalog-lookup" class="mb-5">
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||||
<i class="bi bi-search text-primary me-2"></i>Catalog Lookup & Label Scanner
|
<i class="bi bi-search text-primary me-2"></i>Catalog Lookup & Label Scanner
|
||||||
@@ -304,10 +347,14 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 class="h6 fw-semibold mt-4 mb-2">Printing a label</h3>
|
<h3 class="h6 fw-semibold mt-4 mb-2">Printing a label</h3>
|
||||||
|
<p>You can print from two places — the item's Details page, or directly from the list without opening the item.</p>
|
||||||
<ol class="mb-3">
|
<ol class="mb-3">
|
||||||
<li class="mb-1">Open the inventory item's Details page.</li>
|
<li class="mb-1">
|
||||||
<li class="mb-1">Click <strong>Print QR Label</strong> in the Actions panel — the label opens in a new tab.</li>
|
<strong>From the list:</strong> click the <i class="bi bi-qr-code"></i> QR icon (first button in the Actions column) next to any row.
|
||||||
<li class="mb-1">Click <strong>Print Label</strong> and send it to your printer. The label is sized for a standard 3.5″ label and includes the item name, SKU, colour, finish, and manufacturer.</li>
|
<br /><strong>From Details:</strong> click <strong>Print QR Label</strong> in the Actions panel.
|
||||||
|
</li>
|
||||||
|
<li class="mb-1">A preview modal opens showing the label — item name, SKU, colour, finish, and QR code.</li>
|
||||||
|
<li class="mb-1">Click <strong>Print Label</strong> inside the modal to send it to your printer. No new tab is opened; printing happens directly in the modal. The label is sized for a standard 3.5″ label.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h3 class="h6 fw-semibold mt-4 mb-2">Scanning and logging usage</h3>
|
<h3 class="h6 fw-semibold mt-4 mb-2">Scanning and logging usage</h3>
|
||||||
@@ -533,6 +580,7 @@
|
|||||||
<nav class="nav flex-column">
|
<nav class="nav flex-column">
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#adding-items">Adding Inventory Items</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#adding-items">Adding Inventory Items</a>
|
||||||
|
<a class="nav-link py-1 px-3 small text-body" href="#bin-filter">Location Filtering & Bin Print</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#catalog-lookup">Catalog Lookup & Label Scanner</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#catalog-lookup">Catalog Lookup & Label Scanner</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#stock-levels">Stock Levels and Reorder Points</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#stock-levels">Stock Levels and Reorder Points</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#stock-adjustment">Stock Adjustment</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#stock-adjustment">Stock Adjustment</a>
|
||||||
|
|||||||
@@ -475,12 +475,41 @@
|
|||||||
actual hours vs. estimated hours for costing and productivity analysis.
|
actual hours vs. estimated hours for costing and productivity analysis.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 class="h6 fw-semibold mt-3 mb-2">Rework</h3>
|
<h3 class="h6 fw-semibold mt-3 mb-2">Rework (also called Redo)</h3>
|
||||||
<p>
|
<p>
|
||||||
If finished parts fail quality inspection or need to be re-coated, create a rework record
|
If a finished part fails quality inspection or a customer returns it damaged, open the original
|
||||||
from the Job Details page. Rework records track the rework type, the reason (adhesion failure,
|
job’s Details page and use the <strong>Rework Log</strong> section to record it. Rework
|
||||||
color mismatch, damage, etc.), and the resolution. This data helps identify recurring quality
|
and redo mean the same thing throughout the system.
|
||||||
issues over time.
|
</p>
|
||||||
|
<p>Each entry captures the type (internal defect, customer damage, warranty), the reason (adhesion
|
||||||
|
failure, color mismatch, runs/sags, insufficient coverage, etc.), a defect description, who
|
||||||
|
discovered the issue, and pricing responsibility.</p>
|
||||||
|
|
||||||
|
<h4 class="h6 fw-semibold mt-3 mb-1">Pricing Responsibility</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Shop Fault — no charge:</strong> All copied item prices are set to $0.</li>
|
||||||
|
<li><strong>Customer responsible — reduced rate:</strong> Prices are copied from the original job; edit them down after creation.</li>
|
||||||
|
<li><strong>Customer responsible — full price:</strong> Prices are copied as-is.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 class="h6 fw-semibold mt-3 mb-1">Creating a Rework Job</h4>
|
||||||
|
<p>
|
||||||
|
Toggle <strong>Parts are back — create a Rework Job</strong> at the top of the log form.
|
||||||
|
Select the items that need to be redone and choose the pricing responsibility. The system will:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Create a new job with a sub-number (e.g., <code>JOB-2605-0001-R1</code>)</li>
|
||||||
|
<li>Copy the selected items with their coats and prep services</li>
|
||||||
|
<li>Auto-record intake — parts are already on hand when rework is logged</li>
|
||||||
|
<li>Set the job description to the defect type, reason, and pricing so it is visible at the top of the job</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 class="h6 fw-semibold mt-3 mb-1">Automatic Resolution</h4>
|
||||||
|
<p>
|
||||||
|
When the rework job reaches a terminal status (Completed, Delivered, etc.), the linked rework
|
||||||
|
record on the original job is automatically marked <strong>Resolved</strong> — no manual
|
||||||
|
follow-up needed. If the rework job is <strong>Cancelled</strong>, the record is marked
|
||||||
|
<strong>Written Off</strong> instead.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="supply-categories" class="mb-5">
|
||||||
|
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||||
|
<i class="bi bi-tags text-primary me-2"></i>Supply Categories
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Each vendor can be tagged with one or more <strong>Supply Categories</strong> — for example,
|
||||||
|
<em>Powder</em>, <em>Chemical</em>, or <em>Consumables</em>. These tags tell the system what types of
|
||||||
|
inventory items this vendor supplies.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When you add or edit an inventory item and choose a <strong>Category</strong>, the vendor dropdown
|
||||||
|
automatically filters to show only vendors tagged for that category. This prevents accidentally
|
||||||
|
selecting an unrelated supplier.
|
||||||
|
</p>
|
||||||
|
<ul class="mb-3">
|
||||||
|
<li>A vendor can belong to <strong>multiple categories</strong> — check as many boxes as apply.</li>
|
||||||
|
<li>If <strong>no vendor</strong> in your list is tagged for the selected category, the dropdown falls back to
|
||||||
|
showing all active vendors so you are never blocked.</li>
|
||||||
|
<li>A <em>“Showing vendors for this category”</em> note appears below the dropdown when the filter is
|
||||||
|
active. Click <strong>Show all</strong> in that note to temporarily override the filter.</li>
|
||||||
|
</ul>
|
||||||
|
<p>To tag a vendor with supply categories:</p>
|
||||||
|
<ol class="mb-3">
|
||||||
|
<li class="mb-1">Open the vendor's Edit page (click the vendor name in the list, then <strong>Edit</strong>).</li>
|
||||||
|
<li class="mb-1">In the <strong>Supply Categories</strong> section, check the boxes that apply.</li>
|
||||||
|
<li class="mb-1">Click <strong>Save Vendor</strong>.</li>
|
||||||
|
</ol>
|
||||||
|
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||||
|
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
Supply categories are the same categories used on your inventory items. If you add a new category
|
||||||
|
under <strong>Settings › Inventory Categories</strong>, it will appear automatically in the vendor
|
||||||
|
checkboxes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="deactivating-a-vendor" class="mb-5">
|
<section id="deactivating-a-vendor" class="mb-5">
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||||
<i class="bi bi-truck text-primary me-2" style="text-decoration:line-through"></i>Deactivating a Vendor
|
<i class="bi bi-truck text-primary me-2" style="text-decoration:line-through"></i>Deactivating a Vendor
|
||||||
@@ -186,6 +223,7 @@
|
|||||||
<a class="nav-link py-1 px-3 small text-body" href="#default-expense-account">Default Expense Account</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#default-expense-account">Default Expense Account</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#payment-terms">Payment Terms</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#payment-terms">Payment Terms</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#preferred-vendor">Preferred Vendor</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#preferred-vendor">Preferred Vendor</a>
|
||||||
|
<a class="nav-link py-1 px-3 small text-body" href="#supply-categories">Supply Categories</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#vendor-details">Vendor Details Page</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#vendor-details">Vendor Details Page</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#deactivating-a-vendor">Deactivating a Vendor</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#deactivating-a-vendor">Deactivating a Vendor</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -345,6 +345,10 @@
|
|||||||
<option value="">Select vendor</option>
|
<option value="">Select vendor</option>
|
||||||
<option value="__new__">+ Add New Vendor…</option>
|
<option value="__new__">+ Add New Vendor…</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div id="vendor-filter-note" class="form-text d-none">
|
||||||
|
<i class="bi bi-funnel me-1 text-info"></i><span class="text-info">Showing vendors for this category.</span>
|
||||||
|
<a href="#" id="vendor-filter-clear" class="ms-1">Show all</a>
|
||||||
|
</div>
|
||||||
<span asp-validation-for="PrimaryVendorId" class="text-danger"></span>
|
<span asp-validation-for="PrimaryVendorId" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,4 +442,38 @@
|
|||||||
{
|
{
|
||||||
<script src="~/js/inventory-label-scan.js"></script>
|
<script src="~/js/inventory-label-scan.js"></script>
|
||||||
}
|
}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const categoryVendorMap = @Html.Raw(ViewBag.CategoryVendorMapJson ?? "{}");
|
||||||
|
const vendorSelect = document.getElementById('field-vendor');
|
||||||
|
const allVendorOptions = Array.from(vendorSelect.options).map(o => ({ v: o.value, t: o.text }));
|
||||||
|
|
||||||
|
function filterVendors(catId, forceAll) {
|
||||||
|
const vendorIds = (!forceAll && catId) ? (categoryVendorMap[catId] || []) : [];
|
||||||
|
const isFiltered = vendorIds.length > 0;
|
||||||
|
const currentVal = vendorSelect.value;
|
||||||
|
|
||||||
|
vendorSelect.innerHTML = '';
|
||||||
|
allVendorOptions.forEach(function (opt) {
|
||||||
|
if (!isFiltered || !opt.v || opt.v === '__new__' || vendorIds.includes(Number(opt.v)))
|
||||||
|
vendorSelect.add(new Option(opt.t, opt.v));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.from(vendorSelect.options).some(o => o.value === currentVal))
|
||||||
|
vendorSelect.value = currentVal;
|
||||||
|
|
||||||
|
document.getElementById('vendor-filter-note').classList.toggle('d-none', !isFiltered);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('field-category').addEventListener('change', function () {
|
||||||
|
filterVendors(this.value, false);
|
||||||
|
});
|
||||||
|
document.getElementById('vendor-filter-clear')?.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
filterVendors(document.getElementById('field-category').value, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
filterVendors(document.getElementById('field-category').value, false);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
ViewData["PageIcon"] = "bi-box-seam";
|
ViewData["PageIcon"] = "bi-box-seam";
|
||||||
ViewData["PageHelpTitle"] = "Inventory Item";
|
ViewData["PageHelpTitle"] = "Inventory Item";
|
||||||
ViewData["PageHelpContent"] = "Full detail for this inventory item. Stock Information shows current quantity and reorder thresholds — a Low Stock banner appears when quantity is at or below the Reorder Point. Pricing shows Unit Cost (what you paid), Average Cost (weighted average across purchases), and Total Stock Value. Use the Actions panel to edit, view jobs using this powder, or delete the item.";
|
ViewData["PageHelpContent"] = "Full detail for this inventory item. Stock Information shows current quantity and reorder thresholds — a Low Stock banner appears when quantity is at or below the Reorder Point. Pricing shows Unit Cost (what you paid), Average Cost (weighted average across purchases), and Total Stock Value. Use the Actions panel to edit, view jobs using this powder, or delete the item.";
|
||||||
|
|
||||||
|
string SafeUrl(string? url) =>
|
||||||
|
string.IsNullOrEmpty(url) ? "#"
|
||||||
|
: (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
? url : "http://" + url;
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
@@ -184,7 +189,7 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="text-muted small mb-1">Product URL</label>
|
<label class="text-muted small mb-1">Product URL</label>
|
||||||
<p class="mb-0">
|
<p class="mb-0">
|
||||||
<a href="@Model.SpecPageUrl" target="_blank" class="text-decoration-none">
|
<a href="@SafeUrl(Model.SpecPageUrl)" target="_blank" class="text-decoration-none">
|
||||||
<i class="bi bi-box-arrow-up-right me-1"></i>View on Manufacturer's Web Site
|
<i class="bi bi-box-arrow-up-right me-1"></i>View on Manufacturer's Web Site
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -197,13 +202,13 @@
|
|||||||
<div class="d-flex gap-2 flex-wrap">
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
@if (!string.IsNullOrEmpty(Model.SdsUrl))
|
@if (!string.IsNullOrEmpty(Model.SdsUrl))
|
||||||
{
|
{
|
||||||
<a href="@Model.SdsUrl" target="_blank" class="btn btn-sm btn-outline-secondary">
|
<a href="@SafeUrl(Model.SdsUrl)" target="_blank" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="bi bi-file-earmark-pdf me-1"></i>Safety Data Sheet
|
<i class="bi bi-file-earmark-pdf me-1"></i>Safety Data Sheet
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrEmpty(Model.TdsUrl))
|
@if (!string.IsNullOrEmpty(Model.TdsUrl))
|
||||||
{
|
{
|
||||||
<a href="@Model.TdsUrl" target="_blank" class="btn btn-sm btn-outline-secondary">
|
<a href="@SafeUrl(Model.TdsUrl)" target="_blank" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="bi bi-file-earmark-text me-1"></i>Technical Data Sheet
|
<i class="bi bi-file-earmark-text me-1"></i>Technical Data Sheet
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@@ -293,14 +298,14 @@
|
|||||||
{
|
{
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="text-muted small mb-1">Inventory Account</label>
|
<label class="text-muted small mb-1">Inventory Account</label>
|
||||||
<p class="mb-0">@(Model.InventoryAccountName ?? "—")</p>
|
<p class="mb-0">@Html.Raw(Model.InventoryAccountName ?? "—")</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (Model.CogsAccountId.HasValue)
|
@if (Model.CogsAccountId.HasValue)
|
||||||
{
|
{
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="text-muted small mb-1">COGS Account</label>
|
<label class="text-muted small mb-1">COGS Account</label>
|
||||||
<p class="mb-0">@(Model.CogsAccountName ?? "—")</p>
|
<p class="mb-0">@Html.Raw(Model.CogsAccountName ?? "—")</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -375,7 +380,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="text-muted small mb-1">Location</label>
|
<label class="text-muted small mb-1">Location</label>
|
||||||
<p class="mb-0">@(Model.Location ?? "—")</p>
|
<p class="mb-0">@Html.Raw(Model.Location ?? "—")</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="text-muted small mb-1">Reorder Point</label>
|
<label class="text-muted small mb-1">Reorder Point</label>
|
||||||
@@ -452,9 +457,9 @@
|
|||||||
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#stockAdjustmentModal">
|
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#stockAdjustmentModal">
|
||||||
<i class="bi bi-plus-slash-minus me-2"></i>Stock Adjustment
|
<i class="bi bi-plus-slash-minus me-2"></i>Stock Adjustment
|
||||||
</button>
|
</button>
|
||||||
<a asp-action="Label" asp-route-id="@Model.Id" target="_blank" class="btn btn-outline-secondary">
|
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#qrLabelModal">
|
||||||
<i class="bi bi-qr-code me-2"></i>Print QR Label
|
<i class="bi bi-qr-code me-2"></i>Print QR Label
|
||||||
</a>
|
</button>
|
||||||
<a asp-action="Ledger" asp-route-inventoryItemId="@Model.Id" class="btn btn-outline-secondary">
|
<a asp-action="Ledger" asp-route-inventoryItemId="@Model.Id" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-journal-text me-2"></i>View Activity History
|
<i class="bi bi-journal-text me-2"></i>View Activity History
|
||||||
</a>
|
</a>
|
||||||
@@ -639,6 +644,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* QR Label Modal *@
|
||||||
|
<div class="modal fade" id="qrLabelModal" tabindex="-1" aria-labelledby="qrLabelModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header py-2">
|
||||||
|
<h6 class="modal-title" id="qrLabelModalLabel">
|
||||||
|
<i class="bi bi-qr-code me-2"></i>QR Label — @Model.Name
|
||||||
|
</h6>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0 d-flex justify-content-center" style="background:#f0f0f0;min-height:360px;">
|
||||||
|
<iframe id="qrLabelFrame"
|
||||||
|
src="@Url.Action("Label", new { id = Model.Id, embed = true })"
|
||||||
|
style="width:100%;height:400px;border:none;"
|
||||||
|
title="QR Label Preview"></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer py-2">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
onclick="document.getElementById('qrLabelFrame').contentWindow.print()">
|
||||||
|
<i class="bi bi-printer me-2"></i>Print Label
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script>
|
<script>
|
||||||
/* ── Stock Adjustment Modal ───────────────────────────────── */
|
/* ── Stock Adjustment Modal ───────────────────────────────── */
|
||||||
|
|||||||
@@ -341,6 +341,10 @@
|
|||||||
<option value="">Select vendor</option>
|
<option value="">Select vendor</option>
|
||||||
<option value="__new__">+ Add New Vendor…</option>
|
<option value="__new__">+ Add New Vendor…</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div id="vendor-filter-note" class="form-text d-none">
|
||||||
|
<i class="bi bi-funnel me-1 text-info"></i><span class="text-info">Showing vendors for this category.</span>
|
||||||
|
<a href="#" id="vendor-filter-clear" class="ms-1">Show all</a>
|
||||||
|
</div>
|
||||||
<span asp-validation-for="PrimaryVendorId" class="text-danger"></span>
|
<span asp-validation-for="PrimaryVendorId" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -457,4 +461,39 @@
|
|||||||
{
|
{
|
||||||
<script src="~/js/inventory-label-scan.js"></script>
|
<script src="~/js/inventory-label-scan.js"></script>
|
||||||
}
|
}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const categoryVendorMap = @Html.Raw(ViewBag.CategoryVendorMapJson ?? "{}");
|
||||||
|
const vendorSelect = document.getElementById('field-vendor');
|
||||||
|
const allVendorOptions = Array.from(vendorSelect.options).map(o => ({ v: o.value, t: o.text }));
|
||||||
|
|
||||||
|
function filterVendors(catId, forceAll) {
|
||||||
|
const vendorIds = (!forceAll && catId) ? (categoryVendorMap[catId] || []) : [];
|
||||||
|
const isFiltered = vendorIds.length > 0;
|
||||||
|
const currentVal = vendorSelect.value;
|
||||||
|
|
||||||
|
vendorSelect.innerHTML = '';
|
||||||
|
allVendorOptions.forEach(function (opt) {
|
||||||
|
if (!isFiltered || !opt.v || opt.v === '__new__' || vendorIds.includes(Number(opt.v)))
|
||||||
|
vendorSelect.add(new Option(opt.t, opt.v));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.from(vendorSelect.options).some(o => o.value === currentVal))
|
||||||
|
vendorSelect.value = currentVal;
|
||||||
|
|
||||||
|
document.getElementById('vendor-filter-note').classList.toggle('d-none', !isFiltered);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('field-category').addEventListener('change', function () {
|
||||||
|
filterVendors(this.value, false);
|
||||||
|
});
|
||||||
|
document.getElementById('vendor-filter-clear')?.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
filterVendors(document.getElementById('field-category').value, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply on load — Edit already has a category selected
|
||||||
|
filterVendors(document.getElementById('field-category').value, false);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,10 +123,11 @@
|
|||||||
|
|
||||||
@{
|
@{
|
||||||
var lowStockOnly = (bool)(ViewBag.LowStockOnly ?? false);
|
var lowStockOnly = (bool)(ViewBag.LowStockOnly ?? false);
|
||||||
|
var activeLocation = ViewBag.Location as string;
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm) || !string.IsNullOrEmpty(ViewBag.Category) || lowStockOnly)
|
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm) || !string.IsNullOrEmpty(ViewBag.Category) || !string.IsNullOrEmpty(activeLocation) || lowStockOnly)
|
||||||
{
|
{
|
||||||
<div class="alert @(lowStockOnly ? "alert-warning" : "alert-info") alert-permanent d-flex justify-content-between align-items-center">
|
<div class="alert @(lowStockOnly ? "alert-warning" : "alert-info") alert-permanent d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<i class="bi bi-funnel-fill me-2"></i>
|
<i class="bi bi-funnel-fill me-2"></i>
|
||||||
@if (lowStockOnly)
|
@if (lowStockOnly)
|
||||||
@@ -144,11 +145,24 @@
|
|||||||
{
|
{
|
||||||
<span> in category "<strong>@ViewBag.Category</strong>"</span>
|
<span> in category "<strong>@ViewBag.Category</strong>"</span>
|
||||||
}
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(activeLocation))
|
||||||
|
{
|
||||||
|
<span> in bin "<strong>@activeLocation</strong>"</span>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
<i class="bi bi-x me-1"></i>Clear Filters
|
@if (!string.IsNullOrEmpty(activeLocation))
|
||||||
</a>
|
{
|
||||||
|
<a href="@Url.Action("PrintBin", new { location = activeLocation })" target="_blank"
|
||||||
|
class="btn btn-sm btn-outline-primary" title="Print bin list">
|
||||||
|
<i class="bi bi-printer me-1"></i>Print Bin
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-x me-1"></i>Clear Filters
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,14 +175,24 @@
|
|||||||
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
|
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
|
||||||
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
|
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
|
||||||
<input type="hidden" name="pageSize" value="@Model.PageSize" />
|
<input type="hidden" name="pageSize" value="@Model.PageSize" />
|
||||||
<select name="category" class="form-select" style="max-width: 250px; min-width: 150px;" onchange="this.form.submit()">
|
<select name="category" class="form-select" style="max-width: 180px; min-width: 130px;" onchange="this.form.submit()">
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
@foreach (var cat in ViewBag.Categories)
|
@foreach (var cat in ViewBag.Categories)
|
||||||
{
|
{
|
||||||
<option value="@cat" selected="@(cat == ViewBag.Category)">@cat</option>
|
<option value="@cat" selected="@(cat == ViewBag.Category)">@cat</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
<div class="input-group" style="max-width: 480px; min-width: 300px;">
|
@if (((IEnumerable<string?>)ViewBag.Locations).Any())
|
||||||
|
{
|
||||||
|
<select name="location" class="form-select" style="max-width: 180px; min-width: 130px;" onchange="this.form.submit()">
|
||||||
|
<option value="">All Locations</option>
|
||||||
|
@foreach (var loc in ViewBag.Locations)
|
||||||
|
{
|
||||||
|
<option value="@loc" selected="@(loc == activeLocation)">@loc</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
<div class="input-group" style="max-width: 380px; min-width: 260px;">
|
||||||
<span class="input-group-text bg-white border-end-0">
|
<span class="input-group-text bg-white border-end-0">
|
||||||
<i class="bi bi-search text-muted"></i>
|
<i class="bi bi-search text-muted"></i>
|
||||||
</span>
|
</span>
|
||||||
@@ -210,6 +234,7 @@
|
|||||||
<th sortable="Name" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="ps-4">Item Name</th>
|
<th sortable="Name" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="ps-4">Item Name</th>
|
||||||
<th sortable="Category" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Category</th>
|
<th sortable="Category" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Category</th>
|
||||||
<th sortable="ColorName" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Color</th>
|
<th sortable="ColorName" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Color</th>
|
||||||
|
<th>Location</th>
|
||||||
<th>Vendor</th>
|
<th>Vendor</th>
|
||||||
<th sortable="QuantityOnHand" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Quantity</th>
|
<th sortable="QuantityOnHand" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection">Quantity</th>
|
||||||
<th>Reorder Point</th>
|
<th>Reorder Point</th>
|
||||||
@@ -250,6 +275,21 @@
|
|||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (!string.IsNullOrEmpty(item.Location))
|
||||||
|
{
|
||||||
|
<a href="@Url.Action("Index", new { location = item.Location })"
|
||||||
|
class="badge bg-info bg-opacity-10 text-info text-decoration-none"
|
||||||
|
onclick="event.stopPropagation();"
|
||||||
|
title="Filter by this location">
|
||||||
|
<i class="bi bi-geo-alt me-1"></i>@item.Location
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@if (!string.IsNullOrEmpty(item.PrimaryVendorName))
|
@if (!string.IsNullOrEmpty(item.PrimaryVendorName))
|
||||||
{
|
{
|
||||||
@@ -300,6 +340,11 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-end pe-4">
|
<td class="text-end pe-4">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button type="button" class="btn btn-outline-secondary"
|
||||||
|
title="Print QR Label"
|
||||||
|
onclick="openQrLabelModal(@item.Id, event)">
|
||||||
|
<i class="bi bi-qr-code"></i>
|
||||||
|
</button>
|
||||||
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-primary" title="View Details">
|
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-primary" title="View Details">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
@@ -352,6 +397,15 @@
|
|||||||
<span class="mobile-card-value">@item.ColorName</span>
|
<span class="mobile-card-value">@item.ColorName</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(item.Location))
|
||||||
|
{
|
||||||
|
<div class="mobile-card-row">
|
||||||
|
<span class="mobile-card-label">Location</span>
|
||||||
|
<span class="mobile-card-value">
|
||||||
|
<i class="bi bi-geo-alt me-1 text-info"></i>@item.Location
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (!string.IsNullOrEmpty(item.PrimaryVendorName))
|
@if (!string.IsNullOrEmpty(item.PrimaryVendorName))
|
||||||
{
|
{
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
@@ -405,6 +459,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mobile-card-footer">
|
<div class="mobile-card-footer">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="openQrLabelModal(@item.Id, event)">
|
||||||
|
<i class="bi bi-qr-code me-1"></i>QR Label
|
||||||
|
</button>
|
||||||
<a href="@Url.Action("Details", new { id = item.Id })"
|
<a href="@Url.Action("Details", new { id = item.Id })"
|
||||||
class="btn btn-sm btn-outline-primary"
|
class="btn btn-sm btn-outline-primary"
|
||||||
onclick="event.stopPropagation();">
|
onclick="event.stopPropagation();">
|
||||||
@@ -428,8 +486,42 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* QR Label Modal (shared across all items — src set dynamically by JS) *@
|
||||||
|
<div class="modal fade" id="qrLabelModal" tabindex="-1" aria-labelledby="qrLabelModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header py-2">
|
||||||
|
<h6 class="modal-title" id="qrLabelModalLabel">
|
||||||
|
<i class="bi bi-qr-code me-2"></i>QR Label
|
||||||
|
</h6>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0 d-flex justify-content-center" style="background:#f0f0f0;min-height:360px;">
|
||||||
|
<iframe id="qrLabelFrame"
|
||||||
|
src="about:blank"
|
||||||
|
style="width:100%;height:400px;border:none;"
|
||||||
|
title="QR Label Preview"></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer py-2">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
onclick="document.getElementById('qrLabelFrame').contentWindow.print()">
|
||||||
|
<i class="bi bi-printer me-2"></i>Print Label
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script>
|
<script>
|
||||||
|
function openQrLabelModal(itemId, e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const frame = document.getElementById('qrLabelFrame');
|
||||||
|
frame.src = '@Url.Action("Label", "Inventory")/' + itemId + '?embed=true';
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('qrLabelModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
// Make table rows clickable
|
// Make table rows clickable
|
||||||
document.querySelectorAll('.inventory-row').forEach(row => {
|
document.querySelectorAll('.inventory-row').forEach(row => {
|
||||||
row.addEventListener('click', function(e) {
|
row.addEventListener('click', function(e) {
|
||||||
|
|||||||
@@ -22,6 +22,11 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.embedded {
|
||||||
|
padding-top: 24px;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.screen-controls {
|
.screen-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -112,8 +117,10 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="@((bool)(ViewBag.IsEmbed ?? false) ? "embedded" : "")">
|
||||||
|
|
||||||
|
@if (!(bool)(ViewBag.IsEmbed ?? false))
|
||||||
|
{
|
||||||
<div class="screen-controls">
|
<div class="screen-controls">
|
||||||
<button class="btn btn-primary" onclick="window.print()">
|
<button class="btn btn-primary" onclick="window.print()">
|
||||||
🖶 Print Label
|
🖶 Print Label
|
||||||
@@ -122,6 +129,7 @@
|
|||||||
← Back to Item
|
← Back to Item
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="label-card">
|
<div class="label-card">
|
||||||
<div class="label-logo">Powder Coating Logix</div>
|
<div class="label-logo">Powder Coating Logix</div>
|
||||||
|
|||||||
@@ -291,7 +291,7 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
<td>@(u.CoatColor ?? "—")</td>
|
<td>@Html.Raw(u.CoatColor ?? "—")</td>
|
||||||
<td class="text-end">@u.EstimatedLbs.ToString("N3")</td>
|
<td class="text-end">@u.EstimatedLbs.ToString("N3")</td>
|
||||||
<td class="text-end fw-semibold">@u.ActualLbsUsed.ToString("N3")</td>
|
<td class="text-end fw-semibold">@u.ActualLbsUsed.ToString("N3")</td>
|
||||||
<td class="text-end @(variance > 0 ? "variance-over" : variance < 0 ? "variance-under" : "")">
|
<td class="text-end @(variance > 0 ? "variance-over" : variance < 0 ? "variance-under" : "")">
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
@model IEnumerable<PowderCoating.Application.DTOs.Inventory.InventoryListDto>
|
||||||
|
@{
|
||||||
|
Layout = null;
|
||||||
|
var location = ViewBag.Location as string ?? "";
|
||||||
|
var printedAt = (DateTime)(ViewBag.PrintedAt ?? DateTime.Now);
|
||||||
|
var items = Model.ToList();
|
||||||
|
}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Bin @location — Inventory</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: Arial, Helvetica, sans-serif; font-size: 11pt; color: #111; background: #fff; padding: 20px; }
|
||||||
|
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 14px; }
|
||||||
|
.header h1 { font-size: 18pt; }
|
||||||
|
.header .meta { font-size: 9pt; color: #555; text-align: right; line-height: 1.6; }
|
||||||
|
.summary { font-size: 9.5pt; color: #444; margin-bottom: 14px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
thead th { background: #f0f0f0; border-top: 1px solid #bbb; border-bottom: 1px solid #bbb; padding: 6px 8px; text-align: left; font-size: 9pt; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; }
|
||||||
|
tbody td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; vertical-align: middle; font-size: 10pt; }
|
||||||
|
tbody tr:last-child td { border-bottom: 2px solid #bbb; }
|
||||||
|
.qty { text-align: right; font-weight: 600; }
|
||||||
|
.qty.low { color: #c00; }
|
||||||
|
.qty.out { color: #888; }
|
||||||
|
.reorder { text-align: right; color: #555; }
|
||||||
|
.cost { text-align: right; }
|
||||||
|
.status-low { font-size: 8pt; color: #c00; font-weight: 600; }
|
||||||
|
.status-out { font-size: 8pt; color: #888; font-weight: 600; }
|
||||||
|
.footer { margin-top: 18px; font-size: 8.5pt; color: #888; border-top: 1px solid #ddd; padding-top: 8px; display: flex; justify-content: space-between; }
|
||||||
|
@@media print {
|
||||||
|
body { padding: 10px; }
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
a { text-decoration: none; color: inherit; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="no-print" style="margin-bottom:16px;">
|
||||||
|
<button onclick="window.print()" style="padding:6px 16px;font-size:11pt;cursor:pointer;margin-right:8px;">
|
||||||
|
🖶 Print
|
||||||
|
</button>
|
||||||
|
<a href="javascript:history.back()" style="font-size:10pt;color:#0d6efd;">← Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h1>Bin: @location</h1>
|
||||||
|
<div style="font-size:10pt;color:#555;margin-top:4px;">Inventory count sheet</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
Powder Coating Logix<br />
|
||||||
|
Printed: @printedAt.ToString("MMM d, yyyy h:mm tt")<br />
|
||||||
|
@items.Count item@(items.Count == 1 ? "" : "s")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!items.Any())
|
||||||
|
{
|
||||||
|
<p style="color:#888;margin-top:20px;">No active inventory items found in this location.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:5%">#</th>
|
||||||
|
<th style="width:45%">Item Name</th>
|
||||||
|
<th style="width:25%">Color</th>
|
||||||
|
<th style="width:25%">SKU</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@{ var row = 0; }
|
||||||
|
@foreach (var item in items)
|
||||||
|
{
|
||||||
|
row++;
|
||||||
|
<tr>
|
||||||
|
<td style="color:#aaa;font-size:9pt;">@row</td>
|
||||||
|
<td><strong>@item.Name</strong></td>
|
||||||
|
<td>@Html.Raw(item.ColorName ?? "—")</td>
|
||||||
|
<td style="font-family:monospace;font-size:9.5pt;">@item.SKU</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>Bin: @location</span>
|
||||||
|
<span>Powder Coating Logix — @printedAt.ToString("yyyy")</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -244,9 +244,9 @@
|
|||||||
<div class="text-muted small">@item.Name</div>
|
<div class="text-muted small">@item.Name</div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>@(item.Manufacturer ?? "—")</td>
|
<td>@Html.Raw(item.Manufacturer ?? "—")</td>
|
||||||
<td class="text-muted small">@(item.ManufacturerPartNumber ?? "—")</td>
|
<td class="text-muted small">@Html.Raw(item.ManufacturerPartNumber ?? "—")</td>
|
||||||
<td>@(item.Finish ?? "—")</td>
|
<td>@Html.Raw(item.Finish ?? "—")</td>
|
||||||
<td>
|
<td>
|
||||||
@if (item.QuantityOnHand > 0)
|
@if (item.QuantityOnHand > 0)
|
||||||
{
|
{
|
||||||
@@ -399,9 +399,9 @@
|
|||||||
<div class="text-muted small">@item.Name</div>
|
<div class="text-muted small">@item.Name</div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>@(item.Manufacturer ?? "—")</td>
|
<td>@Html.Raw(item.Manufacturer ?? "—")</td>
|
||||||
<td class="text-muted small">@(item.ManufacturerPartNumber ?? "—")</td>
|
<td class="text-muted small">@Html.Raw(item.ManufacturerPartNumber ?? "—")</td>
|
||||||
<td>@(item.Finish ?? "—")</td>
|
<td>@Html.Raw(item.Finish ?? "—")</td>
|
||||||
<td class="text-end pe-3">
|
<td class="text-end pe-3">
|
||||||
<button class="btn btn-sm btn-outline-danger me-1 btn-toggle-panel"
|
<button class="btn btn-sm btn-outline-danger me-1 btn-toggle-panel"
|
||||||
data-item-id="@item.Id" data-has-panel="false"
|
data-item-id="@item.Id" data-has-panel="false"
|
||||||
@@ -461,7 +461,7 @@
|
|||||||
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.Manufacturer ?? "")</td>
|
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.Manufacturer ?? "")</td>
|
||||||
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.ManufacturerPartNumber ?? "")</td>
|
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.ManufacturerPartNumber ?? "")</td>
|
||||||
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.Finish ?? "")</td>
|
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.Finish ?? "")</td>
|
||||||
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.QuantityOnHand > 0 ? item.QuantityOnHand.ToString("N2") + " " + item.UnitOfMeasure : "—")</td>
|
<td style="border:1px solid #ccc;padding:6px 10px;">@Html.Raw(item.QuantityOnHand > 0 ? item.QuantityOnHand.ToString("N2") + " " + item.UnitOfMeasure : "—")</td>
|
||||||
<td style="border:1px solid #ccc;padding:6px 10px;"> </td>
|
<td style="border:1px solid #ccc;padding:6px 10px;"> </td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,12 +179,12 @@
|
|||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="text-muted small mb-1">Due Date</label>
|
<label class="text-muted small mb-1">Due Date</label>
|
||||||
<p class="mb-0 @(Model.Status == InvoiceStatus.Overdue ? "text-danger fw-bold" : "")">
|
<p class="mb-0 @(Model.Status == InvoiceStatus.Overdue ? "text-danger fw-bold" : "")">
|
||||||
@(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "—")
|
@Html.Raw(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "—")
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="text-muted small mb-1">Sent Date</label>
|
<label class="text-muted small mb-1">Sent Date</label>
|
||||||
<p class="mb-0">@(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "—")</p>
|
<p class="mb-0">@Html.Raw(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "—")</p>
|
||||||
</div>
|
</div>
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.CustomerPO))
|
@if (!string.IsNullOrWhiteSpace(Model.CustomerPO))
|
||||||
{
|
{
|
||||||
@@ -350,7 +350,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted">
|
<td class="text-muted">
|
||||||
@(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "—")
|
@Html.Raw(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end fw-semibold">@gcItem.TotalPrice.ToString("C")</td>
|
<td class="text-end fw-semibold">@gcItem.TotalPrice.ToString("C")</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -396,7 +396,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>@p.PaymentDate.ToString("MM/dd/yyyy")</td>
|
<td>@p.PaymentDate.ToString("MM/dd/yyyy")</td>
|
||||||
<td>@p.PaymentMethodDisplay</td>
|
<td>@p.PaymentMethodDisplay</td>
|
||||||
<td>@(p.Reference ?? "—")</td>
|
<td>@Html.Raw(p.Reference ?? "—")</td>
|
||||||
<td>
|
<td>
|
||||||
@if (!string.IsNullOrEmpty(p.DepositAccountName))
|
@if (!string.IsNullOrEmpty(p.DepositAccountName))
|
||||||
{
|
{
|
||||||
@@ -407,7 +407,7 @@
|
|||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>@(p.RecordedByName ?? "—")</td>
|
<td>@Html.Raw(p.RecordedByName ?? "—")</td>
|
||||||
<td class="text-end fw-semibold text-success">@p.Amount.ToString("C")</td>
|
<td class="text-end fw-semibold text-success">@p.Amount.ToString("C")</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
@if (!isVoided)
|
@if (!isVoided)
|
||||||
@@ -463,7 +463,7 @@
|
|||||||
<td>@r.RefundDate.ToString("MM/dd/yyyy")</td>
|
<td>@r.RefundDate.ToString("MM/dd/yyyy")</td>
|
||||||
<td>@r.RefundMethodDisplay</td>
|
<td>@r.RefundMethodDisplay</td>
|
||||||
<td>@r.Reason</td>
|
<td>@r.Reason</td>
|
||||||
<td>@(r.Reference ?? "—")</td>
|
<td>@Html.Raw(r.Reference ?? "—")</td>
|
||||||
<td><span class="badge bg-@refundStatusColor">@r.Status</span></td>
|
<td><span class="badge bg-@refundStatusColor">@r.Status</span></td>
|
||||||
<td class="text-end fw-semibold text-danger">(@r.Amount.ToString("C"))</td>
|
<td class="text-end fw-semibold text-danger">(@r.Amount.ToString("C"))</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
|
|||||||
@@ -144,7 +144,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
|
<td>@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
|
||||||
<td class="@(inv.IsOverdue ? "fw-bold text-danger" : "")">
|
<td class="@(inv.IsOverdue ? "fw-bold text-danger" : "")">
|
||||||
@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "—")
|
@Html.Raw(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">@inv.Total.ToString("C")</td>
|
<td class="text-end">@inv.Total.ToString("C")</td>
|
||||||
<td class="text-end @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
|
<td class="text-end @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
<td class="text-end">@gross.ToString("C")</td>
|
<td class="text-end">@gross.ToString("C")</td>
|
||||||
<td class="text-end text-muted">@inv.OnlineSurchargeCollected.ToString("C")</td>
|
<td class="text-end text-muted">@inv.OnlineSurchargeCollected.ToString("C")</td>
|
||||||
<td class="text-end fw-semibold">@net.ToString("C")</td>
|
<td class="text-end fw-semibold">@net.ToString("C")</td>
|
||||||
<td>@dateDisplay</td>
|
<td>@Html.Raw(dateDisplay)</td>
|
||||||
<td><span class="badge @statusClass">@inv.OnlinePaymentStatus</span></td>
|
<td><span class="badge @statusClass">@inv.OnlinePaymentStatus</span></td>
|
||||||
<td>
|
<td>
|
||||||
@if (!string.IsNullOrEmpty(inv.StripePaymentIntentId))
|
@if (!string.IsNullOrEmpty(inv.StripePaymentIntentId))
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@invNum
|
@Html.Raw(invNum)
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>@custName</td>
|
<td>@custName</td>
|
||||||
|
|||||||
@@ -28,6 +28,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (Model.IsReworkJob && Model.OriginalJobId.HasValue)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-3 mb-4">
|
||||||
|
<i class="bi bi-arrow-repeat fs-5 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Rework Job</strong> — This job was created to redo work from
|
||||||
|
<a asp-action="Details" asp-route-id="@Model.OriginalJobId" class="alert-link fw-semibold">
|
||||||
|
@(Model.OriginalJobNumber ?? $"Job #{Model.OriginalJobId}")
|
||||||
|
</a>.
|
||||||
|
All costs for this redo are tracked here separately from the original job.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Status Banner -->
|
<!-- Status Banner -->
|
||||||
<div class="alert alert-@Model.StatusColorClass alert-permanent d-flex align-items-center mb-4">
|
<div class="alert alert-@Model.StatusColorClass alert-permanent d-flex align-items-center mb-4">
|
||||||
<i class="bi bi-info-circle me-2" style="font-size: 1.5rem;"></i>
|
<i class="bi bi-info-circle me-2" style="font-size: 1.5rem;"></i>
|
||||||
@@ -2189,6 +2203,88 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div id="reworkAddForm">
|
<div id="reworkAddForm">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
|
|
||||||
|
<!-- Step 1: Are parts back in the shop? -->
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="rwCreateJobToggle" onchange="rework.toggleCreateJob(this.checked)" />
|
||||||
|
<label class="form-check-label fw-semibold" for="rwCreateJobToggle">
|
||||||
|
<i class="bi bi-briefcase me-1"></i>Parts are back — create a Rework Job in the shop
|
||||||
|
</label>
|
||||||
|
<div class="text-muted small">Turn this on if the parts are physically in the shop and need to go back through the workflow.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item selection: checkboxes when creating a job, single dropdown otherwise -->
|
||||||
|
<div id="rwCreateJobOptions" style="display:none;" class="col-12">
|
||||||
|
<div class="border rounded p-3 bg-light">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold mb-1">Which items need to be redone? <span class="text-danger">*</span></label>
|
||||||
|
<div class="text-muted small mb-2">Only checked items will be copied to the rework job.</div>
|
||||||
|
<div id="rwItemCheckboxes">
|
||||||
|
@if (Model.Items != null)
|
||||||
|
{
|
||||||
|
@foreach (var item in Model.Items)
|
||||||
|
{
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input rw-item-cb" type="checkbox" value="@item.Id" id="rwItem_@item.Id" />
|
||||||
|
<label class="form-check-label" for="rwItem_@item.Id">@item.Description</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label fw-semibold mb-1">Who is responsible? <span class="text-danger">*</span></label>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingShopFault" value="0" />
|
||||||
|
<label class="form-check-label" for="rwPricingShopFault">
|
||||||
|
<strong>Shop Fault</strong>
|
||||||
|
<span class="text-muted small d-block">Our mistake — rework job priced at $0.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingCustomerReduced" value="1" />
|
||||||
|
<label class="form-check-label" for="rwPricingCustomerReduced">
|
||||||
|
<strong>Customer — Reduced Rate</strong>
|
||||||
|
<span class="text-muted small d-block">Customer caused it but we’re helping out — prices copied, edit after creation.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingCustomerFull" value="2" />
|
||||||
|
<label class="form-check-label" for="rwPricingCustomerFull">
|
||||||
|
<strong>Customer — Full Price</strong>
|
||||||
|
<span class="text-muted small d-block">Customer caused it — original pricing applies.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="rwSpecificItemRow" class="col-md-6">
|
||||||
|
<label class="form-label">Specific Item (optional)</label>
|
||||||
|
<select class="form-select" id="rwJobItem">
|
||||||
|
<option value="">– Whole Job –</option>
|
||||||
|
@if (Model.Items != null)
|
||||||
|
{
|
||||||
|
@foreach (var item in Model.Items)
|
||||||
|
{
|
||||||
|
<option value="@item.Id">@item.Description</option>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-0" />
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Type <span class="text-danger">*</span></label>
|
<label class="form-label">Type <span class="text-danger">*</span></label>
|
||||||
<select class="form-select" id="rwType">
|
<select class="form-select" id="rwType">
|
||||||
@@ -2215,19 +2311,6 @@
|
|||||||
<label class="form-label">Defect Description <span class="text-danger">*</span></label>
|
<label class="form-label">Defect Description <span class="text-danger">*</span></label>
|
||||||
<textarea class="form-control" id="rwDefect" rows="2" placeholder="Describe the defect or issue..."></textarea>
|
<textarea class="form-control" id="rwDefect" rows="2" placeholder="Describe the defect or issue..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Specific Item (optional)</label>
|
|
||||||
<select class="form-select" id="rwJobItem">
|
|
||||||
<option value="">– Whole Job –</option>
|
|
||||||
@if (Model.Items != null)
|
|
||||||
{
|
|
||||||
@foreach (var item in Model.Items)
|
|
||||||
{
|
|
||||||
<option value="@item.Id">@item.Description</option>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Discovered By</label>
|
<label class="form-label">Discovered By</label>
|
||||||
<select class="form-select" id="rwDiscoveredBy">
|
<select class="form-select" id="rwDiscoveredBy">
|
||||||
@@ -2654,9 +2737,20 @@
|
|||||||
document.getElementById('rwBillingNotes').value = '';
|
document.getElementById('rwBillingNotes').value = '';
|
||||||
document.getElementById('rwReportedBy').value = '';
|
document.getElementById('rwReportedBy').value = '';
|
||||||
document.getElementById('rwDiscoveredDate').value = new Date().toISOString().split('T')[0];
|
document.getElementById('rwDiscoveredDate').value = new Date().toISOString().split('T')[0];
|
||||||
|
// Reset rework job creation section
|
||||||
|
document.getElementById('rwCreateJobToggle').checked = false;
|
||||||
|
document.getElementById('rwCreateJobOptions').style.display = 'none';
|
||||||
|
document.querySelectorAll('.rw-item-cb').forEach(cb => cb.checked = false);
|
||||||
|
document.querySelectorAll('input[name="rwPricingType"]').forEach(r => r.checked = false);
|
||||||
modal.show();
|
modal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCreateJob(on) {
|
||||||
|
document.getElementById('rwCreateJobOptions').style.display = on ? '' : 'none';
|
||||||
|
document.getElementById('rwSpecificItemRow').style.display = on ? 'none' : '';
|
||||||
|
if (!on) document.getElementById('rwJobItem').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
function openEdit(id) {
|
function openEdit(id) {
|
||||||
editId = id;
|
editId = id;
|
||||||
const r = records.find(x => x.id === id);
|
const r = records.find(x => x.id === id);
|
||||||
@@ -2685,9 +2779,23 @@
|
|||||||
// Create
|
// Create
|
||||||
const defect = document.getElementById('rwDefect').value.trim();
|
const defect = document.getElementById('rwDefect').value.trim();
|
||||||
if (!defect) { alert('Defect description is required.'); return; }
|
if (!defect) { alert('Defect description is required.'); return; }
|
||||||
|
|
||||||
|
const createJob = document.getElementById('rwCreateJobToggle').checked;
|
||||||
|
const selectedItemIds = createJob
|
||||||
|
? Array.from(document.querySelectorAll('.rw-item-cb:checked')).map(cb => parseInt(cb.value))
|
||||||
|
: null;
|
||||||
|
const pricingRadio = document.querySelector('input[name="rwPricingType"]:checked');
|
||||||
|
|
||||||
|
if (createJob && (!selectedItemIds || selectedItemIds.length === 0)) {
|
||||||
|
alert('Select at least one item to include in the rework job.'); return;
|
||||||
|
}
|
||||||
|
if (createJob && !pricingRadio) {
|
||||||
|
alert('Select who is responsible for this rework.'); return;
|
||||||
|
}
|
||||||
|
|
||||||
const dto = {
|
const dto = {
|
||||||
jobId: jid,
|
jobId: jid,
|
||||||
jobItemId: document.getElementById('rwJobItem').value || null,
|
jobItemId: createJob ? null : (document.getElementById('rwJobItem').value || null),
|
||||||
reworkType: parseInt(document.getElementById('rwType').value),
|
reworkType: parseInt(document.getElementById('rwType').value),
|
||||||
reason: parseInt(document.getElementById('rwReason').value),
|
reason: parseInt(document.getElementById('rwReason').value),
|
||||||
defectDescription: defect,
|
defectDescription: defect,
|
||||||
@@ -2696,7 +2804,10 @@
|
|||||||
reportedByName: document.getElementById('rwReportedBy').value || null,
|
reportedByName: document.getElementById('rwReportedBy').value || null,
|
||||||
estimatedReworkCost: parseFloat(document.getElementById('rwEstCost').value) || 0,
|
estimatedReworkCost: parseFloat(document.getElementById('rwEstCost').value) || 0,
|
||||||
isBillableToCustomer: document.getElementById('rwBillable').checked,
|
isBillableToCustomer: document.getElementById('rwBillable').checked,
|
||||||
billingNotes: document.getElementById('rwBillingNotes').value || null
|
billingNotes: document.getElementById('rwBillingNotes').value || null,
|
||||||
|
createReworkJob: createJob,
|
||||||
|
reworkJobItemIds: selectedItemIds,
|
||||||
|
reworkPricingType: pricingRadio ? parseInt(pricingRadio.value) : null
|
||||||
};
|
};
|
||||||
const resp = await fetch('/Jobs/AddReworkRecord', {
|
const resp = await fetch('/Jobs/AddReworkRecord', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -2741,7 +2852,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
return { load, openAdd, openEdit, save, del };
|
return { load, openAdd, openEdit, save, del, toggleCreateJob };
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -2906,8 +3017,8 @@
|
|||||||
|
|
||||||
function updateTotals(total) {
|
function updateTotals(total) {
|
||||||
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '—';
|
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '—';
|
||||||
document.getElementById('totalHoursDisplay').textContent = fmt;
|
document.getElementById('totalHoursDisplay').innerHTML = fmt;
|
||||||
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '—';
|
document.getElementById('timeEntriesTotalHours').innerHTML = total > 0 ? total.toFixed(2) : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Modal helpers -------------------------------------------------
|
// -- Modal helpers -------------------------------------------------
|
||||||
@@ -3245,3 +3356,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -483,10 +483,10 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="text-align: center;">@coat.Sequence</td>
|
<td style="text-align: center;">@coat.Sequence</td>
|
||||||
<td><strong>@coat.CoatName</strong></td>
|
<td><strong>@coat.CoatName</strong></td>
|
||||||
<td>@(coat.ColorName ?? "—")</td>
|
<td>@Html.Raw(coat.ColorName ?? "—")</td>
|
||||||
<td>@(coat.ColorCode ?? "—")</td>
|
<td>@Html.Raw(coat.ColorCode ?? "—")</td>
|
||||||
<td>@(coat.Finish ?? "—")</td>
|
<td>@Html.Raw(coat.Finish ?? "—")</td>
|
||||||
<td>@(coat.VendorName ?? "—")</td>
|
<td>@Html.Raw(coat.VendorName ?? "—")</td>
|
||||||
<td>
|
<td>
|
||||||
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -561,7 +561,7 @@
|
|||||||
<tr onclick="window.location='@Url.Action("Details", "Maintenance", new { id = item.Id })'"
|
<tr onclick="window.location='@Url.Action("Details", "Maintenance", new { id = item.Id })'"
|
||||||
style="cursor: pointer;">
|
style="cursor: pointer;">
|
||||||
<td>
|
<td>
|
||||||
<strong>@(item.Equipment?.EquipmentName ?? "—")</strong>
|
<strong>@Html.Raw(item.Equipment?.EquipmentName ?? "—")</strong>
|
||||||
@if (!string.IsNullOrEmpty(item.Equipment?.Location))
|
@if (!string.IsNullOrEmpty(item.Equipment?.Location))
|
||||||
{
|
{
|
||||||
<br /><small class="text-muted"><i class="bi bi-geo-alt me-1"></i>@item.Equipment.Location</small>
|
<br /><small class="text-muted"><i class="bi bi-geo-alt me-1"></i>@item.Equipment.Location</small>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
<dt class="col-5 text-muted">Posted</dt>
|
<dt class="col-5 text-muted">Posted</dt>
|
||||||
<dd class="col-7 small">
|
<dd class="col-7 small">
|
||||||
@Model.PostedAt.Value.ToLocalTime().ToString("MMM d, yyyy h:mm tt")<br />
|
@Model.PostedAt.Value.ToLocalTime().ToString("MMM d, yyyy h:mm tt")<br />
|
||||||
<span class="text-muted">by @(Model.PostedBy ?? "—")</span>
|
<span class="text-muted">by @Html.Raw(Model.PostedBy ?? "—")</span>
|
||||||
</dd>
|
</dd>
|
||||||
}
|
}
|
||||||
<dt class="col-5 text-muted">Created</dt>
|
<dt class="col-5 text-muted">Created</dt>
|
||||||
|
|||||||
@@ -188,16 +188,16 @@
|
|||||||
@{
|
@{
|
||||||
var firstActivity = row.FirstJobCreatedAt ?? row.FirstQuoteCreatedAt;
|
var firstActivity = row.FirstJobCreatedAt ?? row.FirstQuoteCreatedAt;
|
||||||
}
|
}
|
||||||
@(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "—")
|
@Html.Raw(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">
|
<td class="text-muted small">
|
||||||
@(row.FirstInvoiceCreatedAt.HasValue ? row.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "—")
|
@Html.Raw(row.FirstInvoiceCreatedAt.HasValue ? row.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">
|
<td class="text-muted small">
|
||||||
@(row.FirstWorkflowCompletedAt.HasValue ? row.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "—")
|
@Html.Raw(row.FirstWorkflowCompletedAt.HasValue ? row.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">
|
<td class="text-muted small">
|
||||||
@(row.GuidedActivationDismissedAt.HasValue ? row.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "—")
|
@Html.Raw(row.GuidedActivationDismissedAt.HasValue ? row.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@switch (row.Status)
|
@switch (row.Status)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<dl class="row small mb-0">
|
<dl class="row small mb-0">
|
||||||
<dt class="col-5 text-muted">Company</dt>
|
<dt class="col-5 text-muted">Company</dt>
|
||||||
<dd class="col-7">@(ViewBag.CompanyName ?? (Model.CompanyId > 0 ? $"#{Model.CompanyId}" : "—"))</dd>
|
<dd class="col-7">@Html.Raw(ViewBag.CompanyName ?? (Model.CompanyId > 0 ? $"#{Model.CompanyId}" : "—"))</dd>
|
||||||
<dt class="col-5 text-muted">Type</dt>
|
<dt class="col-5 text-muted">Type</dt>
|
||||||
<dd class="col-7">@Model.NotificationType</dd>
|
<dd class="col-7">@Model.NotificationType</dd>
|
||||||
<dt class="col-5 text-muted">Channel</dt>
|
<dt class="col-5 text-muted">Channel</dt>
|
||||||
|
|||||||
@@ -188,13 +188,13 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="text-muted">Monthly ID</td>
|
<td class="text-muted">Monthly ID</td>
|
||||||
<td class="font-monospace small text-truncate" style="max-width: 120px;" title="@plan.StripePriceIdMonthly">
|
<td class="font-monospace small text-truncate" style="max-width: 120px;" title="@plan.StripePriceIdMonthly">
|
||||||
@(string.IsNullOrEmpty(plan.StripePriceIdMonthly) ? "—" : plan.StripePriceIdMonthly)
|
@Html.Raw(string.IsNullOrEmpty(plan.StripePriceIdMonthly) ? "—" : plan.StripePriceIdMonthly)
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-muted">Annual ID</td>
|
<td class="text-muted">Annual ID</td>
|
||||||
<td class="font-monospace small text-truncate" style="max-width: 120px;" title="@plan.StripePriceIdAnnual">
|
<td class="font-monospace small text-truncate" style="max-width: 120px;" title="@plan.StripePriceIdAnnual">
|
||||||
@(string.IsNullOrEmpty(plan.StripePriceIdAnnual) ? "—" : plan.StripePriceIdAnnual)
|
@Html.Raw(string.IsNullOrEmpty(plan.StripePriceIdAnnual) ? "—" : plan.StripePriceIdAnnual)
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
<td class="text-end fw-semibold">@item.CurrentStockLbs.ToString("0.##") lbs</td>
|
<td class="text-end fw-semibold">@item.CurrentStockLbs.ToString("0.##") lbs</td>
|
||||||
<td class="text-end">@item.ScheduledDemandLbs.ToString("0.##") lbs</td>
|
<td class="text-end">@item.ScheduledDemandLbs.ToString("0.##") lbs</td>
|
||||||
<td class="text-end @(item.ShortfallLbs > 0 ? "text-danger fw-bold" : "text-muted")">
|
<td class="text-end @(item.ShortfallLbs > 0 ? "text-danger fw-bold" : "text-muted")">
|
||||||
@(item.ShortfallLbs > 0 ? $"{item.ShortfallLbs:0.##} lbs" : "—")
|
@Html.Raw(item.ShortfallLbs > 0 ? $"{item.ShortfallLbs:0.##} lbs" : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">@item.ActiveJobCount</td>
|
<td class="text-center">@item.ActiveJobCount</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@@ -344,7 +344,7 @@
|
|||||||
<br /><small class="text-muted">@w.CoatName</small>
|
<br /><small class="text-muted">@w.CoatName</small>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">@(w.InventoryItemName ?? "Custom")</td>
|
<td class="text-muted small">@(w.InventoryItemName ?? "Custom")</td>
|
||||||
<td>@(w.Complexity ?? "—")</td>
|
<td>@Html.Raw(w.Complexity ?? "—")</td>
|
||||||
<td class="text-end">@w.EstimatedLbs.ToString("0.##") lbs</td>
|
<td class="text-end">@w.EstimatedLbs.ToString("0.##") lbs</td>
|
||||||
<td class="text-end">@w.ActualLbs.ToString("0.##") lbs</td>
|
<td class="text-end">@w.ActualLbs.ToString("0.##") lbs</td>
|
||||||
<td class="text-end text-danger fw-bold">+@w.OveragePct.ToString("0.#")%</td>
|
<td class="text-end text-danger fw-bold">+@w.OveragePct.ToString("0.#")%</td>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ else
|
|||||||
<span class="text-muted"> · @coat.ColorName</span>
|
<span class="text-muted"> · @coat.ColorName</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end small">@(coat.EstimatedLbs.HasValue ? $"{coat.EstimatedLbs:0.##}" : "—")</td>
|
<td class="text-end small">@Html.Raw(coat.EstimatedLbs.HasValue ? $"{coat.EstimatedLbs:0.##}" : "—")</td>
|
||||||
<td class="text-end small">
|
<td class="text-end small">
|
||||||
@if (coat.IsRecorded)
|
@if (coat.IsRecorded)
|
||||||
{
|
{
|
||||||
@@ -61,10 +61,10 @@ else
|
|||||||
<td>Total</td>
|
<td>Total</td>
|
||||||
<td class="text-end">@Model.TotalEstimatedLbs.ToString("0.##") lbs</td>
|
<td class="text-end">@Model.TotalEstimatedLbs.ToString("0.##") lbs</td>
|
||||||
<td class="text-end @(Model.TotalActualLbs > 0 ? "text-success" : "")">
|
<td class="text-end @(Model.TotalActualLbs > 0 ? "text-success" : "")">
|
||||||
@(Model.TotalActualLbs > 0 ? $"{Model.TotalActualLbs:0.##} lbs" : "—")
|
@Html.Raw(Model.TotalActualLbs > 0 ? $"{Model.TotalActualLbs:0.##} lbs" : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end @(Model.TotalVarianceLbs > 0 ? "text-danger" : Model.TotalVarianceLbs < 0 ? "text-success" : "")">
|
<td class="text-end @(Model.TotalVarianceLbs > 0 ? "text-danger" : Model.TotalVarianceLbs < 0 ? "text-success" : "")">
|
||||||
@(Model.TotalActualLbs > 0 ? $"{(Model.TotalVarianceLbs > 0 ? "+" : "")}{Model.TotalVarianceLbs:0.##} lbs" : "—")
|
@Html.Raw(Model.TotalActualLbs > 0 ? $"{(Model.TotalVarianceLbs > 0 ? "+" : "")}{Model.TotalVarianceLbs:0.##} lbs" : "—")
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
<strong>@tier.TierName</strong>
|
<strong>@tier.TierName</strong>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="text-muted small">@(tier.Description ?? "—")</span>
|
<span class="text-muted small">@Html.Raw(tier.Description ?? "—")</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@if (tier.DiscountPercent == 0)
|
@if (tier.DiscountPercent == 0)
|
||||||
|
|||||||
@@ -256,7 +256,7 @@
|
|||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<span class="text-muted">Expected Delivery</span>
|
<span class="text-muted">Expected Delivery</span>
|
||||||
<span class="@(Model.IsOverdue ? "text-danger fw-semibold" : "")">
|
<span class="@(Model.IsOverdue ? "text-danger fw-semibold" : "")">
|
||||||
@(Model.ExpectedDeliveryDate?.ToString("MM/dd/yyyy") ?? "—")
|
@Html.Raw(Model.ExpectedDeliveryDate?.ToString("MM/dd/yyyy") ?? "—")
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if (Model.ReceivedDate.HasValue)
|
@if (Model.ReceivedDate.HasValue)
|
||||||
|
|||||||
@@ -260,7 +260,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>@po.OrderDate.ToString("MM/dd/yyyy")</td>
|
<td>@po.OrderDate.ToString("MM/dd/yyyy")</td>
|
||||||
<td>@(po.ExpectedDeliveryDate?.ToString("MM/dd/yyyy") ?? "—")</td>
|
<td>@Html.Raw(po.ExpectedDeliveryDate?.ToString("MM/dd/yyyy") ?? "—")</td>
|
||||||
<td class="text-center">@po.ItemCount</td>
|
<td class="text-center">@po.ItemCount</td>
|
||||||
<td class="text-end fw-semibold">$@po.TotalAmount.ToString("N2")</td>
|
<td class="text-end fw-semibold">$@po.TotalAmount.ToString("N2")</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
|
|||||||
@@ -156,11 +156,11 @@ else
|
|||||||
</a>
|
</a>
|
||||||
<span class="badge bg-secondary ms-1">@vend.Bills.Count bill@(vend.Bills.Count == 1 ? "" : "s")</span>
|
<span class="badge bg-secondary ms-1">@vend.Bills.Count bill@(vend.Bills.Count == 1 ? "" : "s")</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end aging-current">@(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—")</td>
|
<td class="text-end aging-current">@Html.Raw(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—")</td>
|
||||||
<td class="text-end aging-1-30">@(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—")</td>
|
<td class="text-end aging-1-30">@Html.Raw(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—")</td>
|
||||||
<td class="text-end aging-31-60">@(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—")</td>
|
<td class="text-end aging-31-60">@Html.Raw(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—")</td>
|
||||||
<td class="text-end aging-61-90">@(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—")</td>
|
<td class="text-end aging-61-90">@Html.Raw(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—")</td>
|
||||||
<td class="text-end aging-over90">@(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—")</td>
|
<td class="text-end aging-over90">@Html.Raw(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—")</td>
|
||||||
<td class="text-end fw-semibold">@vend.TotalBalance.ToString("C")</td>
|
<td class="text-end fw-semibold">@vend.TotalBalance.ToString("C")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ else
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">@bill.BillDate.ToString("MM/dd/yyyy")</td>
|
<td class="text-muted small">@bill.BillDate.ToString("MM/dd/yyyy")</td>
|
||||||
<td class="text-muted small">@(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—")</td>
|
<td class="text-muted small">@Html.Raw(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—")</td>
|
||||||
<td class="text-end fw-semibold @(bill.DaysOverdue > 30 ? "text-danger" : "")">@bill.BalanceDue.ToString("C")</td>
|
<td class="text-end fw-semibold @(bill.DaysOverdue > 30 ? "text-danger" : "")">@bill.BalanceDue.ToString("C")</td>
|
||||||
<td class="text-end"><span class="badge @ageBadge">@ageLabel</span></td>
|
<td class="text-end"><span class="badge @ageBadge">@ageLabel</span></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
|||||||
@@ -156,11 +156,11 @@ else
|
|||||||
</a>
|
</a>
|
||||||
<span class="badge bg-secondary ms-1">@cust.Invoices.Count inv.</span>
|
<span class="badge bg-secondary ms-1">@cust.Invoices.Count inv.</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end aging-current">@(cust.TotalCurrent > 0 ? cust.TotalCurrent.ToString("C") : "—")</td>
|
<td class="text-end aging-current">@Html.Raw(cust.TotalCurrent > 0 ? cust.TotalCurrent.ToString("C") : "—")</td>
|
||||||
<td class="text-end aging-1-30">@(cust.Total1to30 > 0 ? cust.Total1to30.ToString("C") : "—")</td>
|
<td class="text-end aging-1-30">@Html.Raw(cust.Total1to30 > 0 ? cust.Total1to30.ToString("C") : "—")</td>
|
||||||
<td class="text-end aging-31-60">@(cust.Total31to60 > 0 ? cust.Total31to60.ToString("C") : "—")</td>
|
<td class="text-end aging-31-60">@Html.Raw(cust.Total31to60 > 0 ? cust.Total31to60.ToString("C") : "—")</td>
|
||||||
<td class="text-end aging-61-90">@(cust.Total61to90 > 0 ? cust.Total61to90.ToString("C") : "—")</td>
|
<td class="text-end aging-61-90">@Html.Raw(cust.Total61to90 > 0 ? cust.Total61to90.ToString("C") : "—")</td>
|
||||||
<td class="text-end aging-over90">@(cust.TotalOver90 > 0 ? cust.TotalOver90.ToString("C") : "—")</td>
|
<td class="text-end aging-over90">@Html.Raw(cust.TotalOver90 > 0 ? cust.TotalOver90.ToString("C") : "—")</td>
|
||||||
<td class="text-end fw-semibold">@cust.TotalBalance.ToString("C")</td>
|
<td class="text-end fw-semibold">@cust.TotalBalance.ToString("C")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ else
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
|
<td class="text-muted small">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
|
||||||
<td class="text-muted small">@(inv.DueDate?.ToString("MM/dd/yyyy") ?? "—")</td>
|
<td class="text-muted small">@Html.Raw(inv.DueDate?.ToString("MM/dd/yyyy") ?? "—")</td>
|
||||||
<td class="text-end fw-semibold @(inv.DaysOverdue > 30 ? "text-danger" : "")">@inv.BalanceDue.ToString("C")</td>
|
<td class="text-end fw-semibold @(inv.DaysOverdue > 30 ? "text-danger" : "")">@inv.BalanceDue.ToString("C")</td>
|
||||||
<td class="text-end"><span class="badge @ageBadge">@ageLabel</span></td>
|
<td class="text-end"><span class="badge @ageBadge">@ageLabel</span></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
|||||||
@@ -101,11 +101,11 @@
|
|||||||
@item.CustomerName
|
@item.CustomerName
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="small">@(item.Email ?? "—")</td>
|
<td class="small">@Html.Raw(item.Email ?? "—")</td>
|
||||||
<td class="small">@(item.Phone ?? "—")</td>
|
<td class="small">@Html.Raw(item.Phone ?? "—")</td>
|
||||||
<td class="text-end">@item.TotalJobs</td>
|
<td class="text-end">@item.TotalJobs</td>
|
||||||
<td class="text-end">@item.LifetimeRevenue.ToString("C")</td>
|
<td class="text-end">@item.LifetimeRevenue.ToString("C")</td>
|
||||||
<td>@(item.LastJobDate?.ToString("MMM d, yyyy") ?? "—")</td>
|
<td>@Html.Raw(item.LastJobDate?.ToString("MMM d, yyyy") ?? "—")</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
@if (item.DaysSinceLastJob < 0)
|
@if (item.DaysSinceLastJob < 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1604,7 +1604,7 @@
|
|||||||
<div class="fw-medium">@color.DisplayLabel</div>
|
<div class="fw-medium">@color.DisplayLabel</div>
|
||||||
<div class="text-muted small">@color.SKU</div>
|
<div class="text-muted small">@color.SKU</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">@(color.Manufacturer ?? "—")</td>
|
<td class="text-muted small">@Html.Raw(color.Manufacturer ?? "—")</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div>@color.TotalLbsUsed.ToString("N1") lbs</div>
|
<div>@color.TotalLbsUsed.ToString("N1") lbs</div>
|
||||||
<div class="progress mt-1" style="height:4px; min-width:80px;">
|
<div class="progress mt-1" style="height:4px; min-width:80px;">
|
||||||
@@ -1749,7 +1749,7 @@
|
|||||||
@c.BalanceDue.ToString("C")
|
@c.BalanceDue.ToString("C")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end text-muted">@c.AvgInvoiceValue.ToString("C")</td>
|
<td class="text-end text-muted">@c.AvgInvoiceValue.ToString("C")</td>
|
||||||
<td class="text-muted small">@(c.LastInvoiceDate?.ToString("MMM d, yyyy") ?? "—")</td>
|
<td class="text-muted small">@Html.Raw(c.LastInvoiceDate?.ToString("MMM d, yyyy") ?? "—")</td>
|
||||||
<td class="pe-3">
|
<td class="pe-3">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<div class="progress flex-grow-1" style="height:6px;">
|
<div class="progress flex-grow-1" style="height:6px;">
|
||||||
@@ -1896,10 +1896,10 @@
|
|||||||
<span class="badge @badgeClass">@r.RetentionStatus</span>
|
<span class="badge @badgeClass">@r.RetentionStatus</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center small">
|
<td class="text-center small">
|
||||||
@(r.LastJobDate.HasValue ? r.LastJobDate.Value.ToString("MMM d, yyyy") : "—")
|
@Html.Raw(r.LastJobDate.HasValue ? r.LastJobDate.Value.ToString("MMM d, yyyy") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center small">
|
<td class="text-center small">
|
||||||
@(r.DaysSinceLastJob >= 0 ? r.DaysSinceLastJob + "d" : "—")
|
@Html.Raw(r.DaysSinceLastJob >= 0 ? r.DaysSinceLastJob + "d" : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">@r.TotalJobs</td>
|
<td class="text-end">@r.TotalJobs</td>
|
||||||
<td class="text-end pe-3 fw-semibold">@r.LifetimeRevenue.ToString("C0")</td>
|
<td class="text-end pe-3 fw-semibold">@r.LifetimeRevenue.ToString("C0")</td>
|
||||||
@@ -2235,7 +2235,7 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="small">@inv.InvoiceDate.ToString("MMM d, yyyy")</td>
|
<td class="small">@inv.InvoiceDate.ToString("MMM d, yyyy")</td>
|
||||||
<td class="small">@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MMM d, yyyy") : "—")</td>
|
<td class="small">@Html.Raw(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MMM d, yyyy") : "—")</td>
|
||||||
<td class="text-end">@inv.Total.ToString("C")</td>
|
<td class="text-end">@inv.Total.ToString("C")</td>
|
||||||
<td class="text-end text-success">@inv.AmountPaid.ToString("C")</td>
|
<td class="text-end text-success">@inv.AmountPaid.ToString("C")</td>
|
||||||
<td class="text-end fw-semibold @(inv.BalanceDue > 0 ? "text-danger" : "")">@inv.BalanceDue.ToString("C")</td>
|
<td class="text-end fw-semibold @(inv.BalanceDue > 0 ? "text-danger" : "")">@inv.BalanceDue.ToString("C")</td>
|
||||||
@@ -2349,7 +2349,7 @@
|
|||||||
<div class="small text-muted">@p.ColorCode @(!string.IsNullOrEmpty(p.SKU) ? $"· {p.SKU}" : "")</div>
|
<div class="small text-muted">@p.ColorCode @(!string.IsNullOrEmpty(p.SKU) ? $"· {p.SKU}" : "")</div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="small text-muted">@(p.Manufacturer ?? "—")</td>
|
<td class="small text-muted">@Html.Raw(p.Manufacturer ?? "—")</td>
|
||||||
<td class="text-end">@p.TotalPurchasedLbs.ToString("N1")</td>
|
<td class="text-end">@p.TotalPurchasedLbs.ToString("N1")</td>
|
||||||
<td class="text-end">@p.TotalConsumedLbs.ToString("N1")</td>
|
<td class="text-end">@p.TotalConsumedLbs.ToString("N1")</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
<div class="small text-muted">@item.SKU</div>
|
<div class="small text-muted">@item.SKU</div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>@(item.ColorName ?? "—")</td>
|
<td>@Html.Raw(item.ColorName ?? "—")</td>
|
||||||
<td class="text-end">@item.CurrentStockLbs.ToString("N1")</td>
|
<td class="text-end">@item.CurrentStockLbs.ToString("N1")</td>
|
||||||
<td class="text-end">@item.TotalConsumedLbs.ToString("N1")</td>
|
<td class="text-end">@item.TotalConsumedLbs.ToString("N1")</td>
|
||||||
<td class="text-end">@item.TotalPurchasedLbs.ToString("N1")</td>
|
<td class="text-end">@item.TotalPurchasedLbs.ToString("N1")</td>
|
||||||
|
|||||||
@@ -85,12 +85,12 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>@item.InvoiceDate.ToString("MMM d, yyyy")</td>
|
<td>@item.InvoiceDate.ToString("MMM d, yyyy")</td>
|
||||||
<td>@(item.DueDate?.ToString("MMM d, yyyy") ?? "—")</td>
|
<td>@Html.Raw(item.DueDate?.ToString("MMM d, yyyy") ?? "—")</td>
|
||||||
<td class="text-end">@item.Total.ToString("C")</td>
|
<td class="text-end">@item.Total.ToString("C")</td>
|
||||||
<td class="text-end text-success">@item.AmountPaid.ToString("C")</td>
|
<td class="text-end text-success">@item.AmountPaid.ToString("C")</td>
|
||||||
<td class="text-end fw-semibold">@item.BalanceDue.ToString("C")</td>
|
<td class="text-end fw-semibold">@item.BalanceDue.ToString("C")</td>
|
||||||
<td class="text-end @bucketClass">
|
<td class="text-end @bucketClass">
|
||||||
@(item.DaysOverdue > 0 ? item.DaysOverdue.ToString() : "—")
|
@Html.Raw(item.DaysOverdue > 0 ? item.DaysOverdue.ToString() : "—")
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge @bucketClass bg-opacity-10 border">@item.AgingBucket</span></td>
|
<td><span class="badge @bucketClass bg-opacity-10 border">@item.AgingBucket</span></td>
|
||||||
<td><span class="badge bg-secondary-subtle text-secondary">@item.StatusDisplay</span></td>
|
<td><span class="badge bg-secondary-subtle text-secondary">@item.StatusDisplay</span></td>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
{
|
{
|
||||||
<tr class="@(i.QuantityOnHand == 0 ? "table-danger" : "table-warning")">
|
<tr class="@(i.QuantityOnHand == 0 ? "table-danger" : "table-warning")">
|
||||||
<td>@i.Name</td>
|
<td>@i.Name</td>
|
||||||
<td>@(i.ColorName ?? "—")</td>
|
<td>@Html.Raw(i.ColorName ?? "—")</td>
|
||||||
<td class="text-end fw-semibold text-danger">@i.QuantityOnHand.ToString("N1")</td>
|
<td class="text-end fw-semibold text-danger">@i.QuantityOnHand.ToString("N1")</td>
|
||||||
<td class="text-end">@i.ReorderPoint.ToString("N1")</td>
|
<td class="text-end">@i.ReorderPoint.ToString("N1")</td>
|
||||||
<td class="text-muted small">@i.UnitOfMeasure</td>
|
<td class="text-muted small">@i.UnitOfMeasure</td>
|
||||||
|
|||||||
@@ -60,13 +60,13 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@(item.ColorName ?? "—")
|
@Html.Raw(item.ColorName ?? "—")
|
||||||
@if (!string.IsNullOrEmpty(item.ColorCode))
|
@if (!string.IsNullOrEmpty(item.ColorCode))
|
||||||
{
|
{
|
||||||
<span class="badge bg-secondary-subtle text-secondary ms-1">@item.ColorCode</span>
|
<span class="badge bg-secondary-subtle text-secondary ms-1">@item.ColorCode</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted">@(item.Manufacturer ?? "—")</td>
|
<td class="text-muted">@Html.Raw(item.Manufacturer ?? "—")</td>
|
||||||
<td class="text-end">@item.TotalPurchasedLbs.ToString("N1")</td>
|
<td class="text-end">@item.TotalPurchasedLbs.ToString("N1")</td>
|
||||||
<td class="text-end">@item.TotalConsumedLbs.ToString("N1")</td>
|
<td class="text-end">@item.TotalConsumedLbs.ToString("N1")</td>
|
||||||
<td class="text-end fw-semibold @varianceClass">@item.VarianceLbs.ToString("N1")</td>
|
<td class="text-end fw-semibold @varianceClass">@item.VarianceLbs.ToString("N1")</td>
|
||||||
|
|||||||
@@ -79,8 +79,8 @@
|
|||||||
<td>
|
<td>
|
||||||
@item.DisplayLabel
|
@item.DisplayLabel
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">@(item.SKU ?? "—")</td>
|
<td class="text-muted small">@Html.Raw(item.SKU ?? "—")</td>
|
||||||
<td class="text-muted small">@(item.Manufacturer ?? "—")</td>
|
<td class="text-muted small">@Html.Raw(item.Manufacturer ?? "—")</td>
|
||||||
<td class="text-end fw-semibold">@item.TotalLbsUsed.ToString("N1")</td>
|
<td class="text-end fw-semibold">@item.TotalLbsUsed.ToString("N1")</td>
|
||||||
<td class="text-end">@item.TotalCost.ToString("C")</td>
|
<td class="text-end">@item.TotalCost.ToString("C")</td>
|
||||||
<td class="text-end">@item.JobCount</td>
|
<td class="text-end">@item.JobCount</td>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="ps-4">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
|
<td class="ps-4">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
|
||||||
<td class="text-end">@line.Amount.ToString("C")</td>
|
<td class="text-end">@line.Amount.ToString("C")</td>
|
||||||
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
|
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "—" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
<tr class="report-subtotal-row">
|
<tr class="report-subtotal-row">
|
||||||
@@ -169,18 +169,18 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="ps-4">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
|
<td class="ps-4">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
|
||||||
<td class="text-end">@line.Amount.ToString("C")</td>
|
<td class="text-end">@line.Amount.ToString("C")</td>
|
||||||
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
|
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "—" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
<tr class="report-subtotal-row">
|
<tr class="report-subtotal-row">
|
||||||
<td class="ps-4 fw-semibold">Total COGS</td>
|
<td class="ps-4 fw-semibold">Total COGS</td>
|
||||||
<td class="text-end fw-semibold text-warning">(@Model.TotalCogs.ToString("C"))</td>
|
<td class="text-end fw-semibold text-warning">(@Model.TotalCogs.ToString("C"))</td>
|
||||||
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : (Model.TotalCogs / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
|
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "—" : (Model.TotalCogs / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="report-subtotal-row">
|
<tr class="report-subtotal-row">
|
||||||
<td class="ps-2 fw-semibold">Gross Profit</td>
|
<td class="ps-2 fw-semibold">Gross Profit</td>
|
||||||
<td class="text-end fw-semibold @(Model.GrossProfit >= 0 ? "text-success" : "text-danger")">@Model.GrossProfit.ToString("C")</td>
|
<td class="text-end fw-semibold @(Model.GrossProfit >= 0 ? "text-success" : "text-danger")">@Model.GrossProfit.ToString("C")</td>
|
||||||
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : Model.GrossMarginPercent.ToString("F1") + "%")</td>
|
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "—" : Model.GrossMarginPercent.ToString("F1") + "%")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,20 +198,20 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="ps-4">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
|
<td class="ps-4">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
|
||||||
<td class="text-end">@line.Amount.ToString("C")</td>
|
<td class="text-end">@line.Amount.ToString("C")</td>
|
||||||
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
|
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "—" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
<tr class="report-subtotal-row">
|
<tr class="report-subtotal-row">
|
||||||
<td class="ps-4 fw-semibold">Total Expenses</td>
|
<td class="ps-4 fw-semibold">Total Expenses</td>
|
||||||
<td class="text-end fw-semibold text-danger">(@Model.TotalExpenses.ToString("C"))</td>
|
<td class="text-end fw-semibold text-danger">(@Model.TotalExpenses.ToString("C"))</td>
|
||||||
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : (Model.TotalExpenses / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
|
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "—" : (Model.TotalExpenses / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="report-net-row @(Model.NetIncome < 0 ? "report-net-negative" : "")">
|
<tr class="report-net-row @(Model.NetIncome < 0 ? "report-net-negative" : "")">
|
||||||
<td class="ps-2">Net Income</td>
|
<td class="ps-2">Net Income</td>
|
||||||
<td class="text-end @(Model.NetIncome >= 0 ? "text-success" : "text-danger")">@Model.NetIncome.ToString("C")</td>
|
<td class="text-end @(Model.NetIncome >= 0 ? "text-success" : "text-danger")">@Model.NetIncome.ToString("C")</td>
|
||||||
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "—" : (Model.NetIncome / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
|
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "—" : (Model.NetIncome / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ else
|
|||||||
<td class="text-end fw-semibold">@c.TotalInvoiced.ToString("C")</td>
|
<td class="text-end fw-semibold">@c.TotalInvoiced.ToString("C")</td>
|
||||||
<td class="text-end text-success">@c.TotalPaid.ToString("C")</td>
|
<td class="text-end text-success">@c.TotalPaid.ToString("C")</td>
|
||||||
<td class="text-end @(c.BalanceDue > 0 ? "text-warning" : "text-muted")">@c.BalanceDue.ToString("C")</td>
|
<td class="text-end @(c.BalanceDue > 0 ? "text-warning" : "text-muted")">@c.BalanceDue.ToString("C")</td>
|
||||||
<td class="text-end text-muted small no-print">@(Model.TotalInvoiced == 0 ? "—" : (c.TotalInvoiced / Model.TotalInvoiced * 100).ToString("F1") + "%")</td>
|
<td class="text-end text-muted small no-print">@Html.Raw(Model.TotalInvoiced == 0 ? "—" : (c.TotalInvoiced / Model.TotalInvoiced * 100).ToString("F1") + "%")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -263,9 +263,9 @@ else
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">@inv.CustomerName</td>
|
<td class="text-muted small">@inv.CustomerName</td>
|
||||||
<td class="text-muted small">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
|
<td class="text-muted small">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
|
||||||
<td class="text-muted small">@(inv.DueDate?.ToString("MM/dd/yyyy") ?? "—")</td>
|
<td class="text-muted small">@Html.Raw(inv.DueDate?.ToString("MM/dd/yyyy") ?? "—")</td>
|
||||||
<td class="text-end">@inv.SubTotal.ToString("C")</td>
|
<td class="text-end">@inv.SubTotal.ToString("C")</td>
|
||||||
<td class="text-end text-muted small">@(inv.TaxAmount > 0 ? inv.TaxAmount.ToString("C") : "—")</td>
|
<td class="text-end text-muted small">@Html.Raw(inv.TaxAmount > 0 ? inv.TaxAmount.ToString("C") : "—")</td>
|
||||||
<td class="text-end fw-semibold">@inv.Total.ToString("C")</td>
|
<td class="text-end fw-semibold">@inv.Total.ToString("C")</td>
|
||||||
<td class="text-end text-success">@inv.AmountPaid.ToString("C")</td>
|
<td class="text-end text-success">@inv.AmountPaid.ToString("C")</td>
|
||||||
<td><span class="badge @statusBadge">@inv.Status</span></td>
|
<td><span class="badge @statusBadge">@inv.Status</span></td>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
@item.BalanceDue.ToString("C")
|
@item.BalanceDue.ToString("C")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">@item.AvgInvoiceValue.ToString("C")</td>
|
<td class="text-end">@item.AvgInvoiceValue.ToString("C")</td>
|
||||||
<td>@(item.LastInvoiceDate?.ToString("MMM d, yyyy") ?? "—")</td>
|
<td>@Html.Raw(item.LastInvoiceDate?.ToString("MMM d, yyyy") ?? "—")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -284,11 +284,11 @@ else
|
|||||||
<td class="small text-muted">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
|
<td class="small text-muted">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
|
||||||
<td><span class="badge @statusBadge">@inv.Status</span></td>
|
<td><span class="badge @statusBadge">@inv.Status</span></td>
|
||||||
<td class="text-end">@inv.SubTotal.ToString("C")</td>
|
<td class="text-end">@inv.SubTotal.ToString("C")</td>
|
||||||
<td class="text-end text-muted small">@(isTaxable ? inv.TaxPercent.ToString("F2") + "%" : "—")</td>
|
<td class="text-end text-muted small">@Html.Raw(isTaxable ? inv.TaxPercent.ToString("F2") + "%" : "—")</td>
|
||||||
<td class="text-end @(isTaxable ? "fw-semibold text-primary" : "text-muted")">@(isTaxable ? inv.TaxAmount.ToString("C") : "—")</td>
|
<td class="text-end @Html.Raw(isTaxable ? "fw-semibold text-primary" : "text-muted")">@Html.Raw(isTaxable ? inv.TaxAmount.ToString("C") : "—")</td>
|
||||||
<td class="text-end fw-semibold">@inv.Total.ToString("C")</td>
|
<td class="text-end fw-semibold">@inv.Total.ToString("C")</td>
|
||||||
<td class="text-end text-success no-print">@inv.AmountPaid.ToString("C")</td>
|
<td class="text-end text-success no-print">@inv.AmountPaid.ToString("C")</td>
|
||||||
<td class="small text-muted">@(string.IsNullOrEmpty(inv.TaxAccountName) ? "—" : inv.TaxAccountName)</td>
|
<td class="small text-muted">@Html.Raw(string.IsNullOrEmpty(inv.TaxAccountName) ? "—" : inv.TaxAccountName)</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ else
|
|||||||
<span class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>Missing</span>
|
<span class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>Missing</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="small">@(row.Address ?? "—")</td>
|
<td class="small">@Html.Raw(row.Address ?? "—")</td>
|
||||||
<td class="text-end">@row.BillsPaid.ToString("C")</td>
|
<td class="text-end">@row.BillsPaid.ToString("C")</td>
|
||||||
<td class="text-end">@row.ExpensesPaid.ToString("C")</td>
|
<td class="text-end">@row.ExpensesPaid.ToString("C")</td>
|
||||||
<td class="text-end fw-bold @(row.NeedsForm ? "text-danger" : "")">@row.TotalPaid.ToString("C")</td>
|
<td class="text-end fw-bold @(row.NeedsForm ? "text-danger" : "")">@row.TotalPaid.ToString("C")</td>
|
||||||
|
|||||||
@@ -210,7 +210,7 @@
|
|||||||
<td class="small fw-semibold">@alert.CompanyName</td>
|
<td class="small fw-semibold">@alert.CompanyName</td>
|
||||||
<td class="small">@alert.PlanName</td>
|
<td class="small">@alert.PlanName</td>
|
||||||
<td><span class="badge bg-@statusClass">@alert.Status</span></td>
|
<td><span class="badge bg-@statusClass">@alert.Status</span></td>
|
||||||
<td class="small">@(alert.EndDate?.ToString("MM/dd/yyyy") ?? "—")</td>
|
<td class="small">@Html.Raw(alert.EndDate?.ToString("MM/dd/yyyy") ?? "—")</td>
|
||||||
<td class="small @(alert.DaysUntilExpiry < 0 ? "text-danger" : "text-warning")">@daysText</td>
|
<td class="small @(alert.DaysUntilExpiry < 0 ? "text-danger" : "text-warning")">@daysText</td>
|
||||||
<td>
|
<td>
|
||||||
<a asp-controller="SubscriptionManagement" asp-action="Manage"
|
<a asp-controller="SubscriptionManagement" asp-action="Manage"
|
||||||
@@ -248,7 +248,7 @@
|
|||||||
<div class="mobile-card-body">
|
<div class="mobile-card-body">
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">End Date</span>
|
<span class="mobile-card-label">End Date</span>
|
||||||
<span class="mobile-card-value">@(alert.EndDate?.ToString("MM/dd/yyyy") ?? "—")</span>
|
<span class="mobile-card-value">@Html.Raw(alert.EndDate?.ToString("MM/dd/yyyy") ?? "—")</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Days</span>
|
<span class="mobile-card-label">Days</span>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<dd class="col-7"><span class="badge bg-light text-dark border">@Model.EventType</span></dd>
|
<dd class="col-7"><span class="badge bg-light text-dark border">@Model.EventType</span></dd>
|
||||||
|
|
||||||
<dt class="col-5 text-muted">Company ID</dt>
|
<dt class="col-5 text-muted">Company ID</dt>
|
||||||
<dd class="col-7">@(Model.CompanyId.HasValue ? Model.CompanyId.ToString() : "—")</dd>
|
<dd class="col-7">@Html.Raw(Model.CompanyId.HasValue ? Model.CompanyId.ToString() : "—")</dd>
|
||||||
|
|
||||||
<dt class="col-5 text-muted">Status</dt>
|
<dt class="col-5 text-muted">Status</dt>
|
||||||
<dd class="col-7"><span class="badge bg-@statusClass">@Model.Status</span></dd>
|
<dd class="col-7"><span class="badge bg-@statusClass">@Model.Status</span></dd>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<dd class="col-7">@Model.ReceivedAt.ToString("MM/dd/yyyy HH:mm:ss") UTC</dd>
|
<dd class="col-7">@Model.ReceivedAt.ToString("MM/dd/yyyy HH:mm:ss") UTC</dd>
|
||||||
|
|
||||||
<dt class="col-5 text-muted">Processed At</dt>
|
<dt class="col-5 text-muted">Processed At</dt>
|
||||||
<dd class="col-7">@(Model.ProcessedAt.HasValue ? Model.ProcessedAt.Value.ToString("MM/dd/yyyy HH:mm:ss") + " UTC" : "—")</dd>
|
<dd class="col-7">@Html.Raw(Model.ProcessedAt.HasValue ? Model.ProcessedAt.Value.ToString("MM/dd/yyyy HH:mm:ss") + " UTC" : "—")</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -145,12 +145,12 @@
|
|||||||
<td class="small">
|
<td class="small">
|
||||||
<span class="badge bg-secondary-subtle text-body border">@evt.EventType</span>
|
<span class="badge bg-secondary-subtle text-body border">@evt.EventType</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="small">@(evt.CompanyId.HasValue ? $"#{evt.CompanyId}" : "—")</td>
|
<td class="small">@Html.Raw(evt.CompanyId.HasValue ? $"#{evt.CompanyId}" : "—")</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-@statusClass">@evt.Status</span>
|
<span class="badge bg-@statusClass">@evt.Status</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="small">
|
<td class="small">
|
||||||
@(evt.ProcessedAt.HasValue ? evt.ProcessedAt.Value.ToString("HH:mm:ss") : "—")
|
@Html.Raw(evt.ProcessedAt.HasValue ? evt.ProcessedAt.Value.ToString("HH:mm:ss") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a asp-action="Details" asp-route-id="@evt.Id" class="btn btn-outline-secondary btn-sm py-0">View</a>
|
<a asp-action="Details" asp-route-id="@evt.Id" class="btn btn-outline-secondary btn-sm py-0">View</a>
|
||||||
@@ -191,7 +191,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Company</span>
|
<span class="mobile-card-label">Company</span>
|
||||||
<span class="mobile-card-value">@(evt.CompanyId.HasValue ? $"#{evt.CompanyId}" : "—")</span>
|
<span class="mobile-card-value">@Html.Raw(evt.CompanyId.HasValue ? $"#{evt.CompanyId}" : "—")</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Event ID</span>
|
<span class="mobile-card-label">Event ID</span>
|
||||||
|
|||||||
@@ -66,9 +66,9 @@
|
|||||||
<dt class="col-7 text-muted">Users</dt>
|
<dt class="col-7 text-muted">Users</dt>
|
||||||
<dd class="col-5 fw-semibold">@ViewBag.UserCount</dd>
|
<dd class="col-5 fw-semibold">@ViewBag.UserCount</dd>
|
||||||
<dt class="col-7 text-muted">Stripe Customer</dt>
|
<dt class="col-7 text-muted">Stripe Customer</dt>
|
||||||
<dd class="col-5"><code class="small">@(Model.StripeCustomerId ?? "—")</code></dd>
|
<dd class="col-5"><code class="small">@Html.Raw(Model.StripeCustomerId ?? "—")</code></dd>
|
||||||
<dt class="col-7 text-muted">Stripe Sub</dt>
|
<dt class="col-7 text-muted">Stripe Sub</dt>
|
||||||
<dd class="col-5"><code class="small">@(Model.StripeSubscriptionId ?? "—")</code></dd>
|
<dd class="col-5"><code class="small">@Html.Raw(Model.StripeSubscriptionId ?? "—")</code></dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="ps-4">Last Migration</th>
|
<th class="ps-4">Last Migration</th>
|
||||||
<td><small class="text-muted font-monospace">@(Model.LastAppliedMigration ?? "—")</small></td>
|
<td><small class="text-muted font-monospace">@Html.Raw(Model.LastAppliedMigration ?? "—")</small></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="ps-4">Last Seed Run</th>
|
<th class="ps-4">Last Seed Run</th>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@
|
|||||||
<td class="text-nowrap small text-muted">@row.Timestamp.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd HH:mm:ss")</td>
|
<td class="text-nowrap small text-muted">@row.Timestamp.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd HH:mm:ss")</td>
|
||||||
<td><span class="badge @LevelBadge(row.Level)">@row.Level</span></td>
|
<td><span class="badge @LevelBadge(row.Level)">@row.Level</span></td>
|
||||||
<td class="small text-truncate" style="max-width:180px" title="@row.SourceContext">
|
<td class="small text-truncate" style="max-width:180px" title="@row.SourceContext">
|
||||||
@(row.SourceContext?.Split('.').LastOrDefault() ?? "—")
|
@Html.Raw(row.SourceContext?.Split('.').LastOrDefault() ?? "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="small text-truncate" style="max-width:400px">
|
<td class="small text-truncate" style="max-width:400px">
|
||||||
@row.Message
|
@row.Message
|
||||||
@@ -162,8 +162,8 @@
|
|||||||
<i class="bi bi-bug text-danger ms-1" title="Has exception"></i>
|
<i class="bi bi-bug text-danger ms-1" title="Has exception"></i>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="small text-muted">@(row.UserName ?? "—")</td>
|
<td class="small text-muted">@Html.Raw(row.UserName ?? "—")</td>
|
||||||
<td class="small text-muted text-center">@(row.CompanyId?.ToString() ?? "—")</td>
|
<td class="small text-muted text-center">@Html.Raw(row.CompanyId?.ToString() ?? "—")</td>
|
||||||
<td class="text-center"><i class="bi bi-zoom-in text-muted small"></i></td>
|
<td class="text-center"><i class="bi bi-zoom-in text-muted small"></i></td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
<div class="mobile-card-header">
|
<div class="mobile-card-header">
|
||||||
<div class="mobile-card-icon @cardIconBg"><i class="bi @cardIcon"></i></div>
|
<div class="mobile-card-icon @cardIconBg"><i class="bi @cardIcon"></i></div>
|
||||||
<div class="mobile-card-title">
|
<div class="mobile-card-title">
|
||||||
<h6>@msgTruncated</h6>
|
<h6>@Html.Raw(msgTruncated)</h6>
|
||||||
<small class="text-muted">@row.Timestamp.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd HH:mm:ss")</small>
|
<small class="text-muted">@row.Timestamp.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd HH:mm:ss")</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,7 +208,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Source</span>
|
<span class="mobile-card-label">Source</span>
|
||||||
<span class="mobile-card-value small">@(row.SourceContext?.Split('.').LastOrDefault() ?? "—")</span>
|
<span class="mobile-card-value small">@Html.Raw(row.SourceContext?.Split('.').LastOrDefault() ?? "—")</span>
|
||||||
</div>
|
</div>
|
||||||
@if (row.UserName != null)
|
@if (row.UserName != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -67,8 +67,8 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>@rate.Rate.ToString("0.##")%</td>
|
<td>@rate.Rate.ToString("0.##")%</td>
|
||||||
<td>@(rate.State ?? "—")</td>
|
<td>@Html.Raw(rate.State ?? "—")</td>
|
||||||
<td class="text-muted small">@(rate.Description ?? "—")</td>
|
<td class="text-muted small">@Html.Raw(rate.Description ?? "—")</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@if (rate.IsDefault)
|
@if (rate.IsDefault)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -220,23 +220,23 @@
|
|||||||
<div class="mobile-card-body">
|
<div class="mobile-card-body">
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Users</span>
|
<span class="mobile-card-label">Users</span>
|
||||||
<span class="mobile-card-value">@row.Users / @LimitDisplay(row.MaxUsers)</span>
|
<span class="mobile-card-value">@row.Users / @Html.Raw(LimitDisplay(row.MaxUsers))</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Active Jobs</span>
|
<span class="mobile-card-label">Active Jobs</span>
|
||||||
<span class="mobile-card-value">@row.ActiveJobs / @LimitDisplay(row.MaxActiveJobs)</span>
|
<span class="mobile-card-value">@row.ActiveJobs / @Html.Raw(LimitDisplay(row.MaxActiveJobs))</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Customers</span>
|
<span class="mobile-card-label">Customers</span>
|
||||||
<span class="mobile-card-value">@row.Customers / @LimitDisplay(row.MaxCustomers)</span>
|
<span class="mobile-card-value">@row.Customers / @Html.Raw(LimitDisplay(row.MaxCustomers))</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Active Quotes</span>
|
<span class="mobile-card-label">Active Quotes</span>
|
||||||
<span class="mobile-card-value">@row.ActiveQuotes / @LimitDisplay(row.MaxActiveQuotes)</span>
|
<span class="mobile-card-value">@row.ActiveQuotes / @Html.Raw(LimitDisplay(row.MaxActiveQuotes))</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Catalog Items</span>
|
<span class="mobile-card-label">Catalog Items</span>
|
||||||
<span class="mobile-card-value">@row.CatalogItems / @LimitDisplay(row.MaxCatalogItems)</span>
|
<span class="mobile-card-value">@row.CatalogItems / @Html.Raw(LimitDisplay(row.MaxCatalogItems))</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-footer">
|
<div class="mobile-card-footer">
|
||||||
|
|||||||
@@ -123,13 +123,13 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="path-badge text-muted" title="@u.CurrentPath">@(u.CurrentPath ?? "—")</span>
|
<span class="path-badge text-muted" title="@u.CurrentPath">@Html.Raw(u.CurrentPath ?? "—")</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="mb-1 small text-muted">@secsAgo s ago</div>
|
<div class="mb-1 small text-muted">@secsAgo s ago</div>
|
||||||
<div class="last-seen-bar" style="width:@barPct%"></div>
|
<div class="last-seen-bar" style="width:@barPct%"></div>
|
||||||
</td>
|
</td>
|
||||||
<td class="small text-muted">@(u.IpAddress ?? "—")</td>
|
<td class="small text-muted">@Html.Raw(u.IpAddress ?? "—")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
<div class="mobile-card-body">
|
<div class="mobile-card-body">
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Company</span>
|
<span class="mobile-card-label">Company</span>
|
||||||
<span class="mobile-card-value">@(u.CompanyName ?? "—")</span>
|
<span class="mobile-card-value">@Html.Raw(u.CompanyName ?? "—")</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Role</span>
|
<span class="mobile-card-label">Role</span>
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Page</span>
|
<span class="mobile-card-label">Page</span>
|
||||||
<span class="mobile-card-value small text-muted" style="font-family:monospace">@(u.CurrentPath ?? "—")</span>
|
<span class="mobile-card-value small text-muted" style="font-family:monospace">@Html.Raw(u.CurrentPath ?? "—")</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Last Seen</span>
|
<span class="mobile-card-label">Last Seen</span>
|
||||||
|
|||||||
@@ -205,7 +205,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<a asp-controller="Bills" asp-action="Details" asp-route-id="@bill.Id">@bill.BillNumber</a>
|
<a asp-controller="Bills" asp-action="Details" asp-route-id="@bill.Id">@bill.BillNumber</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">@(bill.DueDate?.ToString("MMM d, yyyy") ?? "—")</td>
|
<td class="text-muted small">@Html.Raw(bill.DueDate?.ToString("MMM d, yyyy") ?? "—")</td>
|
||||||
<td class="text-end">@bill.BalanceDue.ToString("C")</td>
|
<td class="text-end">@bill.BalanceDue.ToString("C")</td>
|
||||||
<td class="text-end" style="width:150px">
|
<td class="text-end" style="width:150px">
|
||||||
<form asp-action="Apply" method="post" class="d-inline">
|
<form asp-action="Apply" method="post" class="d-inline">
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
<div class="mobile-card-row">
|
<div class="mobile-card-row">
|
||||||
<span class="mobile-card-label">Remaining</span>
|
<span class="mobile-card-label">Remaining</span>
|
||||||
<span class="mobile-card-value @(vc.RemainingAmount > 0 ? "text-success fw-semibold" : "text-muted")">
|
<span class="mobile-card-value @(vc.RemainingAmount > 0 ? "text-success fw-semibold" : "text-muted")">
|
||||||
@(vc.RemainingAmount > 0 ? vc.RemainingAmount.ToString("C") : "—")
|
@Html.Raw(vc.RemainingAmount > 0 ? vc.RemainingAmount.ToString("C") : "—")
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if (!string.IsNullOrWhiteSpace(vc.Memo))
|
@if (!string.IsNullOrWhiteSpace(vc.Memo))
|
||||||
|
|||||||
@@ -195,6 +195,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Supply Categories -->
|
||||||
|
@if (ViewBag.VendorCategories is IEnumerable<PowderCoating.Core.Entities.InventoryCategoryLookup> cats && cats.Any())
|
||||||
|
{
|
||||||
|
<div class="mb-4">
|
||||||
|
<h5 class="border-bottom pb-2 mb-3">
|
||||||
|
<i class="bi bi-tags me-2 text-primary"></i>Supply Categories
|
||||||
|
</h5>
|
||||||
|
<div class="d-flex flex-wrap gap-3">
|
||||||
|
@foreach (var cat in cats)
|
||||||
|
{
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
name="CategoryIds" value="@cat.Id" id="cat_@cat.Id" />
|
||||||
|
<label class="form-check-label" for="cat_@cat.Id">@cat.DisplayName</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Tag this vendor with the types of supplies they provide. Used to filter the vendor list when adding inventory items.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Notes Section -->
|
<!-- Notes Section -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h5 class="border-bottom pb-2 mb-3">
|
<h5 class="border-bottom pb-2 mb-3">
|
||||||
|
|||||||
@@ -198,6 +198,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Supply Categories -->
|
||||||
|
@if (ViewBag.VendorCategories is IEnumerable<PowderCoating.Core.Entities.InventoryCategoryLookup> cats && cats.Any())
|
||||||
|
{
|
||||||
|
var selectedCatIds = (HashSet<int>)ViewBag.SelectedCategoryIds;
|
||||||
|
<div class="mb-4">
|
||||||
|
<h5 class="border-bottom pb-2 mb-3">
|
||||||
|
<i class="bi bi-tags me-2 text-primary"></i>Supply Categories
|
||||||
|
</h5>
|
||||||
|
<div class="d-flex flex-wrap gap-3">
|
||||||
|
@foreach (var cat in cats)
|
||||||
|
{
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
name="CategoryIds" value="@cat.Id" id="cat_@cat.Id"
|
||||||
|
@(selectedCatIds.Contains(cat.Id) ? "checked" : "") />
|
||||||
|
<label class="form-check-label" for="cat_@cat.Id">@cat.DisplayName</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Tag this vendor with the types of supplies they provide. Used to filter the vendor list when adding inventory items.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Notes Section -->
|
<!-- Notes Section -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h5 class="border-bottom pb-2 mb-3">
|
<h5 class="border-bottom pb-2 mb-3">
|
||||||
|
|||||||
@@ -70,9 +70,9 @@
|
|||||||
<td>
|
<td>
|
||||||
<strong>@vendor.CompanyName</strong>
|
<strong>@vendor.CompanyName</strong>
|
||||||
</td>
|
</td>
|
||||||
<td>@(vendor.ContactName ?? "—")</td>
|
<td>@Html.Raw(vendor.ContactName ?? "—")</td>
|
||||||
<td>@(vendor.Phone ?? "—")</td>
|
<td>@Html.Raw(vendor.Phone ?? "—")</td>
|
||||||
<td>@(vendor.Email ?? "—")</td>
|
<td>@Html.Raw(vendor.Email ?? "—")</td>
|
||||||
<td>
|
<td>
|
||||||
@if (vendor.InventoryItemCount > 0)
|
@if (vendor.InventoryItemCount > 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -413,10 +413,15 @@
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ensureAbsoluteUrl(url) {
|
||||||
|
if (!url) return url;
|
||||||
|
return /^https?:\/\//i.test(url) ? url : 'http://' + url;
|
||||||
|
}
|
||||||
|
|
||||||
function syncLinkButton(inputId, linkId, url) {
|
function syncLinkButton(inputId, linkId, url) {
|
||||||
const link = document.getElementById(linkId);
|
const link = document.getElementById(linkId);
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
if (url) { link.href = url; link.classList.remove('d-none'); }
|
if (url) { link.href = ensureAbsoluteUrl(url); link.classList.remove('d-none'); }
|
||||||
else { link.classList.add('d-none'); }
|
else { link.classList.add('d-none'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -550,10 +550,15 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureAbsoluteUrl(url) {
|
||||||
|
if (!url) return url;
|
||||||
|
return /^https?:\/\//i.test(url) ? url : 'http://' + url;
|
||||||
|
}
|
||||||
|
|
||||||
function syncLink(inputId, linkId, url) {
|
function syncLink(inputId, linkId, url) {
|
||||||
const link = document.getElementById(linkId);
|
const link = document.getElementById(linkId);
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
if (url) { link.href = url; link.classList.remove('d-none'); }
|
if (url) { link.href = ensureAbsoluteUrl(url); link.classList.remove('d-none'); }
|
||||||
else { link.classList.add('d-none'); }
|
else { link.classList.add('d-none'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,13 +150,18 @@ public class QuoteAndReworkControllerFlowTests
|
|||||||
DefectDescription = "Thin coverage on one edge",
|
DefectDescription = "Thin coverage on one edge",
|
||||||
DiscoveredBy = ReworkDiscoveredBy.Internal,
|
DiscoveredBy = ReworkDiscoveredBy.Internal,
|
||||||
DiscoveredDate = new DateTime(2026, 5, 9),
|
DiscoveredDate = new DateTime(2026, 5, 9),
|
||||||
EstimatedReworkCost = 65m
|
EstimatedReworkCost = 65m,
|
||||||
|
CreateReworkJob = true,
|
||||||
|
ReworkJobItemIds = [10],
|
||||||
|
ReworkPricingType = ReworkPricingType.CustomerFull
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.IsType<JsonResult>(result);
|
Assert.IsType<JsonResult>(result);
|
||||||
|
|
||||||
var reworkJob = await context.Jobs.SingleAsync(j => j.IsReworkJob);
|
var reworkJob = await context.Jobs.SingleAsync(j => j.IsReworkJob);
|
||||||
Assert.Equal(1, reworkJob.OriginalJobId);
|
Assert.Equal(1, reworkJob.OriginalJobId);
|
||||||
|
Assert.Equal("JOB-2605-0001-R1", reworkJob.JobNumber);
|
||||||
|
Assert.Equal(2, reworkJob.JobStatusId); // first non-Pending status
|
||||||
|
|
||||||
var reworkItem = await context.JobItems.SingleAsync(i => i.JobId == reworkJob.Id);
|
var reworkItem = await context.JobItems.SingleAsync(i => i.JobId == reworkJob.Id);
|
||||||
Assert.True(reworkItem.IsSalesItem);
|
Assert.True(reworkItem.IsSalesItem);
|
||||||
@@ -284,13 +289,9 @@ public class QuoteAndReworkControllerFlowTests
|
|||||||
CompanyName = "Acme Fabrication"
|
CompanyName = "Acme Fabrication"
|
||||||
});
|
});
|
||||||
|
|
||||||
context.JobStatusLookups.Add(new JobStatusLookup
|
context.JobStatusLookups.AddRange(
|
||||||
{
|
new JobStatusLookup { Id = 1, CompanyId = 1, StatusCode = "PENDING", DisplayName = "Pending", DisplayOrder = 1 },
|
||||||
Id = 1,
|
new JobStatusLookup { Id = 2, CompanyId = 1, StatusCode = "IN_PREPARATION", DisplayName = "In Preparation", DisplayOrder = 2 });
|
||||||
CompanyId = 1,
|
|
||||||
StatusCode = "PENDING",
|
|
||||||
DisplayName = "Pending"
|
|
||||||
});
|
|
||||||
|
|
||||||
context.JobPriorityLookups.Add(new JobPriorityLookup
|
context.JobPriorityLookups.Add(new JobPriorityLookup
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user