Merge dev into master
- Inline item editing on Job/Quote/Invoice Details pages - Live pricing summary and Job Costing card updates on save - PatchItem legacy fallback for jobs without PricingBreakdownJson - GetCostingBreakdown revenue from FinalPrice (not invoice total) - Help docs: Inline Price Editing sections added to all three detail pages - AI knowledge base updated with inline editing and costing revenue behavior - AGENTS.md tracked; .gitignore updated for Claude Code settings and build logs - Resolve conflict in Payment/Index.cshtml (em dash entity style) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,180 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dotnet build:*)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(dotnet restore:*)",
|
||||
"Bash(dotnet clean:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(dotnet ef migrations add:*)",
|
||||
"Bash(dotnet ef migrations remove:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(dotnet ef database update:*)",
|
||||
"Bash(sqlcmd:*)",
|
||||
"Bash(dotnet ef migrations script:*)",
|
||||
"Bash(dotnet run:*)",
|
||||
"Bash(timeout /t 15 dotnet run:*)",
|
||||
"Bash(timeout /t 10 /nobreak)",
|
||||
"Bash(ping:*)",
|
||||
"Bash(start /B dotnet run:*)",
|
||||
"Bash(test:*)",
|
||||
"Bash(dotnet ef migrations:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(xargs -I {} bash -c 'echo \"\"=== {} ===\"\" && head -20 {} | grep -E \"\"class|Authorize\"\"')",
|
||||
"Bash(powershell:*)",
|
||||
"Bash(dotnet tool install:*)",
|
||||
"Bash(dotnet tool update:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(powershell -Command \"cd src\\\\PowderCoating.Web; dotnet ef migrations add UpdateQuoteForProspects --project ..\\\\PowderCoating.Infrastructure\")",
|
||||
"Bash(powershell -Command:*)",
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(netstat:*)",
|
||||
"Bash(libman restore:*)",
|
||||
"Bash(./start-app.bat)",
|
||||
"Bash(dotnet-ef migrations add:*)",
|
||||
"Bash(dotnet-ef database update:*)",
|
||||
"Bash(./stop-app.bat)",
|
||||
"Bash(timeout /t 3 /nobreak)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(if [ -f \"stop-app.bat\" ])",
|
||||
"Bash(then cmd.exe /c stop-app.bat)",
|
||||
"Bash(else echo \"stop-app.bat not found\")",
|
||||
"Bash(fi)",
|
||||
"Bash(powershell.exe -Command \"Unblock-File -Path 'src/PowderCoating.Web/dotnet-tools.json'\":*)",
|
||||
"Bash(powershell.exe -Command \"Get-Process | Where-Object {$_ProcessName -like ''*PowderCoating*''} | Stop-Process -Force\")",
|
||||
"Bash(powershell.exe:*)",
|
||||
"Bash(Select-String -Pattern \"error|Error\")",
|
||||
"Bash(Select-String -NotMatch \"warning\")",
|
||||
"Bash(tasklist:*)",
|
||||
"Bash(dotnet add package:*)",
|
||||
"Bash(start-process dotnet run:*)",
|
||||
"Bash(Select-Object -ExpandProperty Id)",
|
||||
"Bash(find:*)",
|
||||
"Bash(cmd.exe:*)",
|
||||
"Bash(dotnet ef dbcontext:*)",
|
||||
"Bash(handle \"PowderCoating.Web.pdb\")",
|
||||
"Bash(timeout:*)",
|
||||
"Bash(del /F \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\obj\\\\Debug\\\\net8.0\\\\PowderCoating.Web.pdb\")",
|
||||
"Bash(Select-String -Pattern \"Build succeeded|Build FAILED|error\")",
|
||||
"Bash(Select-Object -Last 10)",
|
||||
"Bash(del \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Infrastructure\\\\Migrations\\\\20260211031319_RemovePreexistingCatalogData.cs\" \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Infrastructure\\\\Migrations\\\\20260211031319_RemovePreexistingCatalogData.Designer.cs\")",
|
||||
"Bash(Select-String:*)",
|
||||
"Bash(Select-Object -Last 5)",
|
||||
"Bash(start-app.bat)",
|
||||
"Bash(dotnet script:*)",
|
||||
"Bash(dotnet list:*)",
|
||||
"Bash(dotnet new:*)",
|
||||
"Bash(stop-app.bat)",
|
||||
"Bash(dotnet watch run:*)",
|
||||
"Bash(cmd /c \"taskkill /F /PID 42108\")",
|
||||
"Bash(cmd /c start-app.bat)",
|
||||
"Bash(\"Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Services/PdfService.cs\":*)",
|
||||
"Bash(/y/PCC/PowderCoatingApp/src/PowderCoating.Application/Services/PdfService.cs:*)",
|
||||
"Bash(/tmp/remove_tempdata.pl:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(perl:*)",
|
||||
"Bash(done)",
|
||||
"Bash(cmd:*)",
|
||||
"Bash(tail:*)",
|
||||
"Bash(del:*)",
|
||||
"Bash(dotnet add:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(Stop-Process:*)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(dotnet tool:*)",
|
||||
"Bash(where libman:*)",
|
||||
"Bash(find \"Y:/PCC/PowderCoatingApp\" -type f \\\\\\( -name \"*template*\" -o -name \"*import*\" -o -name \"*export*\" \\\\\\) -iname \"*.csv\" -o -iname \"*.xlsx\" -o -iname \"*.xls\" 2>/dev/null | head -50)",
|
||||
"Bash(grep -n \"powderCostOverride\\\\|PowderCostOverride\\\\|pageMeta\\\\|quoteItems\\\\|existingItems\" \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Quotes/Create.cshtml\" | head -20\ngrep -n \"powderCostOverride\\\\|PowderCostOverride\\\\|pageMeta\\\\|quoteItems\\\\|existingItems\" \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Quotes/Edit.cshtml\" 2>/dev/null | head -20)",
|
||||
"Bash(cat /tmp/sdktest/Program.cs | xxd | head -20)",
|
||||
"Bash(cd /tmp/sdktest && rm -rf bin obj && cat Program.cs)",
|
||||
"Bash(cat /tmp/sdktest/Program.cs | xxd | head -5)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:www.nuget.org)",
|
||||
"Bash(wmic process:*)",
|
||||
"Bash(grep -rn \"AI Photo\\\\|ai.*photo\\\\|photo.*quote\\\\|item-type\\\\|AiPhotoQuotes\\\\|ai_photo\" \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Quotes\\\\\" | grep -i \"photo\\\\|ai\" | head -20)",
|
||||
"Bash(sed -i 's|\"aiAnalyzeUrl\": \"@Url.Action\\(\\\\\"AiAnalyzeItem\\\\\", \\\\\"Quotes\\\\\"\\)\",|\"aiAnalyzeUrl\": \"@Url.Action\\(\\\\\"AiAnalyzeItem\\\\\", \\\\\"Quotes\\\\\"\\)\",\\\\n \"aiPhotoQuotesEnabled\": @Json.Serialize\\(\\(bool\\)\\(ViewBag.AiPhotoQuotesEnabled ?? true\\)\\),|g' \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Quotes\\\\Edit.cshtml\" \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Jobs\\\\Create.cshtml\" \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Jobs\\\\Edit.cshtml\")",
|
||||
"Bash(cp:*)",
|
||||
"Bash(dotnet fsi -e \":*)",
|
||||
"Read(//y/tmp/**)",
|
||||
"Bash(cp /c/Users/spoul/.nuget/packages/stripe.net/50.4.1/stripe.net.50.4.1.nupkg stripe.zip)",
|
||||
"Bash(unzip -o stripe.zip *.cs -d stripe_src)",
|
||||
"Bash(dotnet ef:*)",
|
||||
"Bash(Payment)",
|
||||
"Bash(Deposit \")",
|
||||
"Bash(node:*)",
|
||||
"WebFetch(domain:quickbooks.intuit.com)",
|
||||
"WebFetch(domain:www.saasant.com)",
|
||||
"WebFetch(domain:www.liveflow.com)",
|
||||
"WebFetch(domain:www.gentlefrog.com)",
|
||||
"WebFetch(domain:blog.coupler.io)",
|
||||
"WebFetch(domain:litextension.com)",
|
||||
"WebFetch(domain:www.dancingnumbers.com)",
|
||||
"WebFetch(domain:www.bizbooks.pro)",
|
||||
"WebFetch(domain:support.saasant.com)",
|
||||
"WebFetch(domain:support.getcount.com)",
|
||||
"WebFetch(domain:planergy.com)",
|
||||
"WebFetch(domain:www.wizxpert.com)",
|
||||
"WebFetch(domain:www.trykeep.com)",
|
||||
"WebFetch(domain:gentlefrog.com)",
|
||||
"WebFetch(domain:www.syscloud.com)",
|
||||
"WebFetch(domain:interopay.zendesk.com)",
|
||||
"WebFetch(domain:docs.d-tools.cloud)",
|
||||
"WebFetch(domain:paygration.com)",
|
||||
"Bash([ ! -d \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/$controller\" ])",
|
||||
"Bash(bash /tmp/check_actions.sh)",
|
||||
"Bash(bash /tmp/verify_endpoints.sh)",
|
||||
"Bash(bash /tmp/verify_services.sh)",
|
||||
"Read(//y/PCC/Deployments/**)",
|
||||
"Bash(mkdir -p \"Y:/PCC/Deployments\")",
|
||||
"Bash(dotnet-script -e \"using System.Reflection; var a = Assembly.LoadFrom\\(\\\\\"Anthropic.SDK.dll\\\\\"\\); var types = a.GetTypes\\(\\).Where\\(t => t.Name.Contains\\(\\\\\"Document\\\\\"\\) || t.Name.Contains\\(\\\\\"Content\\\\\"\\)\\).Select\\(t => t.Name\\).OrderBy\\(n => n\\); foreach\\(var t in types\\) Console.WriteLine\\(t\\);\")",
|
||||
"Bash(sort -t'-' -k3 -r)",
|
||||
"Bash(wsl grep:*)",
|
||||
"Bash(find src:*)",
|
||||
"Bash(dotnet csharp *)",
|
||||
"Read(//c/Users/spoul/.nuget/packages/stripe.net/50.4.1/lib/netstandard2.0/**)",
|
||||
"Bash(dotnet publish *)",
|
||||
"Bash(Compress-Archive -Path * -DestinationPath \"..\\\\deploy.zip\" -Force)",
|
||||
"Bash(az webapp *)",
|
||||
"Read(//y/PCC/**)",
|
||||
"Bash(Get-Date -Format 'yyyyMMdd_HHmmss')",
|
||||
"PowerShell(Get-Content *)",
|
||||
"PowerShell(dotnet build *)",
|
||||
"PowerShell(New-Item *)",
|
||||
"PowerShell(& \"Y:\\\\PCC\\\\PowderCoatingApp\\\\scripts\\\\generate-migration-script.ps1\")",
|
||||
"PowerShell(if \\(Test-Path \"Y:\\\\pcc\\\\deployment\\\\migrations.sql\"\\) { $f = Get-Item \"Y:\\\\pcc\\\\deployment\\\\migrations.sql\"; Write-Host \"File exists: $\\($f.Length\\) bytes\" } else { Write-Host \"File not created\" })",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit -m ' *)",
|
||||
"Bash(git push *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(dotnet package *)",
|
||||
"Bash(dotnet test *)",
|
||||
"Bash(git rm *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(dotnet ef *)",
|
||||
"Bash(sqlcmd -S \".\\\\SQLEXPRESS\" -d PowderCoatingDb -Q \"SELECT Id, DisplayName, IsCoating, IsActive FROM InventoryCategoryLookups ORDER BY DisplayOrder\" -W)",
|
||||
"Skill(schedule)",
|
||||
"Bash(git -C \"//192.168.0.37/SCPSoftware/tmp/PowderCoatingApp-dev-perf\" log --oneline -10)",
|
||||
"Bash(git -C \"//192.168.0.37/SCPSoftware/tmp/PowderCoatingApp-dev-perf\" status --short)",
|
||||
"Bash(git *)",
|
||||
"Bash(get-childitem -Recurse -Filter \"QuotesController.cs\")",
|
||||
"Bash(Select-Object -ExpandProperty FullName)",
|
||||
"Bash(dotnet user-secrets *)",
|
||||
"Bash(Get-ChildItem -Path \"Y:\\\\PCC\\\\PowderCoatingApp\" -Directory)",
|
||||
"Bash(Select-Object Name)",
|
||||
"Bash(Get-Content *)",
|
||||
"Bash(python -c \"import json; data=json.load\\(open\\('prismatic_powders.json','r',encoding='utf-8'\\)\\); print\\(f'Total records: {len\\(data\\)}'\\); print\\('First record:'\\); print\\(json.dumps\\(data[0], indent=2\\)\\)\")",
|
||||
"Bash(python -c \"import json; data=json.load\\(open\\('prismatic_powders.json','r',encoding='utf-8'\\)\\); keys=list\\(data.keys\\(\\)\\); print\\('Top-level keys:', keys[:10]\\); first=data[keys[0]]; print\\('First record key:', keys[0]\\); print\\(json.dumps\\(first, indent=2\\)\\)\")",
|
||||
"PowerShell(Get-ChildItem *)",
|
||||
"PowerShell(Select-String *)",
|
||||
"Bash(Select-Object -First 20)",
|
||||
"PowerShell(node -e \"require\\('fs'\\).existsSync\\(require\\('path'\\).join\\(process.cwd\\(\\), 'node_modules', 'sharp'\\)\\) ? console.log\\('sharp ok'\\) : console.log\\('no sharp'\\)\")",
|
||||
"WebFetch(domain:www.powdercoatinglogix.com)",
|
||||
"PowerShell($bytes = [System.IO.File]::ReadAllBytes\\('src/PowderCoating.Web/Views/Jobs/Details.cshtml'\\); $text = [System.Text.Encoding]::UTF8.GetString\\($bytes\\); $idx = $text.IndexOf\\('hasPowderData'\\); $snippet = $text.Substring\\($idx - 20, 250\\); [System.Text.Encoding]::Unicode.GetBytes\\($snippet\\) | Format-Hex | Select-Object -First 30)",
|
||||
"PowerShell($dll = \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\questpdf\\\\2024.12.3\\\\lib\\\\net6.0\\\\QuestPDF.dll\"; $asm = [Reflection.Assembly]::LoadFile\\($dll\\); $asm.GetTypes\\(\\) | Where-Object { $_.Name -eq \"ContainerExtensions\" } | ForEach-Object { $_.GetMethods\\(\\) | Where-Object { $_.Name -match \"Canvas|Rotat|Layer\" } | Select-Object Name } | Sort-Object Name -Unique)",
|
||||
"PowerShell(Get-ChildItem \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\\" -ErrorAction SilentlyContinue | Where-Object { $_.Name -match \"quest|skia\" } | Select-Object Name)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Pre-commit hook: block commits containing corrupted Unicode in .cshtml files.
|
||||
#
|
||||
# All corruption variants start with the UTF-8 byte sequence for a-circumflex
|
||||
# followed by euro-sign (bytes C3 A2 E2 82 AC), which is the first two chars
|
||||
# of every known corruption pattern. Grep for that byte sequence in staged files.
|
||||
|
||||
STAGED=$(git diff --cached --name-only | grep '\.cshtml$')
|
||||
if [ -z "$STAGED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# $'\xc3\xa2\xe2\x82\xac' = UTF-8 bytes for a-circumflex + euro-sign
|
||||
CORRUPT=$(echo "$STAGED" | xargs grep -l $'\xc3\xa2\xe2\x82\xac' 2>/dev/null)
|
||||
|
||||
if [ -n "$CORRUPT" ]; then
|
||||
echo ""
|
||||
echo "ERROR: Corrupted Unicode characters detected in staged .cshtml files:"
|
||||
echo "$CORRUPT" | sed 's/^/ /'
|
||||
echo ""
|
||||
echo "Fix by running: .\\tools\\Fix-Encoding.ps1"
|
||||
echo "Then re-stage the files and commit again."
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,6 +1,11 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
|
||||
# Claude Code tool settings and build logs
|
||||
.claude/settings.local.json
|
||||
.claude/settings.json
|
||||
BuildLog*.txt
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
A production-ready ASP.NET Core 8.0 MVC application for managing powder coating business operations. The application implements Clean Architecture with six projects across three layers (Domain, Application, Infrastructure) plus two presentation layers (Web MVC, RESTful API).
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Building and Running
|
||||
|
||||
```bash
|
||||
# Build entire solution
|
||||
dotnet build
|
||||
|
||||
# Run web application (MVC)
|
||||
cd src/PowderCoating.Web
|
||||
dotnet run
|
||||
# Access at: https://localhost:58461
|
||||
|
||||
# Run web with auto-reload
|
||||
dotnet watch run
|
||||
|
||||
# Run API
|
||||
cd src/PowderCoating.Api
|
||||
dotnet run
|
||||
# Swagger UI at root URL
|
||||
|
||||
# Run tests
|
||||
dotnet test # All tests
|
||||
dotnet test tests/PowderCoating.UnitTests # Unit tests only
|
||||
dotnet test tests/PowderCoating.IntegrationTests # Integration tests only
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
|
||||
```bash
|
||||
# All EF commands run from Web project directory
|
||||
cd src/PowderCoating.Web
|
||||
|
||||
# Create migration (must specify Infrastructure project)
|
||||
dotnet ef migrations add MigrationName --project ../PowderCoating.Infrastructure
|
||||
|
||||
# Apply migrations
|
||||
dotnet ef database update --project ../PowderCoating.Infrastructure
|
||||
|
||||
# Reset database (WARNING: deletes all data)
|
||||
dotnet ef database drop --project ../PowderCoating.Infrastructure
|
||||
dotnet ef database update --project ../PowderCoating.Infrastructure
|
||||
|
||||
# List migrations
|
||||
dotnet ef migrations list --project ../PowderCoating.Infrastructure
|
||||
|
||||
# Remove last migration (if not applied)
|
||||
dotnet ef migrations remove --project ../PowderCoating.Infrastructure
|
||||
```
|
||||
|
||||
### Default Credentials
|
||||
|
||||
```
|
||||
SuperAdmin (break glass): artemis@powdercoatinglogix.com / SuperAdmin123!
|
||||
SuperAdmin (seed): superadmin@powdercoatinglogix.com / SuperAdmin123!
|
||||
SuperAdmin (seed): spouliot@powdercoatinglogix.com / SuperAdmin123!
|
||||
Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123!
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Clean Architecture Layers
|
||||
|
||||
**Domain Layer (PowderCoating.Core)**
|
||||
- Contains business entities, enums, and repository interfaces
|
||||
- `BaseEntity` provides common properties for all entities (Id, CompanyId, CreatedAt, UpdatedAt, IsDeleted, audit fields)
|
||||
- All entities inherit from BaseEntity and support soft delete
|
||||
- No dependencies on other projects
|
||||
|
||||
**Application Layer (PowderCoating.Application)**
|
||||
- DTOs organized by domain (Customer, Job, Equipment, Inventory, Maintenance)
|
||||
- AutoMapper profiles with reverse mappings
|
||||
- Service interfaces (IFileService, etc.)
|
||||
- No UI or infrastructure dependencies
|
||||
|
||||
**Infrastructure Layer (PowderCoating.Infrastructure)**
|
||||
- `ApplicationDbContext` with global query filters for soft deletes and multi-tenancy
|
||||
- Generic `Repository<T>` implementing `IRepository<T>`
|
||||
- `UnitOfWork` implementing `IUnitOfWork` with lazy-loaded repositories
|
||||
- Seed data is triggered **manually** via Platform Management → Seed Data (not automatic on startup)
|
||||
|
||||
**Presentation Layers**
|
||||
- `PowderCoating.Web`: MVC application with Razor views, Bootstrap 5 UI
|
||||
- `PowderCoating.Api`: RESTful API with JWT authentication, Swagger documentation
|
||||
|
||||
### Key Design Patterns
|
||||
|
||||
**Repository Pattern**
|
||||
- Generic `Repository<T>` in Infrastructure
|
||||
- All CRUD operations, search, pagination, eager loading support
|
||||
- Soft delete with `SoftDeleteAsync()` method
|
||||
|
||||
**Unit of Work Pattern**
|
||||
- Coordinates multiple repositories
|
||||
- Transaction support: `BeginTransactionAsync()`, `CommitTransactionAsync()`, `RollbackTransactionAsync()`
|
||||
- Lazy instantiation of repositories
|
||||
- `SaveChangesAsync()` or `CompleteAsync()` to persist changes
|
||||
|
||||
**Dependency Injection**
|
||||
- All dependencies registered in `Program.cs`
|
||||
- Controllers inject `IUnitOfWork` and `IMapper`
|
||||
- Services are scoped to request lifetime
|
||||
|
||||
**Global Query Filters**
|
||||
- Soft deletes: All queries automatically filter `IsDeleted == false`
|
||||
- Multi-tenancy: Non-SuperAdmin users see only their company data
|
||||
- Bypass with `ignoreQueryFilters: true` parameter in repository methods
|
||||
|
||||
### Multi-Tenancy Implementation
|
||||
|
||||
- `CompanyId` foreign key on all business entities
|
||||
- `ITenantContext` injected into DbContext resolves current company
|
||||
- SuperAdmin role can view all companies
|
||||
- Global query filters enforce company isolation at database level
|
||||
- Users have both system role (SuperAdmin) and company role (CompanyAdmin, Manager, Worker, Viewer)
|
||||
|
||||
## Data Access Rules (ENFORCE THESE)
|
||||
|
||||
> **`ApplicationDbContext` is NEVER injected into a controller.**
|
||||
> All data access in controllers goes through `IUnitOfWork`. No exceptions outside the list below.
|
||||
> **This rule is enforced at startup:** `EnforceDataAccessArchitecture()` in `Program.cs` scans all
|
||||
> controllers at boot and throws if any non-exempt controller injects `ApplicationDbContext`.
|
||||
> Full rationale and permanent exceptions list: `docs/DATA_ACCESS_ARCHITECTURE.md`
|
||||
|
||||
### Three tiers — use the right one:
|
||||
|
||||
**Tier 1 — Simple CRUD** → `IUnitOfWork.EntityName` (generic `IRepository<T>`)
|
||||
```csharp
|
||||
var items = await _unitOfWork.CatalogItems.GetAllAsync();
|
||||
await _unitOfWork.Announcements.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
```
|
||||
|
||||
**Tier 2 — Complex domain queries** → typed repositories on `IUnitOfWork`
|
||||
```csharp
|
||||
// Include chains and domain-specific queries belong in the repository, not the controller
|
||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
|
||||
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
|
||||
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
|
||||
```
|
||||
Typed repositories: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`,
|
||||
`ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
|
||||
— defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/`
|
||||
|
||||
**Tier 3 — Aggregate/reporting queries** → injected read services
|
||||
```csharp
|
||||
// P&L, AR aging, cycle time, powder usage — shaped DTOs, never tracked entities
|
||||
var aging = await _financialReports.GetArAgingAsync(companyId);
|
||||
```
|
||||
Services: `IFinancialReportService`, `IOperationalReportService`
|
||||
— defined in `Core/Interfaces/Services/`, implemented in `Infrastructure/Services/`
|
||||
|
||||
### Permanent exceptions (ApplicationDbContext allowed — intentional, documented):
|
||||
`StripeWebhookController`, `WebhooksController`, `PaymentController`, `RegistrationController`,
|
||||
`DataExportController`, `AccountDataExportController`, `DataPurgeController`,
|
||||
`SystemInfoController`, `SystemLogsController`, `CompanyHealthController`
|
||||
|
||||
If you think you need a new exception, you almost certainly don't. Check the spec first.
|
||||
|
||||
---
|
||||
|
||||
## Data Access Patterns
|
||||
|
||||
### Common Controller Pattern
|
||||
|
||||
```csharp
|
||||
public class ExampleController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public ExampleController(IUnitOfWork unitOfWork, IMapper mapper)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var entities = await _unitOfWork.Examples.GetAllAsync();
|
||||
var dtos = _mapper.Map<List<ExampleDto>>(entities);
|
||||
return View(dtos);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateExampleDto dto)
|
||||
{
|
||||
var entity = _mapper.Map<Example>(dto);
|
||||
await _unitOfWork.Examples.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _unitOfWork.Examples.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Unit of Work Repositories
|
||||
|
||||
All entity repositories are available via `IUnitOfWork` properties:
|
||||
- `_unitOfWork.Customers`
|
||||
- `_unitOfWork.Jobs`
|
||||
- `_unitOfWork.JobItems`
|
||||
- `_unitOfWork.Quotes`
|
||||
- `_unitOfWork.InventoryItems`
|
||||
- `_unitOfWork.Equipment`
|
||||
- `_unitOfWork.MaintenanceRecords`
|
||||
- Plus additional entities (Suppliers, JobPhotos, JobNotes, etc.)
|
||||
|
||||
### Eager Loading Related Data
|
||||
|
||||
```csharp
|
||||
// Load customer with related data
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(
|
||||
id,
|
||||
c => c.Jobs,
|
||||
c => c.Quotes,
|
||||
c => c.PricingTier
|
||||
);
|
||||
|
||||
// Find with predicate and includes
|
||||
var activeJobs = await _unitOfWork.Jobs.FindAsync(
|
||||
j => j.Status != JobStatus.Completed,
|
||||
j => j.Customer,
|
||||
j => j.JobItems
|
||||
);
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
```csharp
|
||||
var pagedJobs = await _unitOfWork.Jobs.GetPagedAsync(
|
||||
pageNumber: 1,
|
||||
pageSize: 25,
|
||||
j => j.Status == JobStatus.InPreparation,
|
||||
j => j.Customer
|
||||
);
|
||||
```
|
||||
|
||||
## Important Domain Concepts
|
||||
|
||||
### Job Lifecycle
|
||||
|
||||
Jobs progress through 16 statuses:
|
||||
1. **Pending** → Initial state
|
||||
2. **Quoted** → Quote generated
|
||||
3. **Approved** → Customer approved
|
||||
4. **InPreparation** → Job prep started
|
||||
5. **Sandblasting** → Surface prep
|
||||
6. **MaskingTaping** → Masking areas
|
||||
7. **Cleaning** → Pre-coat cleaning
|
||||
8. **InOven** → Pre-heating
|
||||
9. **Coating** → Applying powder
|
||||
10. **Curing** → Heat curing
|
||||
11. **QualityCheck** → Inspection
|
||||
12. **Completed** → Work finished
|
||||
13. **ReadyForPickup** → Awaiting customer
|
||||
14. **Delivered** → Job delivered
|
||||
15. **OnHold** → Paused
|
||||
16. **Cancelled** → Cancelled
|
||||
|
||||
**Job Priorities**: Low, Normal, High, Urgent, Rush (color-coded in UI)
|
||||
|
||||
### Customer Types
|
||||
|
||||
- **Commercial**: B2B customers with pricing tiers, credit limits
|
||||
- **Non-Commercial**: Individual customers, typically simpler pricing
|
||||
|
||||
### Inventory Management
|
||||
|
||||
**Transaction Types**: Purchase, Sale, Adjustment, Transfer, Return, Waste, Initial
|
||||
- All transactions tracked in `InventoryTransaction` entity
|
||||
- Reorder points trigger low-stock alerts
|
||||
|
||||
### Equipment & Maintenance
|
||||
|
||||
**Equipment Status**: Operational, NeedsMaintenance, UnderMaintenance, OutOfService, Retired
|
||||
**Maintenance Priority**: Low, Normal, High, Critical
|
||||
**Maintenance Status**: Scheduled, InProgress, Completed, Cancelled, Overdue
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Web Application (src/PowderCoating.Web/appsettings.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=.\\SQLEXPRESS;Database=PowderCoatingDb;Trusted_Connection=true;MultipleActiveResultSets=true;TrustServerCertificate=true"
|
||||
},
|
||||
"AppSettings": {
|
||||
"CompanyName": "Powder Coating Logix",
|
||||
"DefaultQuoteValidityDays": 30,
|
||||
"DefaultPaymentTerms": "Net 30",
|
||||
"TaxRate": 0.0,
|
||||
"Currency": "USD",
|
||||
"TrialPeriodDays": 7,
|
||||
"QuoteApprovalTokenDays": 30
|
||||
},
|
||||
"AI": {
|
||||
"Anthropic": {
|
||||
"ApiKey": "your-anthropic-api-key-here"
|
||||
}
|
||||
},
|
||||
"SendGrid": { ... },
|
||||
"Stripe": { ... },
|
||||
"Storage": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**AI uses Anthropic Codex Sonnet 4.6** (`Codex-sonnet-4-6`) — NOT OpenAI. The `AI:Anthropic:ApiKey` config key is what the AI photo quoting and AI scheduling services read.
|
||||
|
||||
### API (src/PowderCoating.Api/appsettings.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"JwtSettings": {
|
||||
"SecretKey": "CHANGE-THIS-TO-YOUR-OWN-SECRET-KEY-AT-LEAST-32-CHARACTERS",
|
||||
"Issuer": "PowderCoatingAPI",
|
||||
"Audience": "PowderCoatingMobileApp",
|
||||
"ExpirationMinutes": 1440
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Launch Settings (src/PowderCoating.Web/Properties/launchSettings.json)
|
||||
|
||||
Default ports:
|
||||
- HTTPS: 58461
|
||||
- HTTP: 58462
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
### System Roles
|
||||
- **SuperAdmin**: Platform-wide access, sees all companies and deleted records
|
||||
- **Administrator**: Company admin
|
||||
- **Manager**: Operations management
|
||||
- **Employee**: Create/edit jobs and quotes
|
||||
- **ShopFloor**: Update job status
|
||||
- **ReadOnly**: View-only access
|
||||
|
||||
### Custom Authorization Policies
|
||||
|
||||
Defined in `PowderCoating.Shared/Constants/AppConstants.cs`:
|
||||
- `RequireAdministratorRole`
|
||||
- `CanManageJobs`
|
||||
- `CanManageInventory`
|
||||
- `CanManageUsers`
|
||||
- `CanViewData`
|
||||
|
||||
Apply with `[Authorize(Policy = "PolicyName")]` on controllers/actions.
|
||||
|
||||
### JWT Authentication (API Only)
|
||||
|
||||
API uses JWT Bearer tokens. Web uses cookie-based Identity authentication.
|
||||
|
||||
## AutoMapper Configuration
|
||||
|
||||
AutoMapper is registered as singleton in `Program.cs`:
|
||||
```csharp
|
||||
builder.Services.AddSingleton(provider => new MapperConfiguration(cfg =>
|
||||
{
|
||||
cfg.AddMaps(typeof(ApplicationAssemblyMarker).Assembly);
|
||||
}).CreateMapper());
|
||||
```
|
||||
|
||||
All profiles in `Application/Mappings/` are auto-discovered. Profiles include reverse mappings for entity ↔ DTO conversion.
|
||||
|
||||
## Logging
|
||||
|
||||
Serilog configured to write:
|
||||
- Console (structured logs)
|
||||
- File: `logs/powdercoating-{Date}.txt` (rolling daily)
|
||||
|
||||
Access via constructor injection:
|
||||
```csharp
|
||||
private readonly ILogger<ExampleController> _logger;
|
||||
```
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Adding a New Entity
|
||||
|
||||
1. Create entity class in `Core/Entities/` inheriting from `BaseEntity`
|
||||
2. Add DbSet to `ApplicationDbContext`
|
||||
3. Register repository property in `IUnitOfWork` interface
|
||||
4. Add lazy-loaded property in `UnitOfWork` implementation
|
||||
5. Create migration: `dotnet ef migrations add AddEntityName --project ../PowderCoating.Infrastructure`
|
||||
6. Apply migration: `dotnet ef database update --project ../PowderCoating.Infrastructure`
|
||||
|
||||
### Adding a New Controller
|
||||
|
||||
1. Create DTOs in `Application/DTOs/`
|
||||
2. Create AutoMapper profile in `Application/Mappings/`
|
||||
3. Create controller in `Web/Controllers/`
|
||||
4. Create views in `Web/Views/[ControllerName]/`
|
||||
5. Add navigation link in `Views/Shared/_Layout.cshtml`
|
||||
|
||||
### Working with Soft Deletes
|
||||
|
||||
```csharp
|
||||
// Soft delete (sets IsDeleted = true)
|
||||
await _unitOfWork.Customers.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Physical delete (use sparingly)
|
||||
await _unitOfWork.Customers.DeleteAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Include deleted records in query
|
||||
var allCustomers = await _unitOfWork.Customers.GetAllAsync(ignoreQueryFilters: true);
|
||||
```
|
||||
|
||||
### Bypassing Multi-Tenancy Filters
|
||||
|
||||
Only for SuperAdmin users:
|
||||
```csharp
|
||||
// See all companies' data
|
||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(ignoreQueryFilters: true);
|
||||
```
|
||||
|
||||
## Implemented Modules
|
||||
|
||||
All modules below are fully implemented with controllers, views, and migrations applied.
|
||||
|
||||
### Operations
|
||||
- **Jobs** — full lifecycle (16 statuses), worker assignment, time entries, rework tracking, shop access codes, job templates
|
||||
- **Quotes** — multi-item pricing engine, AI Photo Quoting (Anthropic Codex Sonnet 4.6), quote-to-job conversion, customer approval portal, online payment
|
||||
- **Invoices** — create from job, partial payments, voids, PDF download, email send; 1:1 Job→Invoice enforced by unique index
|
||||
- **Deposits** — record against customer/job/quote; auto-applied to invoices on creation; receipt PDF via QuestPDF
|
||||
- **Customers** — commercial and non-commercial types, pricing tiers, tax exempt flag + certificate upload, credit limits
|
||||
- **Oven Scheduler** — batch jobs into named ovens, capacity planning, suggested batches
|
||||
|
||||
### Inventory & Purchasing
|
||||
- **Inventory** — stock tracking, transactions, reorder alerts, powder coverage/efficiency fields
|
||||
- **Vendors** — supplier management, payment terms, linked to inventory items
|
||||
- **Purchase Orders** — create/submit/receive POs, convert to vendor bills
|
||||
- **Accounts Payable** — vendor bills, AP ledger, payment tracking
|
||||
|
||||
### Shop Management
|
||||
- **Shop Workers** — roles (Coater, Sandblaster, etc.), assignment to jobs and maintenance tasks
|
||||
- **Equipment & Maintenance** — equipment status lifecycle, scheduled/completed maintenance records
|
||||
- **Catalog Items** — pre-priced service catalog with default prices
|
||||
- **Pricing Tiers** — customer discount tiers; use `CompanyAdminOnly` policy (not `RequireAdministratorRole`)
|
||||
|
||||
### Billing & Payments
|
||||
- **Stripe** — subscription plans, checkout sessions, customer portal, webhooks (`/stripe/webhook`)
|
||||
- **Stripe Connect** — embedded payments, OAuth flow for tenant onboarding
|
||||
- **Twilio SMS** — `ISmsService` fully implemented; webhook at `POST /Webhooks/TwilioSms`
|
||||
|
||||
### Platform (SuperAdmin only)
|
||||
- **Platform Users** — create/manage SuperAdmin accounts
|
||||
- **Companies** — view/manage all tenant companies
|
||||
- **Seed Data** — manual seeding via Platform Management UI (not automatic)
|
||||
- **Subscription Plans** — `SubscriptionPlanConfig` controls per-plan limits and pricing
|
||||
|
||||
### Other
|
||||
- **Help Center** — 14 fully-written articles at `Views/Help/`
|
||||
- **Setup Wizard** — 10-step onboarding wizard at `SetupWizardController`
|
||||
- **Reports** — 24 report actions including P&L, AR Aging, Powder Usage, Job Cycle Time, PDF exports
|
||||
- **Gift Certificates** — issue, redeem, track balance
|
||||
- **Announcements** — platform-wide announcements to tenants
|
||||
|
||||
### Key Pricing Rules
|
||||
- Custom powder (no inventory item + `PowderToOrder` > 0): charge for the **full ordered quantity**, not just calculated usage
|
||||
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
|
||||
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
|
||||
|
||||
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
|
||||
|
||||
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
|
||||
|
||||
| Flag | Effect if missing on JobItem |
|
||||
|------|------------------------------|
|
||||
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
|
||||
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
|
||||
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
|
||||
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
|
||||
|
||||
**Checklist when adding a new pricing routing flag:**
|
||||
1. Add the property to `QuoteItem` (Core/Entities)
|
||||
2. Add the property to `JobItem` (Core/Entities)
|
||||
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
|
||||
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
|
||||
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
|
||||
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
|
||||
7. Add a migration if the field is new on a persisted entity
|
||||
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 1–3 are done — this is intentional
|
||||
|
||||
### Branding
|
||||
- Application name: **Powder Coating Logix**
|
||||
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
|
||||
- Sidebar footer always shows PCL logo linking to `http://www.powdercoatinglogix.com`
|
||||
- Tenant companies can upload their own logo (stored in Azure Blob `companylogos` container); it replaces the PCL logo in the sidebar header
|
||||
|
||||
## Known Issues
|
||||
|
||||
- Entity Framework warnings about global query filters on related entities (non-critical, informational only)
|
||||
|
||||
## File Upload Configuration
|
||||
|
||||
Limits defined in `AppConstants.cs`:
|
||||
- Max file size: 10 MB
|
||||
- Allowed extensions: jpg, jpeg, png, gif, pdf, doc, docx, xls, xlsx
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit Tests**: Test business logic in isolation
|
||||
- **Integration Tests**: Test full request pipeline with test database
|
||||
- Use xUnit framework
|
||||
- Mock `IUnitOfWork` in unit tests
|
||||
|
||||
## Extending the System
|
||||
|
||||
### Adding AI Features
|
||||
|
||||
AI uses Anthropic Codex Sonnet 4.6 via `IAiQuoteService`. Configure the key under `AI:Anthropic:ApiKey` in `appsettings.json`.
|
||||
1. Create service interface in `Application/Interfaces/`
|
||||
2. Implement in `Infrastructure/Services/` calling the Anthropic client
|
||||
3. Inject into controllers via DI
|
||||
|
||||
### SignalR Hubs
|
||||
|
||||
Two hubs are already implemented and mapped in `Program.cs`:
|
||||
- `NotificationHub` → `/hubs/notifications` (company-scoped push notifications)
|
||||
- `ShopHub` → `/hubs/shop` (real-time shop floor updates)
|
||||
|
||||
To add a new hub:
|
||||
1. Create hub class in `Web/Hubs/`
|
||||
2. Map hub in `Program.cs`: `app.MapHub<YourHub>("/hubpath")`
|
||||
3. Use JavaScript client in views to connect
|
||||
|
||||
### Adding API Endpoints
|
||||
|
||||
1. Create controller in `Api/Controllers/` with `[ApiController]` attribute
|
||||
2. Return `ActionResult<T>` types
|
||||
3. Use `[Authorize]` for protected endpoints
|
||||
4. Document with XML comments for Swagger
|
||||
|
||||
## Project Dependencies
|
||||
|
||||
Key NuGet packages:
|
||||
- **AutoMapper 16.0.0**: Entity-to-DTO mapping
|
||||
- **Entity Framework Core 8.0.11**: ORM and database access
|
||||
- **Serilog.AspNetCore 8.0.3**: Structured logging
|
||||
- **Microsoft.AspNetCore.Identity.UI 8.0.11**: Authentication
|
||||
- **Swashbuckle.AspNetCore 7.2.0**: API documentation (API project)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Password requirements: 8+ chars, uppercase, lowercase, digit
|
||||
- HTTPS enforced in production
|
||||
- SQL injection prevented by EF Core parameterization
|
||||
- XSS protection via Razor encoding
|
||||
- CSRF tokens on all forms (automatic with ASP.NET Core)
|
||||
- Sensitive settings (connection strings, API keys) should use User Secrets in development and Azure Key Vault in production
|
||||
|
||||
## Active design work
|
||||
A visual redesign is in progress. If the user asks about UI changes, dashboard/jobs/board styling, or the new design tokens, read `design_handoff_pcl_redesign/README.md` and follow `design_handoff_pcl_redesign/AGENTS.md` for that work.
|
||||
@@ -33,6 +33,10 @@ public class InvoiceDto
|
||||
public string? CustomerEmail { get; set; }
|
||||
public string? CustomerPhone { get; set; }
|
||||
public string? CustomerMobilePhone { get; set; }
|
||||
public string? CustomerAddress { get; set; }
|
||||
public string? CustomerCity { get; set; }
|
||||
public string? CustomerState { get; set; }
|
||||
public string? CustomerZipCode { get; set; }
|
||||
public bool CustomerNotifyByEmail { get; set; }
|
||||
public bool CustomerNotifyBySms { get; set; }
|
||||
public string? PreparedById { get; set; }
|
||||
|
||||
@@ -25,6 +25,12 @@ public interface IPdfService
|
||||
CompanyInfoDto companyInfo,
|
||||
QuoteTemplateSettingsDto? template = null);
|
||||
|
||||
Task<byte[]> GeneratePackingSlipPdfAsync(
|
||||
InvoiceDto invoiceDto,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo);
|
||||
|
||||
Task<byte[]> GeneratePurchaseOrderPdfAsync(
|
||||
PurchaseOrderDto po,
|
||||
byte[]? companyLogo,
|
||||
|
||||
@@ -29,6 +29,10 @@ public class InvoiceProfile : Profile
|
||||
: null))
|
||||
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
||||
.ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null))
|
||||
.ForMember(d => d.CustomerAddress, o => o.MapFrom(s => s.Customer != null ? s.Customer.Address : null))
|
||||
.ForMember(d => d.CustomerCity, o => o.MapFrom(s => s.Customer != null ? s.Customer.City : null))
|
||||
.ForMember(d => d.CustomerState, o => o.MapFrom(s => s.Customer != null ? s.Customer.State : null))
|
||||
.ForMember(d => d.CustomerZipCode, o => o.MapFrom(s => s.Customer != null ? s.Customer.ZipCode : null))
|
||||
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
||||
.ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
|
||||
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
||||
|
||||
@@ -2753,4 +2753,187 @@ public class PdfService : IPdfService
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Packing Slip
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Generates a no-price packing slip PDF for the given invoice. Lists job items with
|
||||
/// description, color, and quantity only — no unit prices or totals. Intended for
|
||||
/// physical pickup/delivery paperwork where pricing should not be visible.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GeneratePackingSlipPdfAsync(
|
||||
InvoiceDto invoiceDto,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accentColor = "#1e40af"; // blue
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.75f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposePackingSlipHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
|
||||
page.Content().Element(c => ComposePackingSlipContent(c, invoiceDto, accentColor));
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.Span("PACKING SLIP | ").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.Span(" | Page ").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.CurrentPageNumber().FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.Span(" of ").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.TotalPages().FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Header for the packing slip: company branding left, "PACKING SLIP" title + invoice/date info right.
|
||||
/// </summary>
|
||||
private void ComposePackingSlipHeader(IContainer container, byte[]? companyLogo, CompanyInfoDto companyInfo, string accentColor, InvoiceDto invoice)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
col.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(column =>
|
||||
{
|
||||
if (companyLogo != null && companyLogo.Length > 0)
|
||||
column.Item().MaxHeight(60).Image(companyLogo);
|
||||
else
|
||||
column.Item().Text(companyInfo.CompanyName).FontSize(18).Bold().FontColor(accentColor);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.Address))
|
||||
column.Item().Text(companyInfo.Address).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
var cityLine = $"{companyInfo.City}{(!string.IsNullOrEmpty(companyInfo.City) && !string.IsNullOrEmpty(companyInfo.State) ? ", " : "")}{companyInfo.State} {companyInfo.ZipCode}".Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cityLine))
|
||||
column.Item().Text(cityLine).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
|
||||
column.Item().Text(companyInfo.PrimaryContactEmail).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
|
||||
row.RelativeItem().AlignRight().Column(column =>
|
||||
{
|
||||
column.Item().Text("PACKING SLIP").FontSize(26).Bold().FontColor(accentColor);
|
||||
column.Item().Text($"Invoice #: {invoice.InvoiceNumber}").FontSize(9).Bold();
|
||||
column.Item().Text($"Date: {invoice.InvoiceDate:MMMM d, yyyy}").FontSize(9);
|
||||
if (!string.IsNullOrWhiteSpace(invoice.JobNumber))
|
||||
column.Item().Text($"Job #: {invoice.JobNumber}").FontSize(9);
|
||||
});
|
||||
});
|
||||
|
||||
col.Item().PaddingVertical(4).LineHorizontal(1).LineColor(accentColor);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Body of the packing slip: customer info block, optional PO number, and an items table
|
||||
/// showing description, color, and quantity — no prices.
|
||||
/// </summary>
|
||||
private void ComposePackingSlipContent(IContainer container, InvoiceDto invoice, string accentColor)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
// Customer info
|
||||
col.Item().PaddingTop(12).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().Text("PREPARED FOR").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||||
c.Item().Text(invoice.CustomerName).Bold();
|
||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerAddress))
|
||||
c.Item().Text(invoice.CustomerAddress).FontSize(9);
|
||||
var cityLine = $"{invoice.CustomerCity}{(!string.IsNullOrEmpty(invoice.CustomerCity) && !string.IsNullOrEmpty(invoice.CustomerState) ? ", " : "")}{invoice.CustomerState} {invoice.CustomerZipCode}".Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cityLine))
|
||||
c.Item().Text(cityLine).FontSize(9);
|
||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPhone))
|
||||
c.Item().Text(FormatPhoneNumber(invoice.CustomerPhone)).FontSize(9);
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
||||
{
|
||||
row.ConstantItem(160).AlignRight().Column(c =>
|
||||
{
|
||||
c.Item().Text("PURCHASE ORDER").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||||
c.Item().Text(invoice.CustomerPO).Bold();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Items table
|
||||
col.Item().PaddingTop(16).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(5);
|
||||
cols.RelativeColumn(3);
|
||||
cols.RelativeColumn(1);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
h.Cell().Background(accentColor).Padding(5).Text("Description").FontColor(Colors.White).Bold().FontSize(9);
|
||||
h.Cell().Background(accentColor).Padding(5).Text("Color / Finish").FontColor(Colors.White).Bold().FontSize(9);
|
||||
h.Cell().Background(accentColor).Padding(5).AlignCenter().Text("Qty").FontColor(Colors.White).Bold().FontSize(9);
|
||||
});
|
||||
|
||||
var rowAlt = false;
|
||||
foreach (var item in invoice.InvoiceItems.OrderBy(i => i.DisplayOrder))
|
||||
{
|
||||
var bg = rowAlt ? Colors.Grey.Lighten4 : Colors.White;
|
||||
table.Cell().Background(bg).Padding(5).Column(c =>
|
||||
{
|
||||
c.Item().Text(item.Description).FontSize(9);
|
||||
if (!string.IsNullOrWhiteSpace(item.Notes))
|
||||
c.Item().Text(item.Notes).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
table.Cell().Background(bg).Padding(5).Text(item.ColorName ?? "—").FontSize(9);
|
||||
table.Cell().Background(bg).Padding(5).AlignCenter().Text(item.Quantity.ToString("G")).FontSize(9);
|
||||
rowAlt = !rowAlt;
|
||||
}
|
||||
});
|
||||
|
||||
// Notes (if any)
|
||||
if (!string.IsNullOrWhiteSpace(invoice.Notes))
|
||||
{
|
||||
col.Item().PaddingTop(16).Column(c =>
|
||||
{
|
||||
c.Item().Text("Notes").Bold().FontSize(9);
|
||||
c.Item().Text(invoice.Notes).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
}
|
||||
|
||||
// Received by signature line
|
||||
col.Item().PaddingTop(32).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
|
||||
c.Item().PaddingTop(2).Text("Received by / Date").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
row.ConstantItem(24);
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
|
||||
c.Item().PaddingTop(2).Text("Condition noted").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +355,15 @@ public class InvoicesController : Controller
|
||||
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
|
||||
if (job == null) return NotFound();
|
||||
|
||||
// Pre-load coats so we can derive color names for invoice line items
|
||||
var activeItemIds = job.JobItems.Where(ji => !ji.IsDeleted).Select(ji => ji.Id).ToList();
|
||||
var allCoats = activeItemIds.Any()
|
||||
? (await _unitOfWork.JobItemCoats.FindAsync(c => activeItemIds.Contains(c.JobItemId) && !c.IsDeleted)).ToList()
|
||||
: new List<JobItemCoat>();
|
||||
var coatsByItem = allCoats
|
||||
.GroupBy(c => c.JobItemId)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(c => c.Sequence).ToList());
|
||||
|
||||
// Validate no existing active invoice for this job (voided ones are kept as history)
|
||||
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value);
|
||||
if (existing != null && existing.Status != InvoiceStatus.Voided)
|
||||
@@ -404,6 +413,16 @@ public class InvoicesController : Controller
|
||||
revenueAccountId = ci.RevenueAccountId;
|
||||
revenueAccountId ??= defaultRevenueAccount?.Id;
|
||||
|
||||
// Derive color from coats when the item itself has no explicit color set
|
||||
var derivedColor = item.ColorName;
|
||||
if (string.IsNullOrEmpty(derivedColor) && coatsByItem.TryGetValue(item.Id, out var itemCoats))
|
||||
{
|
||||
var coatColors = itemCoats
|
||||
.Where(c => !string.IsNullOrEmpty(c.ColorName))
|
||||
.Select(c => c.ColorName!);
|
||||
derivedColor = string.Join(" / ", coatColors);
|
||||
}
|
||||
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
SourceJobItemId = item.Id,
|
||||
@@ -412,7 +431,7 @@ public class InvoicesController : Controller
|
||||
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
||||
UnitPrice = item.UnitPrice,
|
||||
TotalPrice = item.TotalPrice,
|
||||
ColorName = item.ColorName,
|
||||
ColorName = derivedColor,
|
||||
Notes = item.Notes,
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = revenueAccountId
|
||||
@@ -445,16 +464,22 @@ public class InvoicesController : Controller
|
||||
// If the job came from a quote, carry over the quote-level costs and agreed terms.
|
||||
// The quote SubTotal = sum(items) + oven batch cost + shop supplies.
|
||||
// Job items only capture per-item prices, so oven & shop supplies need a separate line.
|
||||
// Read directly from the quote snapshot — never try to reverse-engineer from job.FinalPrice
|
||||
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
|
||||
// For fee components, prefer the job's own breakdown snapshot (updated every time the job
|
||||
// is saved) over the source quote — the quote's FacilityOverheadCost was only added in
|
||||
// migration AddQuotePricingSnapshotFields (May 2026); older quotes have 0 there even though
|
||||
// overhead was included in the quote total. Tax and discount still come from the quote
|
||||
// because those represent the customer-approved agreed terms.
|
||||
if (sourceQuote != null)
|
||||
{
|
||||
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
|
||||
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
|
||||
var processingFees = sourceQuote.OvenBatchCost
|
||||
+ sourceQuote.FacilityOverheadCost
|
||||
+ sourceQuote.ShopSuppliesAmount
|
||||
+ sourceQuote.RushFee;
|
||||
// Prefer job breakdown values for dynamic fee components; fall back to quote for
|
||||
// compatibility with jobs that were never re-saved after the May 2026 migration.
|
||||
var ovenCost = jobBreakdown != null ? jobBreakdown.OvenBatchCost : sourceQuote.OvenBatchCost;
|
||||
var overhead = jobBreakdown != null ? jobBreakdown.FacilityOverheadCost : sourceQuote.FacilityOverheadCost;
|
||||
var shopSupplies = jobBreakdown != null ? jobBreakdown.ShopSuppliesAmount : sourceQuote.ShopSuppliesAmount;
|
||||
var rushFee = jobBreakdown != null ? jobBreakdown.RushFee : sourceQuote.RushFee;
|
||||
|
||||
// Bundle all quote-level charges so the invoice subtotal matches the job total.
|
||||
var processingFees = ovenCost + overhead + shopSupplies + rushFee;
|
||||
|
||||
if (processingFees > 0.01m)
|
||||
{
|
||||
@@ -1763,6 +1788,59 @@ public class InvoicesController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET: /Invoices/DownloadPackingSlip/5
|
||||
// -----------------------------------------------------------------------
|
||||
/// <summary>
|
||||
/// Generates a no-price packing slip PDF for physical pickup/delivery paperwork.
|
||||
/// Reuses the same company branding and invoice data pipeline as DownloadPdf but
|
||||
/// delegates to GeneratePackingSlipPdfAsync which omits all pricing columns.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> DownloadPackingSlip(int? id, bool inline = false)
|
||||
{
|
||||
if (id == null) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
var invoice = await LoadInvoiceForViewAsync(id.Value);
|
||||
if (invoice == null) return NotFound();
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
|
||||
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
|
||||
{
|
||||
CompanyName = company?.CompanyName ?? string.Empty,
|
||||
Phone = company?.Phone,
|
||||
Address = company?.Address,
|
||||
City = company?.City,
|
||||
State = company?.State,
|
||||
ZipCode = company?.ZipCode,
|
||||
PrimaryContactEmail = company?.PrimaryContactEmail
|
||||
};
|
||||
|
||||
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||
var dto = await BuildInvoiceDtoAsync(invoice);
|
||||
var pdfBytes = await _pdfService.GeneratePackingSlipPdfAsync(dto, logoData, logoContentType, companyInfo);
|
||||
var fileName = $"PackingSlip-{invoice.InvoiceNumber}.pdf";
|
||||
|
||||
if (inline)
|
||||
{
|
||||
Response.Headers["Content-Disposition"] = $"inline; filename=\"{fileName}\"";
|
||||
return File(pdfBytes, "application/pdf");
|
||||
}
|
||||
|
||||
return File(pdfBytes, "application/pdf", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating packing slip for invoice {Id}", id);
|
||||
TempData["ErrorPermanent"] = $"Packing slip generation failed: {ex.Message}";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET: /Invoices/ForJob/5 — redirect to existing or Create
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -3089,6 +3167,50 @@ public class InvoicesController : Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline-edits description, quantity, and unit price on a single invoice line item.
|
||||
/// Blocked on paid/voided invoices (same gate as the full Edit action).
|
||||
/// Returns updated totals so the page can reflect the change without a reload.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PatchItem([FromBody] PatchInvoiceItemRequest request)
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var item = await _unitOfWork.InvoiceItems.GetByIdAsync(request.ItemId);
|
||||
if (item == null) return NotFound();
|
||||
|
||||
var invoice = await _unitOfWork.Invoices.GetByIdAsync(item.InvoiceId);
|
||||
if (invoice == null || invoice.CompanyId != currentUser.CompanyId) return NotFound();
|
||||
|
||||
if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue))
|
||||
return BadRequest(new { error = "Cannot edit items on a paid or voided invoice." });
|
||||
|
||||
item.Description = request.Description.Trim();
|
||||
item.Quantity = request.Quantity;
|
||||
item.UnitPrice = request.UnitPrice;
|
||||
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||
await _unitOfWork.InvoiceItems.UpdateAsync(item);
|
||||
|
||||
var allItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == invoice.Id);
|
||||
var newSubTotal = allItems.Sum(i => i.TotalPrice);
|
||||
invoice.SubTotal = newSubTotal;
|
||||
invoice.TaxAmount = Math.Round(newSubTotal * invoice.TaxPercent / 100m, 2);
|
||||
invoice.Total = Math.Round(newSubTotal - invoice.DiscountAmount + invoice.TaxAmount, 2);
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new {
|
||||
lineTotal = item.TotalPrice,
|
||||
subtotal = invoice.SubTotal,
|
||||
taxAmount = invoice.TaxAmount,
|
||||
total = invoice.Total,
|
||||
balanceDue = invoice.BalanceDue
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns logo bytes and content type for PDF generation.
|
||||
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData).
|
||||
@@ -3104,3 +3226,11 @@ public class InvoicesController : Controller
|
||||
return (company.LogoData, company.LogoContentType);
|
||||
}
|
||||
}
|
||||
|
||||
public class PatchInvoiceItemRequest
|
||||
{
|
||||
public int ItemId { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
}
|
||||
|
||||
@@ -110,6 +110,11 @@ public class JobsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
// Default landing view: On Floor — redirect bare /Jobs to ?statusGroup=active
|
||||
// so completed/cancelled jobs don't clutter the first screen.
|
||||
if (string.IsNullOrEmpty(statusGroup) && string.IsNullOrEmpty(searchTerm) && string.IsNullOrEmpty(tagFilter))
|
||||
return RedirectToAction("Index", new { statusGroup = "active" });
|
||||
|
||||
// Create and validate grid request
|
||||
var gridRequest = new GridRequest
|
||||
{
|
||||
@@ -141,6 +146,13 @@ public class JobsController : Controller
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled;
|
||||
}
|
||||
else if (statusGroup == "completed")
|
||||
{
|
||||
filter = j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered;
|
||||
}
|
||||
// "all" or unknown group: no filter applied (show every status)
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
@@ -195,6 +207,27 @@ public class JobsController : Controller
|
||||
gridRequest, jobDtos,
|
||||
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count);
|
||||
|
||||
// Pill badge counts — always global (not scoped to current filter/page)
|
||||
var today = DateTime.Today;
|
||||
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync();
|
||||
ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||
ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||
j.DueDate < today
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||
ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered);
|
||||
ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
|
||||
|
||||
// Set ViewBag for sorting
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
ViewBag.StatusGroup = statusGroup;
|
||||
@@ -3950,10 +3983,11 @@ public class JobsController : Controller
|
||||
ovenCost = opCosts.OvenOperatingCostPerHour * defaultOvenCycleHours;
|
||||
}
|
||||
|
||||
// 4. Revenue
|
||||
decimal revenue = job.Invoice != null
|
||||
? job.Invoice.Total
|
||||
: (job.FinalPrice > 0 ? job.FinalPrice : job.QuotedPrice);
|
||||
// 4. Revenue — prefer FinalPrice (reflects inline edits and job-level changes);
|
||||
// fall back to Invoice.Total only when FinalPrice is zero (voided/zeroed job).
|
||||
decimal revenue = job.FinalPrice > 0
|
||||
? job.FinalPrice
|
||||
: (job.Invoice?.Total ?? job.QuotedPrice);
|
||||
|
||||
// 5. Rework costs from linked rework jobs
|
||||
var reworkRecords = await _unitOfWork.ReworkRecords.FindAsync(
|
||||
@@ -3989,7 +4023,7 @@ public class JobsController : Controller
|
||||
|
||||
return Json(new {
|
||||
revenue = Math.Round(revenue, 2),
|
||||
revenueSource = job.Invoice != null ? "Invoice" : (job.FinalPrice > 0 ? "Final Price" : "Quoted Price"),
|
||||
revenueSource = job.FinalPrice > 0 ? "Final Price" : (job.Invoice != null ? "Invoice" : "Quoted Price"),
|
||||
powderCost = Math.Round(powderCost, 2),
|
||||
laborCost = Math.Round(laborCost, 2),
|
||||
ovenCost = Math.Round(ovenCost, 2),
|
||||
@@ -4183,9 +4217,92 @@ public class JobsController : Controller
|
||||
return Json(new { success = false, message = "An error occurred. Please try again." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline-edits description, quantity, and unit price on a single job line item.
|
||||
/// Adjusts FinalPrice and the stored PricingBreakdownJson snapshot by the price delta.
|
||||
/// Returns updated totals so the page can reflect the change without a reload.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PatchItem([FromBody] PatchJobItemRequest request)
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var item = await _unitOfWork.JobItems.GetByIdAsync(request.ItemId);
|
||||
if (item == null) return NotFound();
|
||||
|
||||
var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId);
|
||||
if (job == null || job.CompanyId != currentUser.CompanyId) return NotFound();
|
||||
|
||||
var oldTotal = item.TotalPrice;
|
||||
item.Description = request.Description.Trim();
|
||||
item.Quantity = request.Quantity;
|
||||
item.UnitPrice = request.UnitPrice;
|
||||
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||
await _unitOfWork.JobItems.UpdateAsync(item);
|
||||
|
||||
var delta = item.TotalPrice - oldTotal;
|
||||
job.FinalPrice = Math.Round(job.FinalPrice + delta, 2);
|
||||
|
||||
// Keep the stored pricing snapshot in sync so the breakdown panel stays consistent.
|
||||
// Case-insensitive options handle JSON stored before PascalCase serialization was enforced.
|
||||
QuotePricingBreakdownDto? pbFinal = null;
|
||||
var jsonOpts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||
{
|
||||
var pb = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson, jsonOpts);
|
||||
if (pb != null)
|
||||
{
|
||||
pb.ItemsSubtotal += delta;
|
||||
pb.SubtotalBeforeDiscount += delta;
|
||||
pb.SubtotalAfterDiscount = pb.SubtotalBeforeDiscount - pb.DiscountAmount;
|
||||
pb.TaxAmount = Math.Round(pb.SubtotalAfterDiscount * pb.TaxPercent / 100m, 2);
|
||||
pb.Total = Math.Round(pb.SubtotalAfterDiscount + pb.RushFee + pb.TaxAmount, 2);
|
||||
job.FinalPrice = pb.Total;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(pb);
|
||||
pbFinal = pb;
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// For legacy jobs without a stored snapshot, derive breakdown from live item totals.
|
||||
if (pbFinal == null)
|
||||
{
|
||||
var allItems = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id && !ji.IsDeleted);
|
||||
var itemsSubtotal = allItems.Sum(ji => ji.TotalPrice);
|
||||
var subtotal = itemsSubtotal + job.OvenBatchCost + job.ShopSuppliesAmount;
|
||||
pbFinal = new QuotePricingBreakdownDto
|
||||
{
|
||||
ItemsSubtotal = itemsSubtotal,
|
||||
SubtotalBeforeDiscount = subtotal,
|
||||
SubtotalAfterDiscount = subtotal,
|
||||
Total = job.FinalPrice
|
||||
};
|
||||
}
|
||||
|
||||
return Json(new {
|
||||
lineTotal = item.TotalPrice,
|
||||
finalPrice = job.FinalPrice,
|
||||
itemsSubtotal = pbFinal.ItemsSubtotal,
|
||||
subtotalBeforeDiscount = pbFinal.SubtotalBeforeDiscount,
|
||||
subtotalAfterDiscount = pbFinal.SubtotalAfterDiscount,
|
||||
taxAmount = pbFinal.TaxAmount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
||||
public class PatchJobItemRequest
|
||||
{
|
||||
public int ItemId { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
}
|
||||
public class LogMaterialRequest
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
|
||||
@@ -3824,6 +3824,49 @@ public class QuotesController : Controller
|
||||
}
|
||||
return (company.LogoData, company.LogoContentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline-edits description, quantity, and unit price on a single quote line item.
|
||||
/// Adjusts stored quote totals by the price delta so the sidebar stays accurate.
|
||||
/// Returns updated totals so the page can reflect the change without a reload.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PatchItem([FromBody] PatchQuoteItemRequest request)
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var item = await _unitOfWork.QuoteItems.GetByIdAsync(request.ItemId);
|
||||
if (item == null) return NotFound();
|
||||
|
||||
var quote = await _unitOfWork.Quotes.GetByIdAsync(item.QuoteId);
|
||||
if (quote == null || quote.CompanyId != currentUser.CompanyId) return NotFound();
|
||||
|
||||
var oldTotal = item.TotalPrice;
|
||||
item.Description = request.Description.Trim();
|
||||
item.Quantity = request.Quantity;
|
||||
item.UnitPrice = request.UnitPrice;
|
||||
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||
await _unitOfWork.QuoteItems.UpdateAsync(item);
|
||||
|
||||
// Cascade delta through stored totals without re-running the pricing engine
|
||||
var delta = item.TotalPrice - oldTotal;
|
||||
quote.ItemsSubtotal += delta;
|
||||
quote.SubTotal += delta;
|
||||
quote.SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount;
|
||||
quote.TaxAmount = Math.Round(quote.SubtotalAfterDiscount * quote.TaxPercent / 100m, 2);
|
||||
quote.Total = Math.Round(quote.SubtotalAfterDiscount + quote.RushFee + quote.TaxAmount, 2);
|
||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new {
|
||||
lineTotal = item.TotalPrice,
|
||||
subtotal = quote.SubTotal,
|
||||
taxAmount = quote.TaxAmount,
|
||||
total = quote.Total
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Request model for AJAX pricing calculation
|
||||
@@ -3834,3 +3877,11 @@ public class UpdateQuoteStatusRequest
|
||||
public int QuoteId { get; set; }
|
||||
public int StatusId { get; set; }
|
||||
}
|
||||
|
||||
public class PatchQuoteItemRequest
|
||||
{
|
||||
public int ItemId { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
}
|
||||
|
||||
@@ -215,6 +215,8 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**Per-item cost breakdown:** On the Quote Details page, each line item shows a collapsible cost breakdown — click the row to expand it and see how material, labor, equipment, complexity, and markup were calculated for that specific item. This is useful for spotting which items are underpriced or where costs are concentrated.
|
||||
|
||||
**Inline item editing on quotes:** On the Quote Details page, any unit price, quantity, or item description can be edited in-place by clicking the value directly. Press Enter or click away to save; press Escape to cancel. The pricing summary (subtotal, discount, tax, and total) updates immediately without reloading the page.
|
||||
|
||||
**Pricing Mode (Markup vs Margin):** In Company Settings → Operating Costs you can choose between two pricing modes:
|
||||
- *Markup on Materials* (default) — the General Markup % is applied as a markup on top of calculated costs: `price = cost × (1 + markup%)`. A 25% markup on a $100 cost = $125.
|
||||
- *Target Margin on Total Cost* — the markup % is treated as a target gross margin: `price = cost ÷ (1 − margin%)`. A 25% margin on a $100 cost = $133.33. The difference grows at higher percentages.
|
||||
@@ -265,12 +267,15 @@ public static class HelpKnowledgeBase
|
||||
**Job Priorities (color-coded):**
|
||||
- Low (grey), Normal (blue), High (orange), Urgent (red), Rush (purple)
|
||||
|
||||
**Jobs list default view:** The Jobs list opens on the **On Floor** filter by default — showing only active jobs (excludes Completed, Ready for Pickup, Delivered, Cancelled). Use the filter pills at the top to switch views: **All** shows every job regardless of status; **On Floor** shows active work; **Overdue** shows past-due active jobs; **Ready** shows jobs awaiting customer pickup; **Completed** shows all finished jobs (Completed + Ready for Pickup + Delivered). Each pill shows a live global count.
|
||||
|
||||
**How to create a job:**
|
||||
1. Go to [Jobs](/Jobs) → "New Job"
|
||||
2. Select customer
|
||||
3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo)
|
||||
4. Set priority, due date, assigned worker, special instructions
|
||||
5. Save
|
||||
5. Optionally set Oven & Batch Settings — select a named oven, number of batches, and cycle time. These affect the oven cost in pricing.
|
||||
6. Save
|
||||
|
||||
**Job Priority Board:** [/JobsPriority](/JobsPriority) — Kanban-style view of all active jobs sorted by priority and status.
|
||||
|
||||
@@ -302,7 +307,13 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
|
||||
|
||||
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
|
||||
**Inline item price editing:** On the Job Details page, any unit price, quantity, or item description can be edited in-place without opening the full edit form. Click the value — it becomes an input field. Type the new value, then press Enter or click away to save (Escape cancels). The pricing summary card (Items Subtotal, Subtotal, Tax, and Total) and the Job Costing card both update immediately without a page reload.
|
||||
|
||||
**Job Costing revenue:** The Job Costing card uses the job's Final Price as the revenue figure — not the linked invoice total. This means inline price edits are reflected in the profit margin estimate immediately, even before an invoice exists.
|
||||
|
||||
**Completing a job:** When a job is ready to mark complete, click the **Complete Job** button on the Job Details page. A modal appears asking you to confirm the completion date, actual hours spent, and final price. If the job used powder from inventory, you will be asked to enter the actual lbs used — the modal groups all coats by unique powder color (not per coat or per item) so you fill in one quantity per powder. The system deducts the entered amounts from inventory, crediting any quantities already logged via QR scan. Once confirmed, the job advances to Completed status, and you are prompted to create the invoice if one does not exist.
|
||||
|
||||
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Line item descriptions include the coat color(s) for each item (e.g., "Color1 / Color2" if multiple coats), helping customers distinguish repeated items on the invoice. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
|
||||
|
||||
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes — one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
|
||||
|
||||
@@ -352,6 +363,8 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**Payment methods:** Cash, Check, Credit/Debit Card, Bank Transfer (ACH), Digital Payment, Store Credit
|
||||
|
||||
**Inline item editing on invoices:** On the Invoice Details page, unit prices, quantities, and item descriptions can be edited in-place while the invoice is in Draft status. Click the value — it becomes an input field. Press Enter or click away to save; press Escape to cancel. The invoice total updates immediately. Line items are locked once the invoice is Sent.
|
||||
|
||||
**Sending an invoice:** Invoice Details → "Send" — emails PDF to customer.
|
||||
|
||||
**Online Payments:** [/Invoices/OnlinePayments](/Invoices/OnlinePayments) — Lists invoices with a shareable payment link the customer can pay without logging in. Requires Stripe Connect to be set up first (see below).
|
||||
|
||||
@@ -634,7 +634,7 @@ app.Use(async (context, next) =>
|
||||
: "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://code.jquery.com https://js.stripe.com";
|
||||
|
||||
var cspConnectSrc = app.Environment.IsDevelopment()
|
||||
? "'self' wss://localhost:* https://cdn.jsdelivr.net https://api.stripe.com" // Allow hot reload WebSocket in dev
|
||||
? "'self' ws://localhost:* wss://localhost:* https://cdn.jsdelivr.net https://api.stripe.com" // Allow hot reload WebSocket in dev (ws:// for browser-refresh, wss:// for SignalR)
|
||||
: "'self' https://cdn.jsdelivr.net https://api.stripe.com";
|
||||
|
||||
context.Response.Headers.Append("Content-Security-Policy",
|
||||
|
||||
@@ -72,14 +72,14 @@
|
||||
<input class="form-check-input" type="radio" name="format" value="xlsx" id="fmt_xlsx" checked />
|
||||
<label class="form-check-label" for="fmt_xlsx">
|
||||
<i class="bi bi-file-earmark-spreadsheet me-1 text-success"></i>
|
||||
Excel (.xlsx) — all sheets in one file
|
||||
Excel (.xlsx) — all sheets in one file
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="format" value="csv" id="fmt_csv" />
|
||||
<label class="form-check-label" for="fmt_csv">
|
||||
<i class="bi bi-file-zip me-1 text-warning"></i>
|
||||
CSV (.zip) — one file per sheet
|
||||
CSV (.zip) — one file per sheet
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,7 +111,7 @@
|
||||
document.getElementById('exportForm').addEventListener('submit', function () {
|
||||
var btn = document.getElementById('exportBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating…';
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating…';
|
||||
// Re-enable after 10s in case browser blocks the download dialog
|
||||
setTimeout(function () {
|
||||
btn.disabled = false;
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<div class="mt-1 text-success" style="font-size:1.6rem;"><i class="bi bi-building"></i></div>
|
||||
<div>
|
||||
<div class="fw-semibold">QuickBooks Desktop</div>
|
||||
<div class="text-muted small">IIF files — import directly via File > Utilities > Import</div>
|
||||
<div class="text-muted small">IIF files — import directly via File > Utilities > Import</div>
|
||||
<div class="mt-2">
|
||||
<span class="badge bg-light text-dark border me-1">customers.iif</span>
|
||||
<span class="badge bg-light text-dark border me-1">invoices_payments.iif</span>
|
||||
@@ -146,7 +146,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Init — mark CSV as selected by default
|
||||
// Init — mark CSV as selected by default
|
||||
document.getElementById('fmt-csv').checked = true;
|
||||
updateFormatCards();
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
document.getElementById('exportForm').addEventListener('submit', function() {
|
||||
const btn = document.getElementById('exportBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating…';
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating…';
|
||||
setTimeout(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-download me-2"></i>Download Export Package'; }, 8000);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.CreateAccountDto
|
||||
@model PowderCoating.Application.DTOs.Accounting.CreateAccountDto
|
||||
@using PowderCoating.Core.Enums
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "New Account";
|
||||
ViewData["PageIcon"] = "bi-journal-plus";
|
||||
ViewData["PageHelpTitle"] = "New Account";
|
||||
ViewData["PageHelpContent"] = "Add a custom account to the Chart of Accounts. Select a Sub-Type first — it auto-sets the Account Type. Use conventional numbering: 1000s = Assets, 2000s = Liabilities, 3000s = Equity, 4000s = Revenue, 5000s = Cost of Goods, 6000s+ = Expenses.";
|
||||
ViewData["PageHelpContent"] = "Add a custom account to the Chart of Accounts. Select a Sub-Type first — it auto-sets the Account Type. Use conventional numbering: 1000s = Assets, 2000s = Liabilities, 3000s = Equity, 4000s = Revenue, 5000s = Cost of Goods, 6000s+ = Expenses.";
|
||||
bool isInline = ViewBag.Inline == true;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Account Number"
|
||||
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique. Sub-accounts can use decimals (e.g. 6100.1).">
|
||||
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique. Sub-accounts can use decimals (e.g. 6100.1).">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="AccountType" asp-items="ViewBag.AccountTypes" class="form-select" id="accountTypeSelect">
|
||||
<option value="">— Select Type —</option>
|
||||
<option value="">— Select Type —</option>
|
||||
</select>
|
||||
<span asp-validation-for="AccountType" class="text-danger small"></span>
|
||||
</div>
|
||||
@@ -70,7 +70,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="AccountSubType" asp-items="ViewBag.AccountSubTypes" class="form-select" id="accountSubTypeSelect">
|
||||
<option value="">— Select Sub-Type —</option>
|
||||
<option value="">— Select Sub-Type —</option>
|
||||
</select>
|
||||
<span asp-validation-for="AccountSubType" class="text-danger small"></span>
|
||||
<div class="form-text text-primary" id="typeAutoSetHint" style="display:none">
|
||||
@@ -89,12 +89,12 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Parent Account"
|
||||
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
|
||||
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="ParentAccountId" asp-items="ViewBag.ParentAccounts" class="form-select">
|
||||
<option value="">— None (top-level account) —</option>
|
||||
<option value="">— None (top-level account) —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// SubType enum values → AccountType enum values (mirrors server-side mapping)
|
||||
// SubType enum values â†' AccountType enum values (mirrors server-side mapping)
|
||||
const subTypeToAccountType = {
|
||||
8: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Assets
|
||||
10: 2, 11: 2, 12: 2, 13: 2, // Liabilities
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.EditAccountDto
|
||||
@model PowderCoating.Application.DTOs.Accounting.EditAccountDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Account";
|
||||
ViewData["PageIcon"] = "bi-pencil-square";
|
||||
ViewData["PageHelpTitle"] = "Edit Account";
|
||||
ViewData["PageHelpContent"] = "You can change number, name, type, sub-type, parent, and opening balance. Changing the account type or sub-type on an account that already has transactions is allowed but use caution — it changes how balances are reported going forward. Inactive accounts are hidden from pickers but preserved in history.";
|
||||
ViewData["PageHelpContent"] = "You can change number, name, type, sub-type, parent, and opening balance. Changing the account type or sub-type on an account that already has transactions is allowed but use caution — it changes how balances are reported going forward. Inactive accounts are hidden from pickers but preserved in history.";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-start mb-4">
|
||||
@@ -27,7 +27,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Account Number"
|
||||
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique.">
|
||||
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -81,12 +81,12 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Parent Account"
|
||||
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
|
||||
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="ParentAccountId" asp-items="ViewBag.ParentAccounts" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<option value="">— None —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Bootstrap toast — confirmation before recalculating balances *@
|
||||
@* Bootstrap toast — confirmation before recalculating balances *@
|
||||
<div class="toast-container position-fixed top-50 start-50 translate-middle p-3" style="z-index:1100">
|
||||
<div id="recalcConfirmToast" class="toast align-items-center border-0 bg-dark text-white" role="alert" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="toast-body d-flex flex-column gap-2 py-3 px-3">
|
||||
@@ -153,7 +153,7 @@
|
||||
<span class="fw-medium">@acct.Name</span>
|
||||
@if (acct.IsSystem)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1" title="System account — cannot be deleted">sys</span>
|
||||
<span class="badge bg-secondary ms-1" title="System account — cannot be deleted">sys</span>
|
||||
}
|
||||
</td>
|
||||
<td><span class="text-muted small">@acct.AccountSubType.ToDisplayName()</span></td>
|
||||
@@ -186,7 +186,7 @@
|
||||
@if (!acct.IsSystem)
|
||||
{
|
||||
<form asp-action="Delete" asp-route-id="@acct.Id" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Delete account @acct.AccountNumber – @acct.Name?')">
|
||||
onsubmit="return confirm('Delete account @acct.AccountNumber – @acct.Name?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
@@ -219,7 +219,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Recalculate Balances — show confirmation toast instead of native confirm()
|
||||
// Recalculate Balances — show confirmation toast instead of native confirm()
|
||||
const recalcToast = new bootstrap.Toast(document.getElementById('recalcConfirmToast'));
|
||||
|
||||
document.getElementById('btnRecalcBalances').addEventListener('click', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@using PowderCoating.Core.Enums
|
||||
|
||||
@{
|
||||
ViewData["Title"] = $"Ledger — {Model.AccountNumber} {Model.Name}";
|
||||
ViewData["Title"] = $"Ledger — {Model.AccountNumber} {Model.Name}";
|
||||
ViewData["PageIcon"] = "bi-journal-text";
|
||||
ViewData["PageHelpTitle"] = "Account Ledger";
|
||||
ViewData["PageHelpContent"] = "A chronological list of every transaction posted to this account. Click any Reference to open the source record. Debit increases asset and expense accounts; credit increases liability, equity, and revenue accounts. Use the date range or quick buttons (This Month, YTD, etc.) to narrow the view.";
|
||||
@@ -60,7 +60,7 @@
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a asp-action="Index">Chart of Accounts</a></li>
|
||||
<li class="breadcrumb-item active">@Model.AccountNumber – @Model.Name</li>
|
||||
<li class="breadcrumb-item active">@Model.AccountNumber – @Model.Name</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
<i class="bi bi-journal-text me-1"></i>
|
||||
Transactions
|
||||
<span class="text-muted fw-normal small ms-1">
|
||||
@Model.From.ToString("MMM d") – @Model.To.ToString("MMM d, yyyy")
|
||||
@Model.From.ToString("MMM d") – @Model.To.ToString("MMM d, yyyy")
|
||||
</span>
|
||||
</span>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
@@ -205,7 +205,7 @@
|
||||
<!-- Opening balance row -->
|
||||
<tr class="table-light">
|
||||
<td class="text-muted small">@Model.From.ToString("MM/dd/yyyy")</td>
|
||||
<td><span class="fw-medium text-muted">—</span></td>
|
||||
<td><span class="fw-medium text-muted">—</span></td>
|
||||
<td><span class="badge bg-dark-subtle text-dark">Opening Balance</span></td>
|
||||
<td class="text-muted small">Balance brought forward as of @Model.From.ToString("MMM d, yyyy")</td>
|
||||
<td></td>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using PowderCoating.Core.Entities
|
||||
@using PowderCoating.Core.Entities
|
||||
@{
|
||||
ViewData["Title"] = "Year-End Close";
|
||||
ViewData["PageIcon"] = "bi-calendar-check";
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="alert alert-warning alert-permanent py-2 mb-4">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>What this does:</strong> Posts a Journal Entry dated December 31 that zeroes all Revenue
|
||||
and Expense account balances into Retained Earnings — the standard accounting close.
|
||||
and Expense account balances into Retained Earnings — the standard accounting close.
|
||||
Run this <strong>after</strong> all entries for the year are posted and the period is locked.
|
||||
A year can only be closed once.
|
||||
</div>
|
||||
@@ -99,7 +99,7 @@
|
||||
<tr>
|
||||
<td class="fw-bold">@c.ClosedYear</td>
|
||||
<td>@c.ClosedAt.ToLocalTime().ToString("MM/dd/yyyy h:mm tt")</td>
|
||||
<td>@(c.ClosedBy ?? "—")</td>
|
||||
<td>@(c.ClosedBy ?? "—")</td>
|
||||
<td>
|
||||
@if (c.JournalEntry != null)
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="fs-3 fw-bold">@Model.TotalCallsLast30Days.ToString("N0")</div>
|
||||
<div class="text-muted small">AI Calls — Last 30 Days</div>
|
||||
<div class="text-muted small">AI Calls — Last 30 Days</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,11 +96,11 @@
|
||||
<!-- Tier Legend -->
|
||||
<div class="d-flex flex-wrap gap-2 mb-3 align-items-center">
|
||||
<span class="text-muted small fw-semibold me-1">Usage Tier (last 30 days):</span>
|
||||
<span class="badge bg-secondary">Inactive — 0 calls</span>
|
||||
<span class="badge bg-success">Light — 1–10</span>
|
||||
<span class="badge bg-primary">Regular — 11–50</span>
|
||||
<span class="badge bg-warning text-dark">Heavy — 51–200</span>
|
||||
<span class="badge bg-danger">Power User — 200+</span>
|
||||
<span class="badge bg-secondary">Inactive — 0 calls</span>
|
||||
<span class="badge bg-success">Light — 1–10</span>
|
||||
<span class="badge bg-primary">Regular — 11–50</span>
|
||||
<span class="badge bg-warning text-dark">Heavy — 51–200</span>
|
||||
<span class="badge bg-danger">Power User — 200+</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Table -->
|
||||
@@ -207,16 +207,16 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center @(row.Today > 0 ? "fw-semibold" : "text-muted")">
|
||||
@(row.Today > 0 ? row.Today.ToString("N0") : "—")
|
||||
@(row.Today > 0 ? row.Today.ToString("N0") : "—")
|
||||
</td>
|
||||
<td class="text-center @(row.Last7Days > 0 ? "fw-semibold" : "text-muted")">
|
||||
@(row.Last7Days > 0 ? row.Last7Days.ToString("N0") : "—")
|
||||
@(row.Last7Days > 0 ? row.Last7Days.ToString("N0") : "—")
|
||||
</td>
|
||||
<td class="text-center @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
|
||||
@(row.Last30Days > 0 ? row.Last30Days.ToString("N0") : "—")
|
||||
@(row.Last30Days > 0 ? row.Last30Days.ToString("N0") : "—")
|
||||
</td>
|
||||
<td class="text-center @(row.AllTime > 0 ? "" : "text-muted")">
|
||||
@(row.AllTime > 0 ? row.AllTime.ToString("N0") : "—")
|
||||
@(row.AllTime > 0 ? row.AllTime.ToString("N0") : "—")
|
||||
</td>
|
||||
<td class="text-center @(row.PhotoCount > 0 ? "" : "text-muted")">
|
||||
@if (row.PhotoCount > 0)
|
||||
@@ -225,7 +225,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>—</span>
|
||||
<span>—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@@ -244,7 +244,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
onclick="window.location='@Url.Action("Edit", new { id = a.Id })'">
|
||||
<td>
|
||||
<div class="fw-medium">@a.Title</div>
|
||||
<small class="text-muted">@a.Message.Substring(0, Math.Min(60, a.Message.Length))@(a.Message.Length > 60 ? "…" : "")</small>
|
||||
<small class="text-muted">@a.Message.Substring(0, Math.Min(60, a.Message.Length))@(a.Message.Length > 60 ? "…" : "")</small>
|
||||
</td>
|
||||
<td><span class="badge @TypeBadge(a.Type)">@a.Type</span></td>
|
||||
<td>
|
||||
@@ -110,7 +110,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Mobile card view — shown on screens < 992px -->
|
||||
<!-- Mobile card view — shown on screens < 992px -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@if (!Model.Any())
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Message <span class="text-danger">*</span></label>
|
||||
<textarea asp-for="Message" class="form-control" rows="3"
|
||||
placeholder="The platform will be offline for maintenance on Saturday from 2–4 AM ET." required></textarea>
|
||||
placeholder="The platform will be offline for maintenance on Saturday from 2–4 AM ET." required></textarea>
|
||||
<span asp-validation-for="Message" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<div id="planTargetGroup" style="display:none">
|
||||
<label class="form-label fw-medium">Plan</label>
|
||||
<select asp-for="TargetPlan" class="form-select">
|
||||
<option value="">— select —</option>
|
||||
<option value="">— select —</option>
|
||||
@foreach (var p in planConfigs)
|
||||
{
|
||||
<option value="@p.Plan">@p.DisplayName</option>
|
||||
@@ -52,7 +52,7 @@
|
||||
<div id="companyTargetGroup" style="display:none">
|
||||
<label class="form-label fw-medium">Company</label>
|
||||
<select asp-for="TargetCompanyId" class="form-select">
|
||||
<option value="">— select —</option>
|
||||
<option value="">— select —</option>
|
||||
@foreach (var c in companies)
|
||||
{
|
||||
<option value="@c.Id">@c.CompanyName</option>
|
||||
@@ -93,7 +93,7 @@
|
||||
<label class="form-label fw-medium text-muted">Preview</label>
|
||||
<div id="announcementPreview" class="alert mb-0" role="alert">
|
||||
<strong id="previewTitle">@Model.Title</strong>
|
||||
<span id="previewMessage"> — @Model.Message</span>
|
||||
<span id="previewMessage"> — @Model.Message</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@
|
||||
const preview = document.getElementById('announcementPreview');
|
||||
preview.className = 'alert mb-0 ' + (typeMap[type] || 'alert-info');
|
||||
document.getElementById('previewTitle').textContent = document.getElementById('Title').value || 'Title';
|
||||
document.getElementById('previewMessage').textContent = ' — ' + (document.getElementById('Message').value || 'Message');
|
||||
document.getElementById('previewMessage').textContent = ' — ' + (document.getElementById('Message').value || 'Message');
|
||||
}
|
||||
document.getElementById('Type')?.addEventListener('change', updatePreview);
|
||||
document.getElementById('Title')?.addEventListener('input', updatePreview);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.Appointment.CreateAppointmentDto
|
||||
@model PowderCoating.Application.DTOs.Appointment.CreateAppointmentDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "New Appointment";
|
||||
ViewData["PageIcon"] = "bi-calendar-plus";
|
||||
ViewData["PageHelpTitle"] = "New Appointment";
|
||||
ViewData["PageHelpContent"] = "Create an appointment to schedule a customer visit, drop-off, pick-up, or consultation. Select the Type first — the Linked Job field appears once a type is chosen. Reminder notifications fire before the scheduled start time.";
|
||||
ViewData["PageHelpContent"] = "Create an appointment to schedule a customer visit, drop-off, pick-up, or consultation. Select the Type first — the Linked Job field appears once a type is chosen. Reminder notifications fire before the scheduled start time.";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-end align-items-center mb-4">
|
||||
@@ -143,7 +143,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Reminder Settings"
|
||||
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance — e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
|
||||
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance — e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.Appointment.AppointmentDto
|
||||
@model PowderCoating.Application.DTOs.Appointment.AppointmentDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = $"Appointment {Model.AppointmentNumber}";
|
||||
ViewData["PageIcon"] = "bi-calendar-event";
|
||||
ViewData["PageHelpTitle"] = "Appointment Details";
|
||||
ViewData["PageHelpContent"] = "View all details for this appointment. Edit to update status or record actual times. Deleting permanently removes the record — consider setting status to Cancelled instead to preserve history.";
|
||||
ViewData["PageHelpContent"] = "View all details for this appointment. Edit to update status or record actual times. Deleting permanently removes the record — consider setting status to Cancelled instead to preserve history.";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mb-4">
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Actual Times"
|
||||
data-bs-content="Record when the customer actually arrived and when the appointment finished. These are optional and separate from the scheduled times — useful for tracking punctuality and measuring how accurately appointments are estimated.">
|
||||
data-bs-content="Record when the customer actually arrived and when the appointment finished. These are optional and separate from the scheduled times — useful for tracking punctuality and measuring how accurately appointments are estimated.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -169,7 +169,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Reminder Settings"
|
||||
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance — e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
|
||||
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance — e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -46,19 +46,19 @@
|
||||
<dd class="col-7">@Model.EntityType</dd>
|
||||
|
||||
<dt class="col-5 text-muted">Entity ID</dt>
|
||||
<dd class="col-7">@(Model.EntityId ?? "—")</dd>
|
||||
<dd class="col-7">@(Model.EntityId ?? "—")</dd>
|
||||
|
||||
<dt class="col-5 text-muted">Description</dt>
|
||||
<dd class="col-7">@(Model.EntityDescription ?? "—")</dd>
|
||||
<dd class="col-7">@(Model.EntityDescription ?? "—")</dd>
|
||||
|
||||
<dt class="col-5 text-muted">User</dt>
|
||||
<dd class="col-7">@Model.UserName</dd>
|
||||
|
||||
<dt class="col-5 text-muted">Company</dt>
|
||||
<dd class="col-7">@(Model.CompanyName ?? (Model.CompanyId?.ToString() ?? "—"))</dd>
|
||||
<dd class="col-7">@(Model.CompanyName ?? (Model.CompanyId?.ToString() ?? "—"))</dd>
|
||||
|
||||
<dt class="col-5 text-muted">IP Address</dt>
|
||||
<dd class="col-7">@(Model.IpAddress ?? "—")</dd>
|
||||
<dd class="col-7">@(Model.IpAddress ?? "—")</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,8 +95,8 @@
|
||||
var newVal = newData.ValueKind == JsonValueKind.Object && newData.TryGetProperty(key, out var nv) ? nv.ToString() : null;
|
||||
<tr>
|
||||
<td class="fw-medium">@key</td>
|
||||
<td class="text-danger font-monospace">@(oldVal ?? "—")</td>
|
||||
<td class="text-success font-monospace">@(newVal ?? "—")</td>
|
||||
<td class="text-danger font-monospace">@(oldVal ?? "—")</td>
|
||||
<td class="text-success font-monospace">@(newVal ?? "—")</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<input name="search" value="@ViewBag.Search" class="form-control form-control-sm"
|
||||
placeholder="User, entity name, ID…" />
|
||||
placeholder="User, entity name, ID…" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="entityType" class="form-select form-select-sm">
|
||||
@@ -145,7 +145,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card view — shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
|
||||
<!-- Mobile card view — shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@if (!Model.Any())
|
||||
@@ -196,7 +196,7 @@
|
||||
{
|
||||
<div class="card-footer d-flex align-items-center justify-content-between py-2">
|
||||
<small class="text-muted">
|
||||
Showing @((page - 1) * pageSize + 1)–@Math.Min(page * pageSize, totalCount) of @totalCount.ToString("N0")
|
||||
Showing @((page - 1) * pageSize + 1)–@Math.Min(page * pageSize, totalCount) of @totalCount.ToString("N0")
|
||||
</small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Bank Account <span class="text-danger">*</span></label>
|
||||
<select asp-for="AccountId" asp-items="accounts" class="form-select" required>
|
||||
<option value="">— select account —</option>
|
||||
<option value="">— select account —</option>
|
||||
</select>
|
||||
<div class="form-text">Only Checking, Savings, and Cash accounts are listed.</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="card shadow-sm py-3">
|
||||
<div class="fw-bold fs-5" id="difference">—</div>
|
||||
<div class="fw-bold fs-5" id="difference">—</div>
|
||||
<div class="text-muted small">Difference</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,7 +212,7 @@
|
||||
document.getElementById('aiMatchBtn')?.addEventListener('click', async function() {
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing…';
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing…';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/BankReconciliations/AiSuggestMatches', {
|
||||
@@ -244,7 +244,7 @@
|
||||
const td = row.querySelector('td:last-child');
|
||||
if (td) {
|
||||
const pct = Math.round(s.confidence * 100);
|
||||
td.insertAdjacentHTML('afterend', `<td class="small text-info" style="white-space:nowrap">${pct}% — ${s.reason}</td>`);
|
||||
td.insertAdjacentHTML('afterend', `<td class="small text-info" style="white-space:nowrap">${pct}% — ${s.reason}</td>`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -256,7 +256,7 @@
|
||||
if (aiSuggestions.length > 0) {
|
||||
document.getElementById('aiMatchAccept').classList.remove('d-none');
|
||||
} else {
|
||||
insightsEl.innerHTML += '<br><span class="text-muted">No high-confidence suggestions found — review items manually.</span>';
|
||||
insightsEl.innerHTML += '<br><span class="text-muted">No high-confidence suggestions found — review items manually.</span>';
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('aiMatchInsights').innerHTML = '<span class="text-danger">Error contacting AI service.</span>';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@model PowderCoating.Core.Entities.BankReconciliation
|
||||
@using PowderCoating.Web.Controllers
|
||||
@{
|
||||
ViewData["Title"] = $"Reconciliation Report – {Model.Account?.Name}";
|
||||
ViewData["Title"] = $"Reconciliation Report – {Model.Account?.Name}";
|
||||
var clearedDeposits = ViewBag.ClearedDeposits as IEnumerable<PowderCoating.Core.Entities.Payment> ?? Enumerable.Empty<PowderCoating.Core.Entities.Payment>();
|
||||
var clearedPayments = ViewBag.ClearedPayments as List<ReconciliationItem> ?? new();
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
<td class="fw-semibold text-end text-success">@clearedDeposits.Sum(p => p.Amount).ToString("C")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">– Cleared Payments:</td>
|
||||
<td class="text-muted">– Cleared Payments:</td>
|
||||
<td class="fw-semibold text-end text-danger">@clearedPayments.Sum(p => p.Amount).ToString("C")</td>
|
||||
</tr>
|
||||
<tr class="border-top">
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
{
|
||||
<tr class="text-muted">
|
||||
<td><code>@ban.IpAddress</code></td>
|
||||
<td><small>@(ban.Reason ?? "—")</small></td>
|
||||
<td><small>@(ban.Reason ?? "—")</small></td>
|
||||
<td><small>@ban.BannedAt.ToString("MMM dd, yyyy")</small></td>
|
||||
<td>
|
||||
@if (!ban.IsActive)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using PowderCoating.Application.DTOs.Subscription
|
||||
@using PowderCoating.Application.DTOs.Subscription
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = "Billing & Subscription";
|
||||
@@ -136,7 +136,7 @@ else if (status.IsExpired)
|
||||
}
|
||||
else if (status.IsGracePeriod)
|
||||
{
|
||||
<span class="text-warning">Grace period — expired @status.EndDate.Value.ToString("MMM d, yyyy")</span>
|
||||
<span class="text-warning">Grace period — expired @status.EndDate.Value.ToString("MMM d, yyyy")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -266,7 +266,7 @@ else if (status.IsExpired)
|
||||
<strong>Cancellation & Refund Policy:</strong>
|
||||
You may cancel your subscription at any time from this page or by contacting
|
||||
<a href="mailto:support@powdercoatinglogix.com">support@powdercoatinglogix.com</a>.
|
||||
Cancellation takes effect at the end of your current billing period — you retain full access until then.
|
||||
Cancellation takes effect at the end of your current billing period — you retain full access until then.
|
||||
All fees are <strong>non-refundable</strong>; unused time is not credited back.
|
||||
See our <a asp-controller="Home" asp-action="TermsOfService" asp-fragment="section-5" target="_blank">full billing terms</a> for details.
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
ViewData["Title"] = "New Bill";
|
||||
ViewData["PageIcon"] = "bi-receipt-cutoff";
|
||||
ViewData["PageHelpTitle"] = "New Bill";
|
||||
ViewData["PageHelpContent"] = "Record a vendor invoice to track what you owe. Bills start as Draft (editable) and become Open once confirmed. Partial payments are supported — each payment reduces the balance. Link line items to expense accounts and optionally to specific jobs for cost tracking.";
|
||||
ViewData["PageHelpContent"] = "Record a vendor invoice to track what you owe. Bills start as Draft (editable) and become Open once confirmed. Partial payments are supported — each payment reduces the balance. Link line items to expense accounts and optionally to specific jobs for cost tracking.";
|
||||
string? fromPoNumber = ViewBag.FromPoNumber as string;
|
||||
int? fromPoId = ViewBag.FromPoId as int?;
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
<div>
|
||||
@if (!string.IsNullOrEmpty(fromPoNumber))
|
||||
{
|
||||
<p class="text-muted mb-0 small"><i class="bi bi-box-arrow-in-down text-success me-1"></i> Pre-filled from <strong>@fromPoNumber</strong> — review and save</p>
|
||||
<p class="text-muted mb-0 small"><i class="bi bi-box-arrow-in-down text-success me-1"></i> Pre-filled from <strong>@fromPoNumber</strong> — review and save</p>
|
||||
}
|
||||
</div>
|
||||
@if (fromPoId.HasValue)
|
||||
@@ -44,7 +44,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Details"
|
||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation. Payment Terms auto-fill from the vendor record.">
|
||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation. Payment Terms auto-fill from the vendor record.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -66,8 +66,8 @@
|
||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
|
||||
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select" id="vendorSelect"
|
||||
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
||||
<option value="">— Select Vendor —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
<option value="">— Select Vendor —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
</select>
|
||||
<span asp-validation-for="VendorId" class="text-danger small"></span>
|
||||
</div>
|
||||
@@ -100,7 +100,7 @@
|
||||
<label for="receiptFile" class="form-label fw-medium">Attach Receipt / Document</label>
|
||||
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,7 +150,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Summary"
|
||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -198,7 +198,7 @@
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-medium">Bank / Cash Account <span class="text-danger">*</span></label>
|
||||
<select name="bankAccountId" class="form-select form-select-sm" id="payNowBankAccount">
|
||||
<option value="">— Select Account —</option>
|
||||
<option value="">— Select Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.BankAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
@@ -232,7 +232,7 @@
|
||||
<tr class="line-item-row">
|
||||
<td>
|
||||
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
|
||||
<option value="">— Account —</option>
|
||||
<option value="">— Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
@@ -242,7 +242,7 @@
|
||||
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
|
||||
<option value="">—</option>
|
||||
<option value="">—</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
@@ -273,7 +273,7 @@
|
||||
<div class="mb-3">
|
||||
<label for="scanReceiptFile" class="form-label fw-medium">Receipt / Invoice Document</label>
|
||||
<input type="file" id="scanReceiptFile" class="form-control" accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
</div>
|
||||
<div id="scanReceiptStatus" class="text-muted small mt-2"></div>
|
||||
</div>
|
||||
@@ -394,7 +394,7 @@
|
||||
if (lineCount === 0) addLineItem();
|
||||
|
||||
// ── AI Auto-suggest Account on description blur ───────────────────────
|
||||
// Keyword shortcuts — handle common cases with zero API cost
|
||||
// Keyword shortcuts — handle common cases with zero API cost
|
||||
const _keywordMap = [
|
||||
{ words: ['electric','power','utility','gas','water','internet','phone','telecom'], hint: 'utilities' },
|
||||
{ words: ['powder','paint','coat','material','supply','supplies','chemical','resin'], hint: 'materials' },
|
||||
@@ -480,7 +480,7 @@
|
||||
hint2.className = 'ai-account-hint text-muted small mt-1';
|
||||
accountSel.parentNode.appendChild(hint2);
|
||||
}
|
||||
hint2.innerHTML = '<span class="spinner-border spinner-border-sm" style="width:.75rem;height:.75rem"></span> Thinking…';
|
||||
hint2.innerHTML = '<span class="spinner-border spinner-border-sm" style="width:.75rem;height:.75rem"></span> Thinking…';
|
||||
|
||||
try {
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
@@ -501,7 +501,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Event delegation — works for dynamically added rows
|
||||
// Event delegation — works for dynamically added rows
|
||||
document.getElementById('lineItemsBody').addEventListener('blur', function (e) {
|
||||
if (e.target.matches('[name$=".Description"]')) {
|
||||
_suggestAccountForRow(e.target.closest('tr'));
|
||||
@@ -535,7 +535,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-fill bill header — try to match vendor name to dropdown
|
||||
// Auto-fill bill header — try to match vendor name to dropdown
|
||||
if (data.vendorName) {
|
||||
const vendorSel = document.getElementById('vendorSelect');
|
||||
if (vendorSel && !vendorSel.value) {
|
||||
@@ -553,7 +553,7 @@
|
||||
vendorSel.value = bestOption.value;
|
||||
vendorSel.dispatchEvent(new Event('change'));
|
||||
} else {
|
||||
// No match — put the name in Memo so user knows what the AI saw
|
||||
// No match — put the name in Memo so user knows what the AI saw
|
||||
const memo = document.querySelector('[name="Memo"]');
|
||||
if (memo && !memo.value) memo.value = data.vendorName;
|
||||
}
|
||||
@@ -598,7 +598,7 @@
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('scanReceiptModal'));
|
||||
if (modal) modal.hide();
|
||||
|
||||
statusEl.textContent = 'Scan complete — review and adjust as needed.';
|
||||
statusEl.textContent = 'Scan complete — review and adjust as needed.';
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Error connecting to AI service.';
|
||||
} finally {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
ViewData["Title"] = $"Bill {Model.BillNumber}";
|
||||
ViewData["PageIcon"] = "bi-receipt-cutoff";
|
||||
ViewData["PageHelpTitle"] = "Bill Status";
|
||||
ViewData["PageHelpContent"] = "Draft: editable, not yet confirmed. Open: awaiting payment. Partially Paid: some payments recorded. Paid: fully settled. Voided: cancelled — preserves history. Edit is only available in Draft status. Use Void instead of deleting to keep a complete audit trail.";
|
||||
ViewData["PageHelpContent"] = "Draft: editable, not yet confirmed. Open: awaiting payment. Partially Paid: some payments recorded. Paid: fully settled. Voided: cancelled — preserves history. Edit is only available in Draft status. Use Void instead of deleting to keep a complete audit trail.";
|
||||
|
||||
string StatusBadge(BillStatus s) => s switch
|
||||
{
|
||||
@@ -266,7 +266,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Balance Summary"
|
||||
data-bs-content="Balance Due = Bill Total minus all payments recorded. Multiple partial payments are supported — each reduces the balance until fully paid. Deleting a payment reverses it and restores the balance due.">
|
||||
data-bs-content="Balance Due = Bill Total minus all payments recorded. Multiple partial payments are supported — each reduces the balance until fully paid. Deleting a payment reverses it and restores the balance due.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -322,7 +322,7 @@
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Record Payment — @Model.BillNumber</h5>
|
||||
<h5 class="modal-title">Record Payment — @Model.BillNumber</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form asp-action="RecordPayment" method="post">
|
||||
@@ -358,12 +358,12 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Bank Account"
|
||||
data-bs-content="The bank or cash account this payment is drawn from. Used for bank reconciliation — helps match this payment to the corresponding debit on your bank statement.">
|
||||
data-bs-content="The bank or cash account this payment is drawn from. Used for bank reconciliation — helps match this payment to the corresponding debit on your bank statement.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select name="BankAccountId" class="form-select" required>
|
||||
<option value="">— Select Account —</option>
|
||||
<option value="">— Select Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.BankAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
@@ -423,7 +423,7 @@
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Bank Account <span class="text-danger">*</span></label>
|
||||
<select name="BankAccountId" id="editBillBankAccountId" class="form-select" required>
|
||||
<option value="">— Select Account —</option>
|
||||
<option value="">— Select Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.BankAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.EditBillDto
|
||||
@model PowderCoating.Application.DTOs.Accounting.EditBillDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Bill";
|
||||
ViewData["PageIcon"] = "bi-pencil-square";
|
||||
ViewData["PageHelpTitle"] = "Edit Bill";
|
||||
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked — Void the bill and recreate it if corrections are needed after confirmation.";
|
||||
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked — Void the bill and recreate it if corrections are needed after confirmation.";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-start mb-4">
|
||||
@@ -24,7 +24,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Details"
|
||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
|
||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -34,8 +34,8 @@
|
||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
|
||||
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
|
||||
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
||||
<option value="">— Select Vendor —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
<option value="">— Select Vendor —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
@@ -87,7 +87,7 @@
|
||||
}
|
||||
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,7 +134,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Summary"
|
||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -171,7 +171,7 @@
|
||||
<tr class="line-item-row">
|
||||
<td>
|
||||
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
|
||||
<option value="">— Account —</option>
|
||||
<option value="">— Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
@@ -181,7 +181,7 @@
|
||||
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
|
||||
<option value="">—</option>
|
||||
<option value="">—</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model List<PowderCoating.Application.DTOs.Accounting.BillExpenseListDto>
|
||||
@model List<PowderCoating.Application.DTOs.Accounting.BillExpenseListDto>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Bills / Expenses";
|
||||
@@ -62,7 +62,7 @@
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<input type="search" name="search" value="@ViewBag.Search" class="form-control"
|
||||
placeholder="Search by #, vendor, memo, amount…" />
|
||||
placeholder="Search by #, vendor, memo, amount…" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="type" class="form-select">
|
||||
@@ -156,13 +156,13 @@
|
||||
}
|
||||
else if (entry.EntryType == "Expense")
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</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 fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")">
|
||||
@(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "—")
|
||||
@(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "—")
|
||||
</td>
|
||||
<td>
|
||||
@if (entry.EntryType == "Bill")
|
||||
@@ -209,7 +209,7 @@ else
|
||||
asp-route-status="@ViewBag.StatusFilter"
|
||||
asp-route-search="@ViewBag.Search"
|
||||
asp-route-page="@((int)ViewBag.Page - 1)"
|
||||
asp-route-pageSize="@ViewBag.PageSize">‹ Prev</a>
|
||||
asp-route-pageSize="@ViewBag.PageSize">‹ Prev</a>
|
||||
</li>
|
||||
@for (var p = 1; p <= (int)ViewBag.TotalPages; p++)
|
||||
{
|
||||
@@ -228,11 +228,11 @@ else
|
||||
asp-route-status="@ViewBag.StatusFilter"
|
||||
asp-route-search="@ViewBag.Search"
|
||||
asp-route-page="@((int)ViewBag.Page + 1)"
|
||||
asp-route-pageSize="@ViewBag.PageSize">Next ›</a>
|
||||
asp-route-pageSize="@ViewBag.PageSize">Next ›</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted small">
|
||||
Showing @(((int)ViewBag.Page - 1) * (int)ViewBag.PageSize + 1)–@(Math.Min((int)ViewBag.Page * (int)ViewBag.PageSize, (int)ViewBag.TotalCount))
|
||||
Showing @(((int)ViewBag.Page - 1) * (int)ViewBag.PageSize + 1)–@(Math.Min((int)ViewBag.Page * (int)ViewBag.PageSize, (int)ViewBag.TotalCount))
|
||||
of @ViewBag.TotalCount entries
|
||||
</p>
|
||||
</nav>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<div id="resultArea" class="d-none">
|
||||
<div id="spinnerArea" class="text-center py-5 d-none">
|
||||
<div class="spinner-border text-primary" style="width:2.5rem;height:2.5rem;" role="status"></div>
|
||||
<p class="text-muted mt-3">Claude is reviewing your bill history…</p>
|
||||
<p class="text-muted mt-3">Claude is reviewing your bill history…</p>
|
||||
</div>
|
||||
|
||||
<div id="errorArea" class="alert alert-danger alert-permanent d-none"></div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@model BudgetCreateVm
|
||||
|
||||
@{
|
||||
ViewData["Title"] = $"Edit Budget — {Model.Name}";
|
||||
ViewData["Title"] = $"Edit Budget — {Model.Name}";
|
||||
ViewData["PageIcon"] = "bi-pencil";
|
||||
var months = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-pie-chart me-2 text-primary"></i>@Model.Name — @Model.FiscalYear
|
||||
<i class="bi bi-pie-chart me-2 text-primary"></i>@Model.Name — @Model.FiscalYear
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
@@ -78,7 +78,7 @@ else
|
||||
</td>
|
||||
<td class="text-center">@b.Lines.Count</td>
|
||||
<td class="text-end text-success">@b.Lines.Sum(l => l.Annual).ToString("C")</td>
|
||||
<td class="text-end text-danger">—</td>
|
||||
<td class="text-end text-danger">—</td>
|
||||
<td class="text-center">
|
||||
@if (b.IsDefault)
|
||||
{
|
||||
|
||||
@@ -246,7 +246,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card view — shown on screens < 992px -->
|
||||
<!-- Mobile card view — shown on screens < 992px -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var report in Model)
|
||||
@@ -313,7 +313,7 @@
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
|
||||
<small class="text-muted">
|
||||
Showing @((pageNumber - 1) * pageSize + 1)–@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount
|
||||
Showing @((pageNumber - 1) * pageSize + 1)–@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount
|
||||
</small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.BugReport.CreateBugReportDto
|
||||
@model PowderCoating.Application.DTOs.BugReport.CreateBugReportDto
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = "Report a Bug";
|
||||
@@ -59,10 +59,10 @@
|
||||
<div class="mb-4">
|
||||
<label asp-for="Priority" class="form-label fw-semibold">Priority</label>
|
||||
<select asp-for="Priority" class="form-select">
|
||||
<option value="@((int)BugReportPriority.Low)">Low – Minor inconvenience, workaround exists</option>
|
||||
<option value="@((int)BugReportPriority.Normal)" selected>Normal – Affects workflow but not critical</option>
|
||||
<option value="@((int)BugReportPriority.High)">High – Significantly impacts operations</option>
|
||||
<option value="@((int)BugReportPriority.Critical)">Critical – System unusable or data loss risk</option>
|
||||
<option value="@((int)BugReportPriority.Low)">Low – Minor inconvenience, workaround exists</option>
|
||||
<option value="@((int)BugReportPriority.Normal)" selected>Normal – Affects workflow but not critical</option>
|
||||
<option value="@((int)BugReportPriority.High)">High – Significantly impacts operations</option>
|
||||
<option value="@((int)BugReportPriority.Critical)">Critical – System unusable or data loss risk</option>
|
||||
</select>
|
||||
<span asp-validation-for="Priority" class="text-danger small"></span>
|
||||
</div>
|
||||
@@ -104,7 +104,7 @@
|
||||
const li = document.createElement('li');
|
||||
const sizeMb = (f.size / 1024 / 1024).toFixed(1);
|
||||
if (f.size > maxBytes) {
|
||||
li.innerHTML = `<i class="bi bi-exclamation-triangle text-danger"></i> ${f.name} (${sizeMb} MB) — exceeds 100 MB limit`;
|
||||
li.innerHTML = `<i class="bi bi-exclamation-triangle text-danger"></i> ${f.name} (${sizeMb} MB) — exceeds 100 MB limit`;
|
||||
} else {
|
||||
li.innerHTML = `<i class="bi bi-file-earmark text-secondary"></i> ${f.name} (${sizeMb} MB)`;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Parent Category"
|
||||
data-bs-content="Leave as '(None)' to create a top-level category. Choose a parent to nest this under it — e.g., place 'Aluminum Wheels' under a parent 'Wheels' category. This creates a browsable hierarchy in the catalog and quote wizard.">
|
||||
data-bs-content="Leave as '(None)' to create a top-level category. Choose a parent to nest this under it — e.g., place 'Aluminum Wheels' under a parent 'Wheels' category. This creates a browsable hierarchy in the catalog and quote wizard.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Parent Category"
|
||||
data-bs-content="Leave as '(None)' to keep this as a top-level category. Choose a parent to nest it — e.g., 'Aluminum Wheels' under 'Wheels'. The system prevents circular references (you cannot make a category its own ancestor).">
|
||||
data-bs-content="Leave as '(None)' to keep this as a top-level category. Choose a parent to nest it — e.g., 'Aluminum Wheels' under 'Wheels'. The system prevents circular references (you cannot make a category its own ancestor).">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
ViewData["Title"] = "AI Catalog Price Check";
|
||||
ViewData["PageIcon"] = "bi-robot";
|
||||
ViewData["PageHelpTitle"] = "AI Catalog Price Check";
|
||||
ViewData["PageHelpContent"] = "The AI Price Check reviews every item in your catalog against your actual operating costs and flags items that may be priced below cost, have thin margins, or appear unusually high. Results are estimates based on industry knowledge and your shop's rates — always apply your own judgment before changing prices.";
|
||||
ViewData["PageHelpContent"] = "The AI Price Check reviews every item in your catalog against your actual operating costs and flags items that may be priced below cost, have thin margins, or appear unusually high. Results are estimates based on industry knowledge and your shop's rates — always apply your own judgment before changing prices.";
|
||||
|
||||
var sortedResults = Model?.Results
|
||||
.OrderBy(r => r.Verdict switch
|
||||
@@ -82,7 +82,7 @@
|
||||
<div class="progress-card">
|
||||
<div class="icon"><i class="bi bi-robot"></i></div>
|
||||
<h5>Analyzing your catalog</h5>
|
||||
<p class="status-msg" id="overlay-status">Preparing items…</p>
|
||||
<p class="status-msg" id="overlay-status">Preparing items…</p>
|
||||
<div class="progress-bar-track">
|
||||
<div class="progress-bar-fill" id="overlay-bar"></div>
|
||||
</div>
|
||||
@@ -151,22 +151,22 @@
|
||||
<div>
|
||||
<h6 class="fw-semibold mb-1">What this analysis does</h6>
|
||||
<p class="small text-muted mb-2">
|
||||
Our AI system reviews every active, priced item in your catalog against your shop's actual operating costs —
|
||||
Our AI system reviews every active, priced item in your catalog against your shop's actual operating costs —
|
||||
labor, oven time, sandblasting, coating booth, and powder material. For each item it estimates a
|
||||
realistic surface area and processing time, calculates a cost floor, then compares that to your
|
||||
current price and returns one of four verdicts:
|
||||
</p>
|
||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||
<span class="verdict-badge verdict-below-cost">Below Cost</span><span class="small text-muted align-self-center">— you're losing money on this item</span>
|
||||
<span class="verdict-badge verdict-below-cost">Below Cost</span><span class="small text-muted align-self-center">— you're losing money on this item</span>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||
<span class="verdict-badge verdict-low">Thin Margin</span><span class="small text-muted align-self-center">— above cost floor but below your target margin</span>
|
||||
<span class="verdict-badge verdict-low">Thin Margin</span><span class="small text-muted align-self-center">— above cost floor but below your target margin</span>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||
<span class="verdict-badge verdict-high">High</span><span class="small text-muted align-self-center">— significantly above typical market rates</span>
|
||||
<span class="verdict-badge verdict-high">High</span><span class="small text-muted align-self-center">— significantly above typical market rates</span>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<span class="verdict-badge verdict-ok">OK</span><span class="small text-muted align-self-center">— price is within a reasonable range</span>
|
||||
<span class="verdict-badge verdict-ok">OK</span><span class="small text-muted align-self-center">— price is within a reasonable range</span>
|
||||
</div>
|
||||
<p class="small text-muted mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-1 text-warning"></i>
|
||||
@@ -301,15 +301,15 @@ else
|
||||
<div class="col-4 text-center">
|
||||
<div class="small text-muted">Suggested</div>
|
||||
<div class="fw-semibold text-primary">
|
||||
@item.SuggestedPriceMin.ToString("C") – @item.SuggestedPriceMax.ToString("C")
|
||||
@item.SuggestedPriceMin.ToString("C") – @item.SuggestedPriceMax.ToString("C")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="small text-muted mb-1">
|
||||
<i class="bi bi-rulers me-1"></i>
|
||||
Est. @item.EstimatedSqFtMin–@item.EstimatedSqFtMax sqft •
|
||||
@item.EstimatedMinutesMin–@item.EstimatedMinutesMax min
|
||||
Est. @item.EstimatedSqFtMin–@item.EstimatedSqFtMax sqft •
|
||||
@item.EstimatedMinutesMin–@item.EstimatedMinutesMax min
|
||||
</div>
|
||||
|
||||
<p class="small mb-1">@item.Reasoning</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-4" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>Catalog item prices are fixed.</strong> The price you enter here is exactly what gets charged when this item is added to a quote or job — no markup, no prep service charges, and no complexity adjustments are added on top. Make sure your price already includes labor, materials, and margin.
|
||||
<strong>Catalog item prices are fixed.</strong> The price you enter here is exactly what gets charged when this item is added to a quote or job — no markup, no prep service charges, and no complexity adjustments are added on top. Make sure your price already includes labor, materials, and margin.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Item Name"
|
||||
data-bs-content="Use a specific, recognizable name. The name appears on quotes, invoices, and in the picker dropdown. Good names include material and size where relevant — e.g., 'Steel Bracket (6in)', '18in Alloy Wheel'.">
|
||||
data-bs-content="Use a specific, recognizable name. The name appears on quotes, invoices, and in the picker dropdown. Good names include material and size where relevant — e.g., 'Steel Bracket (6in)', '18in Alloy Wheel'.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -75,7 +75,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Pricing"
|
||||
data-bs-content="The Default Price is the exact amount charged when this item is added to a quote — no markup, prep services, or complexity adjustments are applied on top. Set the all-in price you want to bill. Approximate Area is optional — if set, it helps estimate powder needed for reporting purposes.">
|
||||
data-bs-content="The Default Price is the exact amount charged when this item is added to a quote — no markup, prep services, or complexity adjustments are applied on top. Set the all-in price you want to bill. Approximate Area is optional — if set, it helps estimate powder needed for reporting purposes.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Default Price"
|
||||
data-bs-content="This is the final price charged to the customer — no markup, prep services, or complexity charges are added on top. Enter the all-in amount you want to bill for this item. Staff can still override it on individual quotes if needed.">
|
||||
data-bs-content="This is the final price charged to the customer — no markup, prep services, or complexity charges are added on top. Enter the all-in amount you want to bill for this item. Staff can still override it on individual quotes if needed.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -122,7 +122,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Financial Accounts"
|
||||
data-bs-content="Links this item to your chart of accounts for accounting exports. Revenue Account is credited when invoiced; COGS Account is debited when materials are consumed. Leave both blank to use the company default accounts — most shops only need to set these for items with special accounting treatment.">
|
||||
data-bs-content="Links this item to your chart of accounts for accounting exports. Revenue Account is credited when invoiced; COGS Account is debited when materials are consumed. Leave both blank to use the company default accounts — most shops only need to set these for items with special accounting treatment.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -140,7 +140,7 @@
|
||||
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
|
||||
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
|
||||
<option value="">(Default COGS account)</option>
|
||||
<option value="__new__">+ Add New Account…</option>
|
||||
<option value="__new__">+ Add New Account…</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Account debited when materials are consumed.</small>
|
||||
</div>
|
||||
@@ -164,7 +164,7 @@
|
||||
<div class="mb-3">
|
||||
<label for="image" class="form-label fw-semibold">Upload Image</label>
|
||||
<input type="file" class="form-control" id="image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewCatalogImage(this)" />
|
||||
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp — max 10 MB. A 200×200 thumbnail is generated automatically.</div>
|
||||
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp — max 10 MB. A 200×200 thumbnail is generated automatically.</div>
|
||||
<div id="imagePreview" class="mt-2 d-none">
|
||||
<img id="imagePreviewImg" src="" alt="Preview" style="max-width:200px;max-height:200px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;" />
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Item Name"
|
||||
data-bs-content="Use a specific, recognizable name. The name appears on quotes, invoices, and in the picker dropdown. Good names include material and size where relevant — e.g., 'Steel Bracket (6in)', '18in Alloy Wheel'.">
|
||||
data-bs-content="Use a specific, recognizable name. The name appears on quotes, invoices, and in the picker dropdown. Good names include material and size where relevant — e.g., 'Steel Bracket (6in)', '18in Alloy Wheel'.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Pricing & Status"
|
||||
data-bs-content="The Default Price is the exact amount charged when this item is added to a quote — no markup, prep services, or complexity adjustments are applied on top. Staff can override it on individual quotes. Set Status to Inactive to hide the item from the picker without deleting it.">
|
||||
data-bs-content="The Default Price is the exact amount charged when this item is added to a quote — no markup, prep services, or complexity adjustments are applied on top. Staff can override it on individual quotes. Set Status to Inactive to hide the item from the picker without deleting it.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -81,7 +81,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Default Price"
|
||||
data-bs-content="This is the final price charged to the customer — no markup, prep services, or complexity charges are added on top. Enter the all-in amount you want to bill for this item. Staff can still override it on individual quotes if needed.">
|
||||
data-bs-content="This is the final price charged to the customer — no markup, prep services, or complexity charges are added on top. Enter the all-in amount you want to bill for this item. Staff can still override it on individual quotes if needed.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -125,7 +125,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Financial Accounts"
|
||||
data-bs-content="Links this item to your chart of accounts for accounting exports. Revenue Account is credited when invoiced; COGS Account is debited when materials are consumed. Leave both blank to use the company default accounts — most shops only need to set these for items with special accounting treatment.">
|
||||
data-bs-content="Links this item to your chart of accounts for accounting exports. Revenue Account is credited when invoiced; COGS Account is debited when materials are consumed. Leave both blank to use the company default accounts — most shops only need to set these for items with special accounting treatment.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -143,7 +143,7 @@
|
||||
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
|
||||
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
|
||||
<option value="">(Default COGS account)</option>
|
||||
<option value="__new__">+ Add New Account…</option>
|
||||
<option value="__new__">+ Add New Account…</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Account debited when materials are consumed.</small>
|
||||
</div>
|
||||
@@ -186,7 +186,7 @@
|
||||
<div class="mb-3">
|
||||
<label for="image" class="form-label fw-semibold">Upload Image</label>
|
||||
<input type="file" class="form-control" id="image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewCatalogImage(this)" />
|
||||
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp — max 10 MB. A 200×200 thumbnail is generated automatically.</div>
|
||||
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp — max 10 MB. A 200×200 thumbnail is generated automatically.</div>
|
||||
<div id="imagePreview" class="mt-2 d-none">
|
||||
<img id="imagePreviewImg" src="" alt="Preview" style="max-width:200px;max-height:200px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;" />
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
ViewData["Title"] = "Product Catalog";
|
||||
ViewData["PageIcon"] = "bi-book";
|
||||
ViewData["PageHelpTitle"] = "Product Catalog";
|
||||
ViewData["PageHelpContent"] = "The Product Catalog is a library of standard items (wheels, brackets, panels, etc.) that your shop regularly quotes and invoices. Each item has a fixed price — when a catalog item is added to a quote or job, that price is used exactly as entered. No markup, no prep services, and no complexity charges are added on top. Organize items into categories to keep the catalog easy to browse.";
|
||||
ViewData["PageHelpContent"] = "The Product Catalog is a library of standard items (wheels, brackets, panels, etc.) that your shop regularly quotes and invoices. Each item has a fixed price — when a catalog item is added to a quote or job, that price is used exactly as entered. No markup, no prep services, and no complexity charges are added on top. Organize items into categories to keep the catalog easy to browse.";
|
||||
var totalItemsCount = ViewBag.TotalItemsCount ?? 0;
|
||||
var activeItemsCount = ViewBag.ActiveItemsCount ?? 0;
|
||||
var averagePrice = ViewBag.AveragePrice ?? 0m;
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
<div class="card-body">
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<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>@(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>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>
|
||||
@@ -174,7 +174,7 @@
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<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>Phone</th><td>@(Model.Phone ?? "—")</td></tr>
|
||||
<tr><th>Phone</th><td>@(Model.Phone ?? "—")</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,7 +283,7 @@
|
||||
}
|
||||
else { <span class="text-muted">N/A</span> }
|
||||
</td>
|
||||
<td>@(user.Department ?? "—")</td>
|
||||
<td>@(user.Department ?? "—")</td>
|
||||
<td>
|
||||
<span class="badge @(user.IsActive ? "bg-success" : "bg-danger")">
|
||||
@(user.IsActive ? "Active" : "Inactive")
|
||||
@@ -527,20 +527,20 @@
|
||||
@{
|
||||
var firstActivity = onboarding.FirstJobCreatedAt ?? onboarding.FirstQuoteCreatedAt;
|
||||
}
|
||||
@(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "—")
|
||||
@(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "—")
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>First Invoice</th>
|
||||
<td>@(onboarding.FirstInvoiceCreatedAt.HasValue ? onboarding.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "—")</td>
|
||||
<td>@(onboarding.FirstInvoiceCreatedAt.HasValue ? onboarding.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "—")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Workflow Completed</th>
|
||||
<td>@(onboarding.FirstWorkflowCompletedAt.HasValue ? onboarding.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "—")</td>
|
||||
<td>@(onboarding.FirstWorkflowCompletedAt.HasValue ? onboarding.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "—")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Widget Dismissed</th>
|
||||
<td>@(onboarding.GuidedActivationDismissedAt.HasValue ? onboarding.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "—")</td>
|
||||
<td>@(onboarding.GuidedActivationDismissedAt.HasValue ? onboarding.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "—")</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -618,7 +618,7 @@
|
||||
|
||||
</div><!-- /tab-content -->
|
||||
|
||||
<!-- Danger Zone (outside tabs — always present) -->
|
||||
<!-- Danger Zone (outside tabs — always present) -->
|
||||
<div class="card shadow-sm border-danger mt-4">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="card-title mb-0 text-danger">
|
||||
@@ -630,7 +630,7 @@
|
||||
<div>
|
||||
<h6 class="mb-1 text-warning-emphasis"><i class="bi bi-fire me-1"></i>Reset All Company Data</h6>
|
||||
<p class="text-muted small mb-0">
|
||||
Permanently deletes all business data — customers, jobs, quotes, invoices, inventory, and more.
|
||||
Permanently deletes all business data — customers, jobs, quotes, invoices, inventory, and more.
|
||||
The company record, users, and settings are preserved. Use this to wipe a migration and start fresh.
|
||||
</p>
|
||||
</div>
|
||||
@@ -646,7 +646,7 @@
|
||||
Permanently deletes the company and everything in it. There is no going back.
|
||||
@if (Model.UserCount > 0)
|
||||
{
|
||||
<br /><strong class="text-danger">This company has @Model.UserCount user(s) — remove them first.</strong>
|
||||
<br /><strong class="text-danger">This company has @Model.UserCount user(s) — remove them first.</strong>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@@ -672,7 +672,7 @@
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div id="oc-loading" class="d-flex justify-content-center align-items-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading…</span></div>
|
||||
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading…</span></div>
|
||||
</div>
|
||||
<div id="oc-content" style="display:none;">
|
||||
<div class="px-3 pt-3 pb-2">
|
||||
@@ -688,14 +688,14 @@
|
||||
<span id="oc-status-badge" class="badge ms-auto"></span>
|
||||
</div>
|
||||
<table class="table table-sm table-borderless mb-0 small">
|
||||
<tr><th style="width:38%;" class="text-muted fw-normal">Role</th><td id="oc-role">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Department</th><td id="oc-dept">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Position</th><td id="oc-position">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Phone</th><td id="oc-phone">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Hired</th><td id="oc-hire">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Account created</th><td id="oc-created">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Last login</th><td id="oc-lastlogin">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Email confirmed</th><td id="oc-emailconf">—</td></tr>
|
||||
<tr><th style="width:38%;" class="text-muted fw-normal">Role</th><td id="oc-role">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Department</th><td id="oc-dept">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Position</th><td id="oc-position">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Phone</th><td id="oc-phone">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Hired</th><td id="oc-hire">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Account created</th><td id="oc-created">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Last login</th><td id="oc-lastlogin">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Email confirmed</th><td id="oc-emailconf">—</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<hr class="my-0" />
|
||||
|
||||
@@ -127,12 +127,12 @@
|
||||
</div>
|
||||
|
||||
<h5 class="card-title mb-3 pb-2 border-bottom">Feature Overrides</h5>
|
||||
<p class="text-muted small mb-3">Override plan-level feature access for this company. Leave blank (—) to inherit from the subscription plan.</p>
|
||||
<p class="text-muted small mb-3">Override plan-level feature access for this company. Leave blank (—) to inherit from the subscription plan.</p>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Online Payments</label>
|
||||
<select asp-for="OnlinePaymentsOverride" class="form-select">
|
||||
<option value="">— Use plan default —</option>
|
||||
<option value="">— Use plan default —</option>
|
||||
<option value="true">Force Enable</option>
|
||||
<option value="false">Force Disable</option>
|
||||
</select>
|
||||
@@ -141,7 +141,7 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Accounting Module</label>
|
||||
<select asp-for="AccountingOverride" class="form-select">
|
||||
<option value="">— Use plan default —</option>
|
||||
<option value="">— Use plan default —</option>
|
||||
<option value="true">Force Enable</option>
|
||||
<option value="false">Force Disable</option>
|
||||
</select>
|
||||
@@ -150,7 +150,7 @@
|
||||
</div>
|
||||
|
||||
<h5 class="card-title mb-3 pb-2 border-bottom">SMS Override</h5>
|
||||
<p class="text-muted small mb-3">Use this to immediately cut off SMS for a company — for example if they are sending abusive messages or have a billing dispute. This overrides the plan entitlement and the company's own opt-in setting.</p>
|
||||
<p class="text-muted small mb-3">Use this to immediately cut off SMS for a company — for example if they are sending abusive messages or have a billing dispute. This overrides the plan entitlement and the company's own opt-in setting.</p>
|
||||
<div class="mb-4">
|
||||
<div class="form-check form-switch">
|
||||
<input asp-for="SmsDisabledByAdmin" class="form-check-input" type="checkbox" role="switch" id="SmsDisabledByAdmin" />
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="searchTerm" class="form-control"
|
||||
placeholder="Search by name, code, email, phone…"
|
||||
placeholder="Search by name, code, email, phone…"
|
||||
value="@searchTerm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,7 +265,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card view — shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
|
||||
<!-- Mobile card view — shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var company in Model)
|
||||
@@ -321,7 +321,7 @@
|
||||
{
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<div class="text-muted small">
|
||||
Showing @((pageNumber - 1) * pageSize + 1)–@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount companies
|
||||
Showing @((pageNumber - 1) * pageSize + 1)–@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount companies
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div>
|
||||
@@ -448,7 +448,7 @@
|
||||
<h6 class="fw-bold text-danger"><i class="bi bi-fire me-2"></i>Hard Delete (Permanent)</h6>
|
||||
<p class="text-muted small mb-2">
|
||||
<strong class="text-danger">This cannot be undone.</strong>
|
||||
All company data — users, jobs, quotes, customers, invoices, and everything else — will be
|
||||
All company data — users, jobs, quotes, customers, invoices, and everything else — will be
|
||||
<strong>permanently and irreversibly deleted</strong> from the database.
|
||||
</p>
|
||||
<div class="alert alert-danger alert-permanent py-2 mb-3">
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<input name="search" value="@ViewBag.Search" class="form-control form-control-sm"
|
||||
placeholder="Company name or email…" />
|
||||
placeholder="Company name or email…" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="risk" class="form-select form-select-sm">
|
||||
@@ -274,7 +274,7 @@
|
||||
<td>
|
||||
@if (h.RiskLevel == ChurnRisk.NeverActivated)
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -338,7 +338,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card view — shown on screens < 992px -->
|
||||
<!-- Mobile card view — shown on screens < 992px -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var h in Model)
|
||||
@@ -394,7 +394,7 @@
|
||||
@if (!Model.Any(h => h.RiskLevel != ChurnRisk.Healthy) && Model.Any())
|
||||
{
|
||||
<div class="card-footer text-center py-3 text-success">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>All tenants are healthy — no churn signals detected.
|
||||
<i class="bi bi-check-circle-fill me-2"></i>All tenants are healthy — no churn signals detected.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<ul class="list-unstyled mb-3">
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-person-x-fill text-danger me-2"></i>
|
||||
<strong>@userCount user account@(userCount != 1 ? "s" : "")</strong> will be deactivated — no one will be able to log in.
|
||||
<strong>@userCount user account@(userCount != 1 ? "s" : "")</strong> will be deactivated — no one will be able to log in.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-briefcase-fill text-danger me-2"></i>
|
||||
@@ -90,13 +90,13 @@
|
||||
<div class="card-body">
|
||||
<ul class="mb-0">
|
||||
<li class="mb-1">
|
||||
<strong>Pause instead of delete</strong> — Contact support to temporarily suspend your account.
|
||||
<strong>Pause instead of delete</strong> — Contact support to temporarily suspend your account.
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<strong>Cancel your subscription</strong> — Stop future billing without deleting your data.
|
||||
<strong>Cancel your subscription</strong> — Stop future billing without deleting your data.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Export your data first</strong> — Go to
|
||||
<strong>Export your data first</strong> — Go to
|
||||
<a asp-controller="Reports" asp-action="Index">Reports</a>
|
||||
to export jobs, customers, invoices, and more before proceeding.
|
||||
</li>
|
||||
@@ -146,7 +146,7 @@
|
||||
<i class="bi bi-trash3 me-1"></i>Delete My Account Permanently
|
||||
</button>
|
||||
<a asp-controller="CompanySettings" asp-action="Index" class="btn btn-outline-secondary px-4">
|
||||
Cancel — Keep My Account
|
||||
Cancel — Keep My Account
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
@@ -176,7 +176,7 @@
|
||||
// Extra guard: prevent accidental double-submit after the form is submitted.
|
||||
document.getElementById('deleteAccountForm').addEventListener('submit', function () {
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Deleting…';
|
||||
deleteBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Deleting…';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@model PowderCoating.Application.DTOs.Notification.NotificationTemplateDto
|
||||
@{
|
||||
ViewData["Title"] = $"Edit Template — {Model.DisplayName}";
|
||||
ViewData["Title"] = $"Edit Template — {Model.DisplayName}";
|
||||
ViewData["PageIcon"] = "bi-envelope-gear";
|
||||
var placeholders = ViewBag.Placeholders as List<(string Placeholder, string Description)>
|
||||
?? new List<(string, string)>();
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
@if (isEmail)
|
||||
{
|
||||
<!-- Raw HTML textarea for email — supports {{placeholders}} and full HTML -->
|
||||
<!-- Raw HTML textarea for email — supports {{placeholders}} and full HTML -->
|
||||
<textarea asp-for="Body" class="form-control font-monospace" rows="16"
|
||||
placeholder="Enter HTML email body..."></textarea>
|
||||
<div class="form-text text-muted mt-1">
|
||||
@@ -131,7 +131,7 @@
|
||||
<div>
|
||||
<span class="badge bg-light text-dark border placeholder-pill px-2 py-1"
|
||||
onclick="copyPlaceholder('@placeholder', this)"
|
||||
title="@description — click to copy">
|
||||
title="@description — click to copy">
|
||||
@placeholder
|
||||
</span>
|
||||
<span class="copy-feedback ms-1">Copied!</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Company.CompanySettingsDto
|
||||
@model PowderCoating.Application.DTOs.Company.CompanySettingsDto
|
||||
@{
|
||||
ViewData["Title"] = "Company Settings";
|
||||
ViewData["PageIcon"] = "bi-building";
|
||||
@@ -118,7 +118,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Company Information"
|
||||
data-bs-content="This information appears on every customer-facing document — quotes, invoices, and PDFs. Keep the company name, address, and email accurate so customers see the right details. The <strong>Primary Contact Email</strong> is used as the reply-to address on all outgoing notifications.<br><br><a href='/Help/Settings#company-information' target='_blank'>Learn more →</a>">
|
||||
data-bs-content="This information appears on every customer-facing document — quotes, invoices, and PDFs. Keep the company name, address, and email accurate so customers see the right details. The <strong>Primary Contact Email</strong> is used as the reply-to address on all outgoing notifications.<br><br><a href='/Help/Settings#company-information' target='_blank'>Learn more →</a>">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -165,39 +165,39 @@
|
||||
<label for="timeZone" class="form-label">Time Zone</label>
|
||||
<select class="form-select" id="timeZone" name="TimeZone">
|
||||
<optgroup label="United States">
|
||||
<option value="America/New_York" selected="@(Model.TimeZone == "America/New_York" ? "selected" : null)">Eastern (ET) — New York</option>
|
||||
<option value="America/Chicago" selected="@(Model.TimeZone == "America/Chicago" ? "selected" : null)">Central (CT) — Chicago</option>
|
||||
<option value="America/Denver" selected="@(Model.TimeZone == "America/Denver" ? "selected" : null)">Mountain (MT) — Denver</option>
|
||||
<option value="America/Phoenix" selected="@(Model.TimeZone == "America/Phoenix" ? "selected" : null)">Mountain no-DST — Phoenix</option>
|
||||
<option value="America/Los_Angeles" selected="@(Model.TimeZone == "America/Los_Angeles" ? "selected" : null)">Pacific (PT) — Los Angeles</option>
|
||||
<option value="America/Anchorage" selected="@(Model.TimeZone == "America/Anchorage" ? "selected" : null)">Alaska (AKT) — Anchorage</option>
|
||||
<option value="Pacific/Honolulu" selected="@(Model.TimeZone == "Pacific/Honolulu" ? "selected" : null)">Hawaii (HT) — Honolulu</option>
|
||||
<option value="America/New_York" selected="@(Model.TimeZone == "America/New_York" ? "selected" : null)">Eastern (ET) — New York</option>
|
||||
<option value="America/Chicago" selected="@(Model.TimeZone == "America/Chicago" ? "selected" : null)">Central (CT) — Chicago</option>
|
||||
<option value="America/Denver" selected="@(Model.TimeZone == "America/Denver" ? "selected" : null)">Mountain (MT) — Denver</option>
|
||||
<option value="America/Phoenix" selected="@(Model.TimeZone == "America/Phoenix" ? "selected" : null)">Mountain no-DST — Phoenix</option>
|
||||
<option value="America/Los_Angeles" selected="@(Model.TimeZone == "America/Los_Angeles" ? "selected" : null)">Pacific (PT) — Los Angeles</option>
|
||||
<option value="America/Anchorage" selected="@(Model.TimeZone == "America/Anchorage" ? "selected" : null)">Alaska (AKT) — Anchorage</option>
|
||||
<option value="Pacific/Honolulu" selected="@(Model.TimeZone == "Pacific/Honolulu" ? "selected" : null)">Hawaii (HT) — Honolulu</option>
|
||||
</optgroup>
|
||||
<optgroup label="Canada">
|
||||
<option value="America/Halifax" selected="@(Model.TimeZone == "America/Halifax" ? "selected" : null)">Atlantic (AT) — Halifax</option>
|
||||
<option value="America/Toronto" selected="@(Model.TimeZone == "America/Toronto" ? "selected" : null)">Eastern — Toronto</option>
|
||||
<option value="America/Winnipeg" selected="@(Model.TimeZone == "America/Winnipeg" ? "selected" : null)">Central — Winnipeg</option>
|
||||
<option value="America/Edmonton" selected="@(Model.TimeZone == "America/Edmonton" ? "selected" : null)">Mountain — Edmonton</option>
|
||||
<option value="America/Vancouver" selected="@(Model.TimeZone == "America/Vancouver" ? "selected" : null)">Pacific — Vancouver</option>
|
||||
<option value="America/Halifax" selected="@(Model.TimeZone == "America/Halifax" ? "selected" : null)">Atlantic (AT) — Halifax</option>
|
||||
<option value="America/Toronto" selected="@(Model.TimeZone == "America/Toronto" ? "selected" : null)">Eastern — Toronto</option>
|
||||
<option value="America/Winnipeg" selected="@(Model.TimeZone == "America/Winnipeg" ? "selected" : null)">Central — Winnipeg</option>
|
||||
<option value="America/Edmonton" selected="@(Model.TimeZone == "America/Edmonton" ? "selected" : null)">Mountain — Edmonton</option>
|
||||
<option value="America/Vancouver" selected="@(Model.TimeZone == "America/Vancouver" ? "selected" : null)">Pacific — Vancouver</option>
|
||||
</optgroup>
|
||||
<optgroup label="Europe">
|
||||
<option value="Europe/London" selected="@(Model.TimeZone == "Europe/London" ? "selected" : null)">GMT/BST — London</option>
|
||||
<option value="Europe/Paris" selected="@(Model.TimeZone == "Europe/Paris" ? "selected" : null)">CET — Paris / Berlin</option>
|
||||
<option value="Europe/Helsinki" selected="@(Model.TimeZone == "Europe/Helsinki" ? "selected" : null)">EET — Helsinki</option>
|
||||
<option value="Europe/Moscow" selected="@(Model.TimeZone == "Europe/Moscow" ? "selected" : null)">MSK — Moscow</option>
|
||||
<option value="Europe/London" selected="@(Model.TimeZone == "Europe/London" ? "selected" : null)">GMT/BST — London</option>
|
||||
<option value="Europe/Paris" selected="@(Model.TimeZone == "Europe/Paris" ? "selected" : null)">CET — Paris / Berlin</option>
|
||||
<option value="Europe/Helsinki" selected="@(Model.TimeZone == "Europe/Helsinki" ? "selected" : null)">EET — Helsinki</option>
|
||||
<option value="Europe/Moscow" selected="@(Model.TimeZone == "Europe/Moscow" ? "selected" : null)">MSK — Moscow</option>
|
||||
</optgroup>
|
||||
<optgroup label="Asia / Pacific">
|
||||
<option value="Asia/Dubai" selected="@(Model.TimeZone == "Asia/Dubai" ? "selected" : null)">GST — Dubai</option>
|
||||
<option value="Asia/Kolkata" selected="@(Model.TimeZone == "Asia/Kolkata" ? "selected" : null)">IST — India</option>
|
||||
<option value="Asia/Bangkok" selected="@(Model.TimeZone == "Asia/Bangkok" ? "selected" : null)">ICT — Bangkok</option>
|
||||
<option value="Asia/Shanghai" selected="@(Model.TimeZone == "Asia/Shanghai" ? "selected" : null)">CST — Beijing / Shanghai</option>
|
||||
<option value="Asia/Tokyo" selected="@(Model.TimeZone == "Asia/Tokyo" ? "selected" : null)">JST — Tokyo</option>
|
||||
<option value="Australia/Sydney" selected="@(Model.TimeZone == "Australia/Sydney" ? "selected" : null)">AEST — Sydney</option>
|
||||
<option value="Pacific/Auckland" selected="@(Model.TimeZone == "Pacific/Auckland" ? "selected" : null)">NZST — Auckland</option>
|
||||
<option value="Asia/Dubai" selected="@(Model.TimeZone == "Asia/Dubai" ? "selected" : null)">GST — Dubai</option>
|
||||
<option value="Asia/Kolkata" selected="@(Model.TimeZone == "Asia/Kolkata" ? "selected" : null)">IST — India</option>
|
||||
<option value="Asia/Bangkok" selected="@(Model.TimeZone == "Asia/Bangkok" ? "selected" : null)">ICT — Bangkok</option>
|
||||
<option value="Asia/Shanghai" selected="@(Model.TimeZone == "Asia/Shanghai" ? "selected" : null)">CST — Beijing / Shanghai</option>
|
||||
<option value="Asia/Tokyo" selected="@(Model.TimeZone == "Asia/Tokyo" ? "selected" : null)">JST — Tokyo</option>
|
||||
<option value="Australia/Sydney" selected="@(Model.TimeZone == "Australia/Sydney" ? "selected" : null)">AEST — Sydney</option>
|
||||
<option value="Pacific/Auckland" selected="@(Model.TimeZone == "Pacific/Auckland" ? "selected" : null)">NZST — Auckland</option>
|
||||
</optgroup>
|
||||
<optgroup label="South America">
|
||||
<option value="America/Sao_Paulo" selected="@(Model.TimeZone == "America/Sao_Paulo" ? "selected" : null)">BRT — São Paulo</option>
|
||||
<option value="America/Buenos_Aires" selected="@(Model.TimeZone == "America/Buenos_Aires" ? "selected" : null)">ART — Buenos Aires</option>
|
||||
<option value="America/Sao_Paulo" selected="@(Model.TimeZone == "America/Sao_Paulo" ? "selected" : null)">BRT — São Paulo</option>
|
||||
<option value="America/Buenos_Aires" selected="@(Model.TimeZone == "America/Buenos_Aires" ? "selected" : null)">ART — Buenos Aires</option>
|
||||
</optgroup>
|
||||
<optgroup label="UTC">
|
||||
<option value="UTC" selected="@(Model.TimeZone == "UTC" ? "selected" : null)">UTC</option>
|
||||
@@ -243,7 +243,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted small mb-2">No period lock set — all dates are open.</div>
|
||||
<div class="text-muted small mb-2">No period lock set — all dates are open.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
@@ -353,7 +353,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Operating Costs"
|
||||
data-bs-content="These are the rates the quoting engine uses to price every job automatically. Set them to your real shop costs and the system will produce accurate quotes without manual calculation. <strong>New quotes use the current rates</strong> — changing a rate here does not retroactively reprice existing quotes.<br><br><a href='/Help/Settings#pricing-configuration' target='_blank'>Learn more →</a>">
|
||||
data-bs-content="These are the rates the quoting engine uses to price every job automatically. Set them to your real shop costs and the system will produce accurate quotes without manual calculation. <strong>New quotes use the current rates</strong> — changing a rate here does not retroactively reprice existing quotes.<br><br><a href='/Help/Settings#pricing-configuration' target='_blank'>Learn more →</a>">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -368,7 +368,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Rates & Costs"
|
||||
data-bs-content="<strong>Standard Labor Rate</strong> is the baseline $/hr for all coating work — sandblasting and masking are multiplied from this. <strong>Powder Coating Cost/sq ft</strong> is the fallback material rate used when you don't select a specific powder inventory item on a quote item. <strong>Additional Coat Labor</strong> is the percentage of the base labor cost charged for each coat after the first (e.g. 30% means a 2nd coat adds 30% more labor).">
|
||||
data-bs-content="<strong>Standard Labor Rate</strong> is the baseline $/hr for all coating work — sandblasting and masking are multiplied from this. <strong>Powder Coating Cost/sq ft</strong> is the fallback material rate used when you don't select a specific powder inventory item on a quote item. <strong>Additional Coat Labor</strong> is the percentage of the base labor cost charged for each coat after the first (e.g. 30% means a 2nd coat adds 30% more labor).">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -506,7 +506,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Equipment Operating Costs"
|
||||
data-bs-content="The hourly cost of running each piece of equipment, including energy and depreciation. These are added to quote items based on the prep services selected. The <strong>Default Oven Rate</strong> is used on quotes where no named oven is chosen — add individual shop ovens below if you have multiple ovens with different capacities and costs.">
|
||||
data-bs-content="The hourly cost of running each piece of equipment, including energy and depreciation. These are added to quote items based on the prep services selected. The <strong>Default Oven Rate</strong> is used on quotes where no named oven is chosen — add individual shop ovens below if you have multiple ovens with different capacities and costs.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -554,7 +554,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Pricing & Profit"
|
||||
data-bs-content="<strong>Markup mode</strong> adds a % on top of material costs only (labor and equipment pass through at cost). <strong>Margin mode</strong> targets a gross margin % of the total selling price — e.g. 30% margin on a $100 cost base gives a $142.86 price. Note: margin % and markup % are not the same number. <strong>Shop Minimum</strong> sets a floor price for any job.">
|
||||
data-bs-content="<strong>Markup mode</strong> adds a % on top of material costs only (labor and equipment pass through at cost). <strong>Margin mode</strong> targets a gross margin % of the total selling price — e.g. 30% margin on a $100 cost base gives a $142.86 price. Note: margin % and markup % are not the same number. <strong>Shop Minimum</strong> sets a floor price for any job.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -678,7 +678,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Part Complexity Multipliers"
|
||||
data-bs-content="A percentage added to the price of <strong>calculated items</strong> based on how intricate the part is. When adding an item in a quote, staff select a complexity level — the system then applies this multiplier to account for the extra time and care needed. <em>Simple</em> = 0% (flat panels, basic shapes). <em>Extreme</em> = highly detailed, tight recesses, masking-intensive parts.">
|
||||
data-bs-content="A percentage added to the price of <strong>calculated items</strong> based on how intricate the part is. When adding an item in a quote, staff select a complexity level — the system then applies this multiplier to account for the extra time and care needed. <em>Simple</em> = 0% (flat panels, basic shapes). <em>Extreme</em> = highly detailed, tight recesses, masking-intensive parts.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -775,11 +775,11 @@
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1 ms-auto">
|
||||
<span class="text-muted">=</span>
|
||||
<span id="ovenDimResult" class="fw-semibold small text-primary" style="min-width:65px;">—</span>
|
||||
<span id="ovenDimResult" class="fw-semibold small text-primary" style="min-width:65px;">—</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="ovenDimApply" disabled>Use</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted mt-1" style="font-size:.72rem;">W × D × H of oven interior — 20% deducted for rack & wall depth</div>
|
||||
<div class="text-muted mt-1" style="font-size:.72rem;">W × D × H of oven interior — 20% deducted for rack & wall depth</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="ovenOrderInput" class="form-label">Display Order</label>
|
||||
@@ -810,7 +810,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="AI Photo Quote Profile"
|
||||
data-bs-content="Describe your shop's specialties, typical items, and pricing style in plain language. This text is injected into the AI's system prompt every time a photo quote is analysed — the more specific you are, the better calibrated the estimates will be for your business. You can also describe anything the AI tends to get wrong. <br><br><strong>Additionally</strong>, the AI automatically learns from quotes your team accepted without overriding — those become calibration examples that improve accuracy over time.">
|
||||
data-bs-content="Describe your shop's specialties, typical items, and pricing style in plain language. This text is injected into the AI's system prompt every time a photo quote is analysed — the more specific you are, the better calibrated the estimates will be for your business. You can also describe anything the AI tends to get wrong. <br><br><strong>Additionally</strong>, the AI automatically learns from quotes your team accepted without overriding — those become calibration examples that improve accuracy over time.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -821,9 +821,9 @@
|
||||
<div class="mb-3">
|
||||
<label for="aiContextProfile" class="form-label fw-semibold">Shop Description</label>
|
||||
<textarea id="aiContextProfile" class="form-control" rows="8" maxlength="2000"
|
||||
placeholder="Examples: • We specialise in automotive restoration — wheels, frames, suspension brackets, and roll cages are our bread and butter. • Our customers expect premium pricing. We rarely work on items over 20 sqft. • Most items come to us already stripped; sandblasting adds roughly 15 min per item on average. • We use a 2-stage cure cycle — pre-heat 10 min, coat, cure 20 min at 400°F.">@(Model.OperatingCosts?.AiContextProfile)</textarea>
|
||||
placeholder="Examples: • We specialise in automotive restoration — wheels, frames, suspension brackets, and roll cages are our bread and butter. • Our customers expect premium pricing. We rarely work on items over 20 sqft. • Most items come to us already stripped; sandblasting adds roughly 15 min per item on average. • We use a 2-stage cure cycle — pre-heat 10 min, coat, cure 20 min at 400°F.">@(Model.OperatingCosts?.AiContextProfile)</textarea>
|
||||
<div class="d-flex justify-content-between mt-1">
|
||||
<small class="text-muted">Plain language — write it as if briefing a new estimator on your shop.</small>
|
||||
<small class="text-muted">Plain language — write it as if briefing a new estimator on your shop.</small>
|
||||
<small class="text-muted"><span id="aiProfileCharCount">@(Model.OperatingCosts?.AiContextProfile?.Length ?? 0)</span>/2000</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -832,7 +832,7 @@
|
||||
<i class="bi bi-floppy me-1"></i> Save AI Profile
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="btnGenerateAiDraft"
|
||||
title="Build a suggested profile from your existing settings — ovens, workers, inventory categories, and rates">
|
||||
title="Build a suggested profile from your existing settings — ovens, workers, inventory categories, and rates">
|
||||
<i class="bi bi-stars me-1"></i> Generate from my settings
|
||||
</button>
|
||||
<span id="aiProfileStatus" class="small"></span>
|
||||
@@ -843,9 +843,9 @@
|
||||
<div class="card bg-light border-0">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title"><i class="bi bi-lightbulb text-warning me-1"></i> How AI Learning Works</h6>
|
||||
<p class="small mb-2"><strong>Layer 1 — Pricing config:</strong> Your operating costs (labor, equipment, markup) are always injected automatically.</p>
|
||||
<p class="small mb-2"><strong>Layer 2 — Your shop profile:</strong> The description you write here is added to every AI analysis, guiding estimates toward your typical work.</p>
|
||||
<p class="small mb-0"><strong>Layer 3 — Automatic learning:</strong> Each time your team accepts an AI estimate without changing it, that item is silently added as a calibration example. The AI improves on its own the more you use it.</p>
|
||||
<p class="small mb-2"><strong>Layer 1 — Pricing config:</strong> Your operating costs (labor, equipment, markup) are always injected automatically.</p>
|
||||
<p class="small mb-2"><strong>Layer 2 — Your shop profile:</strong> The description you write here is added to every AI analysis, guiding estimates toward your typical work.</p>
|
||||
<p class="small mb-0"><strong>Layer 3 — Automatic learning:</strong> Each time your team accepts an AI estimate without changing it, that item is silently added as a calibration example. The AI improves on its own the more you use it.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -874,10 +874,10 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">Shop Size</label>
|
||||
<select class="form-select" id="shopCapabilityTier" style="max-width:320px">
|
||||
<option value="0" selected="@(tierVal == 0 ? "selected" : null)">Garage — Home setup, part-time</option>
|
||||
<option value="1" selected="@(tierVal == 1 ? "selected" : null)">Small — 1–5 person shop</option>
|
||||
<option value="2" selected="@(tierVal == 2 ? "selected" : null)">Medium — Established shop, 5–10 people</option>
|
||||
<option value="3" selected="@(tierVal == 3 ? "selected" : null)">Large — High-volume, 10+ people</option>
|
||||
<option value="0" selected="@(tierVal == 0 ? "selected" : null)">Garage — Home setup, part-time</option>
|
||||
<option value="1" selected="@(tierVal == 1 ? "selected" : null)">Small — 1–5 person shop</option>
|
||||
<option value="2" selected="@(tierVal == 2 ? "selected" : null)">Medium — Established shop, 5–10 people</option>
|
||||
<option value="3" selected="@(tierVal == 3 ? "selected" : null)">Large — High-volume, 10+ people</option>
|
||||
</select>
|
||||
<div class="form-text">Used by the AI when estimating job complexity and throughput.</div>
|
||||
</div>
|
||||
@@ -978,11 +978,11 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Default Currency</label>
|
||||
<select class="form-select" id="defaultCurrency" name="DefaultCurrency">
|
||||
<option value="USD" selected="@(Model.Preferences?.DefaultCurrency == "USD" ? "selected" : null)">USD — US Dollar</option>
|
||||
<option value="CAD" selected="@(Model.Preferences?.DefaultCurrency == "CAD" ? "selected" : null)">CAD — Canadian Dollar</option>
|
||||
<option value="EUR" selected="@(Model.Preferences?.DefaultCurrency == "EUR" ? "selected" : null)">EUR — Euro</option>
|
||||
<option value="GBP" selected="@(Model.Preferences?.DefaultCurrency == "GBP" ? "selected" : null)">GBP — British Pound</option>
|
||||
<option value="AUD" selected="@(Model.Preferences?.DefaultCurrency == "AUD" ? "selected" : null)">AUD — Australian Dollar</option>
|
||||
<option value="USD" selected="@(Model.Preferences?.DefaultCurrency == "USD" ? "selected" : null)">USD — US Dollar</option>
|
||||
<option value="CAD" selected="@(Model.Preferences?.DefaultCurrency == "CAD" ? "selected" : null)">CAD — Canadian Dollar</option>
|
||||
<option value="EUR" selected="@(Model.Preferences?.DefaultCurrency == "EUR" ? "selected" : null)">EUR — Euro</option>
|
||||
<option value="GBP" selected="@(Model.Preferences?.DefaultCurrency == "GBP" ? "selected" : null)">GBP — British Pound</option>
|
||||
<option value="AUD" selected="@(Model.Preferences?.DefaultCurrency == "AUD" ? "selected" : null)">AUD — Australian Dollar</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1046,7 +1046,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Number Prefixes"
|
||||
data-bs-content="The prefix is combined with a date stamp and sequence number to form record IDs — for example prefix <strong>QT</strong> produces <em>QT-2603-0042</em>. Change the prefix to match your preferred numbering convention. Changing it only affects <strong>new</strong> records; existing numbers are not renamed.">
|
||||
data-bs-content="The prefix is combined with a date stamp and sequence number to form record IDs — for example prefix <strong>QT</strong> produces <em>QT-2603-0042</em>. Change the prefix to match your preferred numbering convention. Changing it only affects <strong>new</strong> records; existing numbers are not renamed.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h6>
|
||||
@@ -1082,7 +1082,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Job & Workflow Defaults"
|
||||
data-bs-content="Controls how jobs are created and flow through your shop. <strong>Require Customer PO</strong> enforces that a PO number is entered before a job can be saved — useful for commercial accounts. <strong>Allow Customer Approval</strong> enables the approval step in the job workflow — when a quote is approved, the job moves to an Approved status before work begins.">
|
||||
data-bs-content="Controls how jobs are created and flow through your shop. <strong>Require Customer PO</strong> enforces that a PO number is entered before a job can be saved — useful for commercial accounts. <strong>Allow Customer Approval</strong> enables the approval step in the job workflow — when a quote is approved, the job moves to an Approved status before work begins.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -1141,7 +1141,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Notifications & Alerts"
|
||||
data-bs-content="Controls which events send emails to your team and customers. Set the <strong>From Email Address</strong> to a domain you control — using a domain-verified address prevents emails landing in spam. If left blank, the system default address is used. Turn off notification types you don't need to avoid inbox noise.">
|
||||
data-bs-content="Controls which events send emails to your team and customers. Set the <strong>From Email Address</strong> to a domain you control — using a domain-verified address prevents emails landing in spam. If left blank, the system default address is used. Turn off notification types you don't need to avoid inbox noise.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -1311,7 +1311,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Notification Templates"
|
||||
data-bs-content="Customise the subject and body of every automated email sent by the system — job status updates, quote approvals, invoice reminders, and more. Templates use <strong>{{placeholder}}</strong> tokens that are replaced with live data when the email is sent. Click <strong>Edit</strong> on any row to modify it; use <strong>Reset to Default</strong> to restore the original wording at any time.<br><br>Changes take effect immediately — the next triggered notification will use the updated template.">
|
||||
data-bs-content="Customise the subject and body of every automated email sent by the system — job status updates, quote approvals, invoice reminders, and more. Templates use <strong>{{placeholder}}</strong> tokens that are replaced with live data when the email is sent. Click <strong>Edit</strong> on any row to modify it; use <strong>Reset to Default</strong> to restore the original wording at any time.<br><br>Changes take effect immediately — the next triggered notification will use the updated template.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -1371,7 +1371,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Data Retention"
|
||||
data-bs-content="Controls how long records are kept. Most businesses set quote and job retention to <strong>7 years</strong> to satisfy tax and audit requirements. <strong>Deleted record retention</strong> is the grace period after a soft-delete before the record is permanently purged — useful if someone accidentally deletes something.">
|
||||
data-bs-content="Controls how long records are kept. Most businesses set quote and job retention to <strong>7 years</strong> to satisfy tax and audit requirements. <strong>Deleted record retention</strong> is the grace period after a soft-delete before the record is permanently purged — useful if someone accidentally deletes something.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -1433,7 +1433,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right"
|
||||
data-bs-title="Data Lookups"
|
||||
data-bs-content="Lookups are the dropdown options that appear throughout the app — job statuses, priorities, quote statuses, and more. You can rename labels, change colours, and reorder them to match your shop's terminology. <strong>Status codes</strong> drive workflow logic and should not be changed unless you understand the impact.">
|
||||
data-bs-content="Lookups are the dropdown options that appear throughout the app — job statuses, priorities, quote statuses, and more. You can rename labels, change colours, and reorder them to match your shop's terminology. <strong>Status codes</strong> drive workflow logic and should not be changed unless you understand the impact.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</h5>
|
||||
@@ -1869,7 +1869,7 @@
|
||||
<textarea id="woTerms" class="form-control" rows="5" maxlength="2000"
|
||||
placeholder="e.g. *Products must be picked up within 5 days of notification of completion or a storage fee may apply."
|
||||
>@(Model.Preferences?.WoTerms ?? "")</textarea>
|
||||
<div class="form-text">Printed in italic at the bottom of every blank work order. Supports plain text — use * or ** for visual emphasis.</div>
|
||||
<div class="form-text">Printed in italic at the bottom of every blank work order. Supports plain text — use * or ** for visual emphasis.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
@@ -2168,7 +2168,7 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title" id="smsTermsModalLabel">
|
||||
<i class="bi bi-phone me-2"></i>SMS Notifications — Terms of Service
|
||||
<i class="bi bi-phone me-2"></i>SMS Notifications — Terms of Service
|
||||
</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -2178,9 +2178,9 @@
|
||||
</div>
|
||||
|
||||
<h6 class="fw-bold">1. Prior Express Written Consent Required</h6>
|
||||
<p class="text-muted small">You <strong>must obtain clear, documented consent</strong> from each customer before sending them any SMS message. This means each customer must have explicitly agreed — in writing or through a recorded digital interaction — that they wish to receive text messages from your business. Enabling this feature is not consent on their behalf. You must collect and record their authorization individually, before enabling SMS for their account in this system.</p>
|
||||
<p class="text-muted small">You <strong>must obtain clear, documented consent</strong> from each customer before sending them any SMS message. This means each customer must have explicitly agreed — in writing or through a recorded digital interaction — that they wish to receive text messages from your business. Enabling this feature is not consent on their behalf. You must collect and record their authorization individually, before enabling SMS for their account in this system.</p>
|
||||
|
||||
<h6 class="fw-bold">2. Federal Law Governs SMS — Fines Are Real</h6>
|
||||
<h6 class="fw-bold">2. Federal Law Governs SMS — Fines Are Real</h6>
|
||||
<p class="text-muted small">The <strong>Telephone Consumer Protection Act (TCPA)</strong>, enforced by the Federal Communications Commission (FCC), imposes fines of <strong>$500 to $1,500 per individual message</strong> sent without proper authorization. These fines apply per text, not per customer. A single campaign to 100 unconsented recipients could result in exposure of $50,000 to $150,000. The FCC and private plaintiffs both actively pursue TCPA violations.</p>
|
||||
|
||||
<h6 class="fw-bold">3. Opt-Out Requests Must Be Honored Immediately</h6>
|
||||
@@ -2189,7 +2189,7 @@
|
||||
<h6 class="fw-bold">4. Message Rates & Content Restrictions</h6>
|
||||
<p class="text-muted small">Every message sent must include your business name and an opt-out reminder (e.g., "Reply STOP to opt out"). Messages must be directly relevant to the service the customer consented to receive and must not contain solicitations, promotions, or third-party offers unless the customer has separately consented to those.</p>
|
||||
|
||||
<h6 class="fw-bold">5. Your Responsibility — Not Ours</h6>
|
||||
<h6 class="fw-bold">5. Your Responsibility — Not Ours</h6>
|
||||
<p class="text-muted small">Powder Coating Logix provides this feature as a communication tool only. <strong>We are not responsible for how you use it.</strong> You agree that your company is solely responsible for obtaining proper consent, maintaining records of that consent, honoring opt-outs, and ensuring all outbound messages comply with the TCPA, FCC regulations, and any applicable state laws. You agree to indemnify and hold Powder Coating Logix harmless from any claims, fines, or damages arising from your company's use of SMS.</p>
|
||||
|
||||
<hr />
|
||||
@@ -2201,7 +2201,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" id="smsTermsDeclineBtn">Cancel — Keep SMS Disabled</button>
|
||||
<button type="button" class="btn btn-secondary" id="smsTermsDeclineBtn">Cancel — Keep SMS Disabled</button>
|
||||
<button type="button" class="btn btn-primary" id="smsTermsAcceptBtn" disabled>
|
||||
<i class="bi bi-check-circle me-1"></i>I Agree & Enable SMS
|
||||
</button>
|
||||
@@ -2469,7 +2469,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
// AI Profile — char counter and save (elements only exist when AiPhotoQuotesEnabled)
|
||||
// AI Profile — char counter and save (elements only exist when AiPhotoQuotesEnabled)
|
||||
$('#aiContextProfile').on('input', function () {
|
||||
$('#aiProfileCharCount').text($(this).val().length);
|
||||
});
|
||||
@@ -2511,7 +2511,7 @@
|
||||
if (response.success) {
|
||||
$('#aiContextProfile').val(response.draft);
|
||||
$('#aiProfileCharCount').text(response.draft.length);
|
||||
showToast('info', 'Draft generated — review and edit it, then click Save AI Profile.');
|
||||
showToast('info', 'Draft generated — review and edit it, then click Save AI Profile.');
|
||||
} else {
|
||||
showToast('error', response.message);
|
||||
}
|
||||
@@ -2525,7 +2525,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Quoting Calibration — save
|
||||
// Quoting Calibration — save
|
||||
$('#saveBlastProfile').on('click', function () {
|
||||
var btn = $(this);
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Saving...');
|
||||
@@ -2631,7 +2631,7 @@
|
||||
};
|
||||
}, 'Save Retention Policy');
|
||||
|
||||
// SMS toggle — shows terms modal on first enable (or after terms version change)
|
||||
// SMS toggle — shows terms modal on first enable (or after terms version change)
|
||||
(function () {
|
||||
const toggle = document.getElementById('smsEnabledToggle');
|
||||
if (!toggle) return;
|
||||
@@ -2737,7 +2737,7 @@
|
||||
<script src="~/js/company-settings-lookups-modals.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
// ── Oven Costs Management ──────────────────────────────────────────────
|
||||
// Cache element references once — modal is now outside all forms
|
||||
// Cache element references once — modal is now outside all forms
|
||||
|
||||
const _ovenModal = new bootstrap.Modal(document.getElementById('ovenModal'));
|
||||
const _ovenTitle = document.getElementById('ovenModalTitle');
|
||||
@@ -2765,7 +2765,7 @@
|
||||
if (!data.success) throw new Error(data.message);
|
||||
renderOvenTable(data.data);
|
||||
_ovenCount.textContent = data.data.length === 0
|
||||
? 'No shop ovens — default rate will be used on all quotes.'
|
||||
? 'No shop ovens — default rate will be used on all quotes.'
|
||||
: `${data.data.length} oven(s) configured`;
|
||||
} catch (e) {
|
||||
_ovenBody.innerHTML = `<tr><td colspan="6" class="text-center text-danger">Failed to load ovens: ${escHtml(e.message)}</td></tr>`;
|
||||
@@ -2783,7 +2783,7 @@
|
||||
<tr>
|
||||
<td><strong>${escHtml(o.label)}</strong></td>
|
||||
<td>$${parseFloat(o.costPerHour).toFixed(2)}/hr</td>
|
||||
<td class="text-muted small">${o.maxLoadSqFt != null ? parseFloat(o.maxLoadSqFt).toFixed(0) + ' sqft' : '—'}</td>
|
||||
<td class="text-muted small">${o.maxLoadSqFt != null ? parseFloat(o.maxLoadSqFt).toFixed(0) + ' sqft' : '—'}</td>
|
||||
<td>${o.displayOrder}</td>
|
||||
<td>${o.isActive
|
||||
? '<span class="badge bg-success">Active</span>'
|
||||
@@ -2887,7 +2887,7 @@
|
||||
document.getElementById('ovenCalcToggle').addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const hidden = _calcPanel.classList.toggle('d-none');
|
||||
if (!hidden) { _calcW.value = ''; _calcD.value = ''; _calcH.value = ''; _calcResult.textContent = '—'; _calcApply.disabled = true; _calcW.focus(); }
|
||||
if (!hidden) { _calcW.value = ''; _calcD.value = ''; _calcH.value = ''; _calcResult.textContent = '—'; _calcApply.disabled = true; _calcW.focus(); }
|
||||
});
|
||||
|
||||
function _updateCalc() {
|
||||
@@ -2907,7 +2907,7 @@
|
||||
_calcApply.disabled = false;
|
||||
_calcApply.dataset.val = val;
|
||||
} else {
|
||||
_calcResult.textContent = '—';
|
||||
_calcResult.textContent = '—';
|
||||
_calcApply.disabled = true;
|
||||
}
|
||||
}
|
||||
@@ -2925,7 +2925,7 @@
|
||||
document.getElementById('ovenModal').addEventListener('hidden.bs.modal', function () {
|
||||
_calcPanel.classList.add('d-none');
|
||||
_calcW.value = ''; _calcD.value = ''; _calcH.value = '';
|
||||
_calcResult.textContent = '—'; _calcApply.disabled = true;
|
||||
_calcResult.textContent = '—'; _calcApply.disabled = true;
|
||||
});
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3108,7 +3108,7 @@
|
||||
onmouseenter="this.classList.replace('bg-light','bg-primary');this.classList.replace('text-dark','text-white')"
|
||||
onmouseleave="this.classList.replace('bg-primary','bg-light');this.classList.replace('text-white','text-dark')"
|
||||
onclick="ntplCopyPlaceholder('${p.placeholder}', this)"
|
||||
title="${p.description} — click to copy">
|
||||
title="${p.description} — click to copy">
|
||||
${p.placeholder}
|
||||
</span>
|
||||
<span class="ms-1 text-success small" style="display:none;">Copied!</span>
|
||||
|
||||
@@ -435,13 +435,13 @@
|
||||
<div class="col-sm-6">
|
||||
<label for="blastSetupNozzleSize" class="form-label">Nozzle Size</label>
|
||||
<select class="form-select blast-modal-input" id="blastSetupNozzleSize">
|
||||
<option value="2">#2 (1/8") — Very small / entry level</option>
|
||||
<option value="3">#3 (3/16") — Small / hobby</option>
|
||||
<option value="4">#4 (1/4") — Light duty</option>
|
||||
<option value="5" selected>#5 (5/16") — Medium (most common)</option>
|
||||
<option value="6">#6 (3/8") — Heavy duty</option>
|
||||
<option value="7">#7 (7/16") — High volume</option>
|
||||
<option value="8">#8 (1/2") — Industrial</option>
|
||||
<option value="2">#2 (1/8") — Very small / entry level</option>
|
||||
<option value="3">#3 (3/16") — Small / hobby</option>
|
||||
<option value="4">#4 (1/4") — Light duty</option>
|
||||
<option value="5" selected>#5 (5/16") — Medium (most common)</option>
|
||||
<option value="6">#6 (3/8") — Heavy duty</option>
|
||||
<option value="7">#7 (7/16") — High volume</option>
|
||||
<option value="8">#8 (1/2") — Industrial</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
@@ -465,7 +465,7 @@
|
||||
<div class="col-sm-6 d-flex align-items-end">
|
||||
<div class="w-100 p-3 bg-light rounded text-center">
|
||||
<div class="text-muted small">Derived Rate</div>
|
||||
<div class="fw-bold fs-5" id="blastSetupDerivedRate">—</div>
|
||||
<div class="fw-bold fs-5" id="blastSetupDerivedRate">—</div>
|
||||
<div class="text-muted small">sqft/hr</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.User.CreateCompanyUserDto
|
||||
@model PowderCoating.Application.DTOs.User.CreateCompanyUserDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Add New User";
|
||||
ViewData["PageIcon"] = "bi-person-plus";
|
||||
ViewData["PageHelpTitle"] = "Add New User";
|
||||
ViewData["PageHelpContent"] = "Creates a new login account for a member of your company. The email address doubles as the login username. Set a temporary password — the user can change it from their Profile page after their first login. Assign a Role, then fine-tune individual permissions below.";
|
||||
ViewData["PageHelpContent"] = "Creates a new login account for a member of your company. The email address doubles as the login username. Set a temporary password — the user can change it from their Profile page after their first login. Assign a Role, then fine-tune individual permissions below.";
|
||||
}
|
||||
|
||||
<div class="container">
|
||||
@@ -26,7 +26,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Basic Information"
|
||||
data-bs-content="First Name, Last Name, and Email are required. The email is used as the login username — it must be unique across the system. Employee Number is an optional internal reference. The user can update their name and phone from their own Profile page after logging in.">
|
||||
data-bs-content="First Name, Last Name, and Email are required. The email is used as the login username — it must be unique across the system. Employee Number is an optional internal reference. The user can update their name and phone from their own Profile page after logging in.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.User.UpdateCompanyUserDto
|
||||
@model PowderCoating.Application.DTOs.User.UpdateCompanyUserDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit User";
|
||||
ViewData["PageIcon"] = "bi-person-gear";
|
||||
ViewData["PageHelpTitle"] = "Edit User";
|
||||
ViewData["PageHelpContent"] = "Update this user's account details, role, and permissions. Unchecking User Active prevents the user from logging in without deleting their account or history. Changing the email here also changes their login username — notify them so they can log in with the new address.";
|
||||
ViewData["PageHelpContent"] = "Update this user's account details, role, and permissions. Unchecking User Active prevents the user from logging in without deleting their account or history. Changing the email here also changes their login username — notify them so they can log in with the new address.";
|
||||
}
|
||||
|
||||
<div class="container">
|
||||
@@ -36,7 +36,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Basic Information"
|
||||
data-bs-content="Email is this user's login username — changing it here means they must use the new address to log in. User Active controls whether the account can sign in; deactivating preserves all data without deleting the account. To reset a password, the user can use Forgot Password on the login page, or a SuperAdmin can set one directly.">
|
||||
data-bs-content="Email is this user's login username — changing it here means they must use the new address to log in. User Active controls whether the account can sign in; deactivating preserves all data without deleting the account. To reset a password, the user can use Forgot Password on the login page, or a SuperAdmin can set one directly.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Role & Department"
|
||||
data-bs-content="Changing the Role updates the user's base access level immediately on save. Termination Date is informational — to actually prevent login, also uncheck User Active above. Department and Position appear on the user's profile card and in the Manage Users list.">
|
||||
data-bs-content="Changing the Role updates the user's base access level immediately on save. Termination Date is informational — to actually prevent login, also uncheck User Active above. Department and Position appear on the user's profile card and in the Manage Users list.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold">AI Assistant</div>
|
||||
<div class="text-muted small">Ask anything — available 24/7 in the bottom-right corner.</div>
|
||||
<div class="text-muted small">Ask anything — available 24/7 in the bottom-right corner.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-success flex-shrink-0"
|
||||
onclick="document.getElementById('aiHelpTrigger')?.click()">
|
||||
@@ -122,7 +122,7 @@
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Form.Category" class="form-label fw-semibold"></label>
|
||||
<select asp-for="Form.Category" class="form-select">
|
||||
<option value="">— Select a Category —</option>
|
||||
<option value="">— Select a Category —</option>
|
||||
@foreach (var cat in ContactFormModel.Categories)
|
||||
{
|
||||
<option value="@cat" selected="@(Model.Form.Category == cat)">@cat</option>
|
||||
|
||||
@@ -105,7 +105,7 @@ else
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-semibold">Admin Note <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<textarea name="adminNotes" class="form-control" rows="3"
|
||||
placeholder="e.g. Replied via email, escalated to billing, resolved…"></textarea>
|
||||
placeholder="e.g. Replied via email, escalated to billing, resolved…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="mb-3">
|
||||
<label asp-for="CustomerId" class="form-label">Customer <span class="text-danger">*</span></label>
|
||||
<select asp-for="CustomerId" asp-items="customers" class="form-select">
|
||||
<option value="0">— select customer —</option>
|
||||
<option value="0">— select customer —</option>
|
||||
</select>
|
||||
<span asp-validation-for="CustomerId" class="text-danger small"></span>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@
|
||||
<div class="mb-3">
|
||||
<label asp-for="Reason" class="form-label">Reason <span class="text-danger">*</span></label>
|
||||
<input asp-for="Reason" class="form-control"
|
||||
placeholder="e.g. Price adjustment, billing error, goodwill credit…" />
|
||||
placeholder="e.g. Price adjustment, billing error, goodwill credit…" />
|
||||
<span asp-validation-for="Reason" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<div class="mb-4">
|
||||
<label asp-for="ExpiryDate" class="form-label">
|
||||
Expiry Date
|
||||
<span class="text-muted small ms-1">(optional — leave blank for no expiry)</span>
|
||||
<span class="text-muted small ms-1">(optional — leave blank for no expiry)</span>
|
||||
</label>
|
||||
<input asp-for="ExpiryDate" type="date" class="form-control" />
|
||||
<span asp-validation-for="ExpiryDate" class="text-danger small"></span>
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
</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="small text-muted">@(a.AppliedBy?.FullName ?? "—")</td>
|
||||
<td class="small text-muted">@(a.AppliedBy?.FullName ?? "—")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -220,12 +220,12 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Select Invoice</label>
|
||||
<select name="invoiceId" id="applyInvoiceId" class="form-select" required>
|
||||
<option value="">— choose invoice —</option>
|
||||
<option value="">— choose invoice —</option>
|
||||
@foreach (var inv in openInvoices)
|
||||
{
|
||||
<option value="@inv.Id"
|
||||
data-balance="@inv.BalanceDue.ToString("F2")">
|
||||
@inv.InvoiceNumber — Due @inv.BalanceDue.ToString("C")
|
||||
@inv.InvoiceNumber — Due @inv.BalanceDue.ToString("C")
|
||||
@if (inv.DueDate.HasValue && inv.DueDate.Value < DateTime.UtcNow)
|
||||
{ <text>(Overdue)</text> }
|
||||
</option>
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-1">Search</label>
|
||||
<input name="search" value="@search" class="form-control form-control-sm"
|
||||
placeholder="Customer, memo #, or reason…" />
|
||||
placeholder="Customer, memo #, or reason…" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
@@ -204,7 +204,7 @@ else
|
||||
</td>
|
||||
<td>@m.IssueDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
|
||||
<td class="@(expired ? "text-danger fw-semibold" : "")">
|
||||
@(m.ExpiryDate.HasValue ? m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yyyy") : "—")
|
||||
@(m.ExpiryDate.HasValue ? m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yyyy") : "—")
|
||||
@if (expired) { <small>(Expired)</small> }
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="fw-semibold">@job.FinalPrice.ToString("C")</td>
|
||||
@@ -267,7 +267,7 @@
|
||||
{
|
||||
<div class="card-footer bg-transparent d-flex justify-content-between align-items-center py-3 px-4">
|
||||
<div class="text-muted small">
|
||||
Showing @jobs.StartIndex–@jobs.EndIndex of @jobs.TotalCount jobs
|
||||
Showing @jobs.StartIndex–@jobs.EndIndex of @jobs.TotalCount jobs
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
@@ -399,7 +399,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="fw-semibold">@quote.Total.ToString("C")</td>
|
||||
@@ -426,7 +426,7 @@
|
||||
{
|
||||
<div class="card-footer bg-transparent d-flex justify-content-between align-items-center py-3 px-4">
|
||||
<div class="text-muted small">
|
||||
Showing @quotes.StartIndex–@quotes.EndIndex of @quotes.TotalCount quotes
|
||||
Showing @quotes.StartIndex–@quotes.EndIndex of @quotes.TotalCount quotes
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Company Information"
|
||||
data-bs-content="Company Name is used on quotes, invoices, and correspondence. Leave it blank for individual (non-business) customers. Customer Type controls which features are available — Commercial customers get payment terms, credit limits, and pricing tier discounts; Individual customers are for simpler one-off work.">
|
||||
data-bs-content="Company Name is used on quotes, invoices, and correspondence. Leave it blank for individual (non-business) customers. Customer Type controls which features are available — Commercial customers get payment terms, credit limits, and pricing tier discounts; Individual customers are for simpler one-off work.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -59,7 +59,7 @@
|
||||
</h5>
|
||||
<div class="alert alert-info alert-permanent py-2 px-3 mb-3" style="font-size:.875rem;">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<strong>Required:</strong> At least one of Company Name, First Name, or Last Name — and at least one of Email, Phone, or Mobile Phone.
|
||||
<strong>Required:</strong> At least one of Company Name, First Name, or Last Name — and at least one of Email, Phone, or Mobile Phone.
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
@@ -216,7 +216,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Business Information"
|
||||
data-bs-content="These fields govern billing and compliance. Payment Terms sets the default for invoices (e.g., Net 30 = payment due within 30 days). Credit Limit is a soft cap on outstanding balance — the system will warn when exceeded. Tax Exempt removes tax from all invoices for this customer (upload the exemption certificate on the Edit page).">
|
||||
data-bs-content="These fields govern billing and compliance. Payment Terms sets the default for invoices (e.g., Net 30 = payment due within 30 days). Credit Limit is a soft cap on outstanding balance — the system will warn when exceeded. Tax Exempt removes tax from all invoices for this customer (upload the exemption certificate on the Edit page).">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -232,7 +232,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Payment Terms"
|
||||
data-bs-content="Sets the default due date on invoices for this customer. 'Net 30' means payment is due 30 days after the invoice date. This is a default — you can override it on individual invoices.">
|
||||
data-bs-content="Sets the default due date on invoices for this customer. 'Net 30' means payment is due 30 days after the invoice date. This is a default — you can override it on individual invoices.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -250,7 +250,7 @@
|
||||
<div class="col-md-3">
|
||||
<label asp-for="PricingTierId" class="form-label">Pricing Tier</label>
|
||||
<select asp-for="PricingTierId" asp-items="ViewBag.PricingTiers" class="form-select">
|
||||
<option value="">— No tier —</option>
|
||||
<option value="">— No tier —</option>
|
||||
</select>
|
||||
<small class="text-muted">Applies a discount to all quotes for this customer.</small>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Customer.CustomerDto
|
||||
@model PowderCoating.Application.DTOs.Customer.CustomerDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = !string.IsNullOrWhiteSpace(Model.CompanyName)
|
||||
@@ -123,7 +123,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not set — invoices go to contact email</span>
|
||||
<span class="text-muted">Not set — invoices go to contact email</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@@ -531,7 +531,7 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Reason <span class="text-danger">*</span></label>
|
||||
<select name="Reason" class="form-select" required id="creditReasonSelect">
|
||||
<option value="">— Select reason —</option>
|
||||
<option value="">— Select reason —</option>
|
||||
<option value="Pre-payment / Deposit">Pre-payment / Deposit</option>
|
||||
<option value="Gift / Gift Card">Gift / Gift Card</option>
|
||||
<option value="Overpayment credit">Overpayment credit</option>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Customer.UpdateCustomerDto
|
||||
@model PowderCoating.Application.DTOs.Customer.UpdateCustomerDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Customer";
|
||||
@@ -26,7 +26,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Company Information"
|
||||
data-bs-content="Company Name is used on quotes, invoices, and correspondence. Customer Type controls which features are available — Commercial customers get payment terms, credit limits, and pricing tier discounts. Status Inactive hides the customer from new quote/job dropdowns but preserves all history.">
|
||||
data-bs-content="Company Name is used on quotes, invoices, and correspondence. Customer Type controls which features are available — Commercial customers get payment terms, credit limits, and pricing tier discounts. Status Inactive hides the customer from new quote/job dropdowns but preserves all history.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -220,7 +220,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Business Information"
|
||||
data-bs-content="Payment Terms sets the default due date on invoices (e.g., Net 30 = 30 days from invoice date). Credit Limit is a soft warning cap — the system alerts when exceeded. Tax Exempt removes tax from all invoices; upload the exemption certificate in the Tax Exempt Certificate section below.">
|
||||
data-bs-content="Payment Terms sets the default due date on invoices (e.g., Net 30 = 30 days from invoice date). Credit Limit is a soft warning cap — the system alerts when exceeded. Tax Exempt removes tax from all invoices; upload the exemption certificate in the Tax Exempt Certificate section below.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -246,7 +246,7 @@
|
||||
<div class="col-md-3">
|
||||
<label asp-for="PricingTierId" class="form-label">Pricing Tier</label>
|
||||
<select asp-for="PricingTierId" asp-items="ViewBag.PricingTiers" class="form-select">
|
||||
<option value="">— No tier —</option>
|
||||
<option value="">— No tier —</option>
|
||||
</select>
|
||||
<small class="text-muted">Applies a discount to all quotes for this customer.</small>
|
||||
</div>
|
||||
@@ -291,7 +291,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Notification Preferences"
|
||||
data-bs-content="Controls when the customer receives automatic updates. Email notifications send status change alerts (e.g., job ready for pickup) to the customer's email address. SMS requires separate TCPA consent — uncheck 'SMS Notifications Active' to temporarily pause without revoking consent.">
|
||||
data-bs-content="Controls when the customer receives automatic updates. Email notifications send status change alerts (e.g., job ready for pickup) to the customer's email address. SMS requires separate TCPA consent — uncheck 'SMS Notifications Active' to temporarily pause without revoking consent.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -314,7 +314,7 @@
|
||||
<div class="mt-3">
|
||||
@if (Model.SmsConsentedAt.HasValue)
|
||||
{
|
||||
<!-- Consent already recorded — show status and allow pause/resume -->
|
||||
<!-- Consent already recorded — show status and allow pause/resume -->
|
||||
<div class="card border-success bg-success-subtle p-3 mb-2">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<i class="bi bi-shield-fill-check text-success fs-4 mt-1"></i>
|
||||
@@ -339,7 +339,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- No consent on file — show the compliance notice and consent checkbox -->
|
||||
<!-- No consent on file — show the compliance notice and consent checkbox -->
|
||||
<div class="alert alert-warning border-warning alert-permanent" role="alert">
|
||||
<h6 class="alert-heading fw-bold mb-2">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>SMS Consent Requirement (TCPA)
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@@ -128,7 +128,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@@ -140,7 +140,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@@ -224,7 +224,7 @@
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Phone</span>
|
||||
<span class="mobile-card-value">@(customer.Phone ?? "—")</span>
|
||||
<span class="mobile-card-value">@(customer.Phone ?? "—")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Type</span>
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="align-middle text-end fw-semibold">@inv.Total.ToString("C")</td>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.CustomerStatementDto
|
||||
@{
|
||||
ViewData["Title"] = $"Statement – {Model.CustomerName}";
|
||||
ViewData["Title"] = $"Statement – {Model.CustomerName}";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h4 class="mb-0">Customer Statement</h4>
|
||||
<p class="text-muted mb-0">@Model.CustomerName · @Model.From.ToString("MMM d, yyyy") – @Model.To.ToString("MMM d, yyyy")</p>
|
||||
<p class="text-muted mb-0">@Model.CustomerName · @Model.From.ToString("MMM d, yyyy") – @Model.To.ToString("MMM d, yyyy")</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<form method="get" class="d-flex gap-2 align-items-center">
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<p class="mb-3" style="font-family:var(--font-display);font-size:1.35rem;font-weight:500;line-height:1.4;color:var(--pcl-ink);">
|
||||
@if (_attnCount > 0)
|
||||
{
|
||||
<span>Shop is </span><span style="color:var(--pcl-bad);">running hot</span><span> — @_attnCount item@(_attnCount == 1 ? "" : "s") need attention.</span>
|
||||
<span>Shop is </span><span style="color:var(--pcl-bad);">running hot</span><span> — @_attnCount item@(_attnCount == 1 ? "" : "s") need attention.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* PWA install banner — rendered by JS only on mobile, hidden once dismissed or already installed *@
|
||||
@* PWA install banner — rendered by JS only on mobile, hidden once dismissed or already installed *@
|
||||
<div id="pwa-install-banner" class="row mb-4" style="display:none!important;">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-permanent mb-0 d-flex align-items-start gap-3 py-3"
|
||||
@@ -115,7 +115,7 @@
|
||||
@await Html.PartialAsync("_ShopProgressWidget", shopProgressWidget)
|
||||
}
|
||||
|
||||
@* Config health alert — only shown when there are setup gaps *@
|
||||
@* Config health alert — only shown when there are setup gaps *@
|
||||
@if (configHealth != null && !configHealth.IsHealthy)
|
||||
{
|
||||
<div class="row mb-4">
|
||||
@@ -417,15 +417,15 @@
|
||||
}
|
||||
@if (Model.AgingDays1To30 > 0)
|
||||
{
|
||||
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-warn);"></span>1–30d @Model.AgingDays1To30.ToString("C0")</span>
|
||||
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-warn);"></span>1–30d @Model.AgingDays1To30.ToString("C0")</span>
|
||||
}
|
||||
@if (Model.AgingDays31To60 > 0)
|
||||
{
|
||||
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-bad);"></span>31–60d @Model.AgingDays31To60.ToString("C0")</span>
|
||||
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-bad);"></span>31–60d @Model.AgingDays31To60.ToString("C0")</span>
|
||||
}
|
||||
@if (Model.AgingDays61To90 > 0)
|
||||
{
|
||||
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-bad);"></span>61–90d @Model.AgingDays61To90.ToString("C0")</span>
|
||||
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-bad);"></span>61–90d @Model.AgingDays61To90.ToString("C0")</span>
|
||||
}
|
||||
@if (Model.AgingDaysOver90 > 0)
|
||||
{
|
||||
@@ -547,7 +547,7 @@
|
||||
@if (line.EstCost.HasValue)
|
||||
{<span>@line.EstCost.Value.ToString("C")</span>}
|
||||
else
|
||||
{<span class="text-muted">—</span>}
|
||||
{<span class="text-muted">—</span>}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<button class="btn btn-sm btn-outline-danger mark-ordered-btn text-nowrap"
|
||||
@@ -563,7 +563,7 @@
|
||||
<tr>
|
||||
<td colspan="2">Vendor Total</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">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "—")</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -582,7 +582,7 @@
|
||||
<div class="card border-0 shadow-sm dashboard-card" style="border-left: 4px solid #0d6efd !important;">
|
||||
<div class="card-header bg-body border-0 d-flex justify-content-between align-items-center pt-4 pb-3">
|
||||
<h5 class="mb-0 fw-bold">
|
||||
<i class="bi bi-box-arrow-in-down me-2 text-muted"></i>Powder Ordered — Awaiting Receipt
|
||||
<i class="bi bi-box-arrow-in-down me-2 text-muted"></i>Powder Ordered — Awaiting Receipt
|
||||
<span class="ms-2 text-muted fw-normal small" id="placed-count-label">@Model.PowderOrdersPlacedCount item@(Model.PowderOrdersPlacedCount == 1 ? "" : "s")</span>
|
||||
</h5>
|
||||
<small class="text-muted">Grouped by vendor · Enter lbs received to update inventory</small>
|
||||
@@ -641,13 +641,13 @@
|
||||
@if (line.EstCost.HasValue)
|
||||
{<span>@line.EstCost.Value.ToString("C")</span>}
|
||||
else
|
||||
{<span class="text-muted">—</span>}
|
||||
{<span class="text-muted">—</span>}
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
@if (line.OrderedAt.HasValue)
|
||||
{<span title="@line.OrderedAt.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("g")">@line.OrderedAt.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM d")</span>}
|
||||
else
|
||||
{<span>—</span>}
|
||||
{<span>—</span>}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="d-flex align-items-center gap-1 justify-content-center receive-form-@line.CoatId">
|
||||
@@ -680,7 +680,7 @@
|
||||
<tr>
|
||||
<td colspan="2">Vendor Total</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">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "—")</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -750,7 +750,7 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Category</label>
|
||||
<select class="form-select" id="apm-categoryId" name="inventoryCategoryId">
|
||||
<option value="">— Select category —</option>
|
||||
<option value="">— Select category —</option>
|
||||
@if (ViewBag.InventoryCategories != null)
|
||||
{
|
||||
foreach (var cat in (IEnumerable<dynamic>)ViewBag.InventoryCategories)
|
||||
@@ -764,7 +764,7 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Primary Vendor</label>
|
||||
<select class="form-select" id="apm-vendorId" name="primaryVendorId">
|
||||
<option value="">— Select vendor —</option>
|
||||
<option value="">— Select vendor —</option>
|
||||
@if (ViewBag.VendorList != null)
|
||||
{
|
||||
foreach (var v in (IEnumerable<dynamic>)ViewBag.VendorList)
|
||||
@@ -838,12 +838,12 @@
|
||||
@section Scripts {
|
||||
<script src="~/js/shop-progress-widget.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
// Start Intake — pushes SignalR event to front-desk tablet
|
||||
// Start Intake — pushes SignalR event to front-desk tablet
|
||||
document.getElementById('btnStartIntake')?.addEventListener('click', async function () {
|
||||
const btn = this;
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sending…';
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sending…';
|
||||
try {
|
||||
const res = await fetch('/Kiosk/StartSession', {
|
||||
method: 'POST',
|
||||
@@ -928,7 +928,7 @@
|
||||
|
||||
const esc = s => s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : '';
|
||||
const estCost = (c.lbsToOrder && c.costPerLb) ? (c.lbsToOrder * c.costPerLb) : null;
|
||||
const orderedDate = c.orderedAt ? new Date(c.orderedAt).toLocaleDateString('en-US',{month:'short',day:'numeric'}) : '—';
|
||||
const orderedDate = c.orderedAt ? new Date(c.orderedAt).toLocaleDateString('en-US',{month:'short',day:'numeric'}) : '—';
|
||||
const lbsFmt = c.lbsToOrder ? parseFloat(c.lbsToOrder).toFixed(2) : '0.00';
|
||||
|
||||
// Find or create vendor group
|
||||
@@ -973,7 +973,7 @@
|
||||
${c.finish ? `<span class="badge bg-light text-dark border ms-1">${esc(c.finish)}</span>` : ''}
|
||||
</td>
|
||||
<td class="text-end fw-medium">${lbsFmt} lbs</td>
|
||||
<td class="text-end">${estCost ? '$' + estCost.toFixed(2) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="text-end">${estCost ? '$' + estCost.toFixed(2) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="text-muted small">${orderedDate}</td>
|
||||
<td class="text-center">
|
||||
<div class="d-flex align-items-center gap-1 justify-content-center receive-form-${c.coatId}">
|
||||
@@ -1024,7 +1024,7 @@
|
||||
}
|
||||
qtyInput.classList.remove('is-invalid');
|
||||
|
||||
// Custom powder (no inventory item) → open modal to add to inventory
|
||||
// Custom powder (no inventory item) â†' open modal to add to inventory
|
||||
if (!hasInv) {
|
||||
const modal = document.getElementById('addPowderModal');
|
||||
// Pre-fill hidden + text fields
|
||||
@@ -1069,7 +1069,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Inventory item exists → receive directly
|
||||
// Inventory item exists â†' receive directly
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
|
||||
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
|
||||
|
||||
@@ -1110,7 +1110,7 @@
|
||||
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
|
||||
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
|
||||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
|
||||
|
||||
try {
|
||||
const resp = await fetch('@Url.Action("AddCustomPowderToInventory", "Dashboard")', {
|
||||
@@ -1139,7 +1139,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── AI Lookup for Add Powder modal ───────────────────────────────────────
|
||||
// -- AI Lookup for Add Powder modal ---------------------------------------
|
||||
(function () {
|
||||
const apmBtn = document.getElementById('apm-ai-btn');
|
||||
const apmStatusEl = document.getElementById('apm-ai-status');
|
||||
@@ -1189,7 +1189,7 @@
|
||||
|
||||
const hasInput = manufacturer || colorName || colorCode || partNumber || itemName;
|
||||
if (!hasInput) {
|
||||
apmShowStatus('warning', '<i class="bi bi-exclamation-triangle me-1"></i>Fill in at least one field — Manufacturer, Color Name, Color Code, or Item Name — then try again.');
|
||||
apmShowStatus('warning', '<i class="bi bi-exclamation-triangle me-1"></i>Fill in at least one field — Manufacturer, Color Name, Color Code, or Item Name — then try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1198,7 +1198,7 @@
|
||||
document.getElementById('apm-bad-match-btn')?.remove();
|
||||
apmBtn.disabled = true;
|
||||
apmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Looking up...';
|
||||
apmShowStatus('info', '<i class="bi bi-hourglass-split me-1"></i>Searching for product specifications…');
|
||||
apmShowStatus('info', '<i class="bi bi-hourglass-split me-1"></i>Searching for product specifications…');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -1265,7 +1265,7 @@
|
||||
: '';
|
||||
apmShowStatus('success', `<i class="bi bi-check-circle me-1"></i>Auto-filled: ${filled.join(', ')}.${reasoning}`);
|
||||
} else {
|
||||
apmShowStatus('warning', '<i class="bi bi-info-circle me-1"></i>No new fields to fill — they may already be populated, or the product wasn\'t found.');
|
||||
apmShowStatus('warning', '<i class="bi bi-info-circle me-1"></i>No new fields to fill — they may already be populated, or the product wasn\'t found.');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
@@ -1319,7 +1319,7 @@
|
||||
(function () {
|
||||
var DISMISSED_KEY = 'pcl_pwa_banner_dismissed';
|
||||
|
||||
// Already installed as standalone — never show
|
||||
// Already installed as standalone — never show
|
||||
var isStandalone = window.navigator.standalone === true ||
|
||||
window.matchMedia('(display-mode: standalone)').matches;
|
||||
if (isStandalone) return;
|
||||
@@ -1343,7 +1343,7 @@
|
||||
var isSafari = /webkit/i.test(ua) && !/crios|chrome|fxios|opios/i.test(ua);
|
||||
if (isSafari) {
|
||||
titleEl.textContent = 'Add to Home Screen';
|
||||
msgEl.innerHTML = 'For the best experience — and so the camera only asks once — open the ' +
|
||||
msgEl.innerHTML = 'For the best experience — and so the camera only asks once — open the ' +
|
||||
'<strong>Share menu</strong> <span style="font-size:1.1em">▲</span> at the bottom of Safari ' +
|
||||
'and tap <strong>Add to Home Screen</strong>.';
|
||||
} else {
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Mobile card view — shown on screens < 992px -->
|
||||
<!-- Mobile card view — shown on screens < 992px -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@if (!Model.Any())
|
||||
@@ -188,7 +188,7 @@
|
||||
}
|
||||
@foreach (var tip in Model)
|
||||
{
|
||||
var tipPreview = tip.TipText.Length > 60 ? tip.TipText.Substring(0, 60) + "…" : tip.TipText;
|
||||
var tipPreview = tip.TipText.Length > 60 ? tip.TipText.Substring(0, 60) + "…" : tip.TipText;
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon bg-warning"><i class="bi bi-lightbulb"></i></div>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<h6 class="mb-0">Select a Company</h6>
|
||||
<input type="text" id="companySearch" class="form-control form-control-sm w-auto"
|
||||
placeholder="Search…" style="min-width:180px" />
|
||||
placeholder="Search…" style="min-width:180px" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive" style="max-height:520px;overflow-y:auto">
|
||||
@@ -101,7 +101,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card view — shown on screens < 992px -->
|
||||
<!-- Mobile card view — shown on screens < 992px -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var c in Model)
|
||||
@@ -148,7 +148,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Right: export options — always visible *@
|
||||
@* Right: export options — always visible *@
|
||||
<div class="col-lg-5">
|
||||
<div class="card border-0 shadow-sm" style="position:sticky;top:1rem">
|
||||
<div class="card-header bg-primary text-white py-2">
|
||||
@@ -156,14 +156,14 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<!-- Company selection banner — shown/hidden by JS -->
|
||||
<!-- Company selection banner — shown/hidden by JS -->
|
||||
<div id="noCompanyBanner" class="alert alert-light alert-permanent border d-flex align-items-center gap-2 mb-3 small">
|
||||
<i class="bi bi-arrow-left-circle fs-5 text-muted"></i>
|
||||
<span>Select a company from the list to begin.</span>
|
||||
</div>
|
||||
<div id="selectedBanner" class="alert alert-info alert-permanent py-2 mb-3 small" style="display:none">
|
||||
<i class="bi bi-building me-1"></i>
|
||||
Exporting: <strong id="selectedCompanyName">—</strong>
|
||||
Exporting: <strong id="selectedCompanyName">—</strong>
|
||||
</div>
|
||||
|
||||
<form method="post" asp-action="Export" id="exportForm">
|
||||
@@ -266,7 +266,7 @@
|
||||
<input class="form-check-input" type="radio" name="format" id="fmtCsv" value="csv" />
|
||||
<label class="form-check-label" for="fmtCsv">
|
||||
<i class="bi bi-filetype-csv me-1 text-secondary"></i>CSV (.zip)
|
||||
<span class="text-muted small">— one file per sheet</span>
|
||||
<span class="text-muted small">— one file per sheet</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,7 +333,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ── Format toggle — update button label ──────────────────────────────────
|
||||
// ── Format toggle — update button label ──────────────────────────────────
|
||||
document.querySelectorAll('input[name="format"]').forEach(function (radio) {
|
||||
radio.addEventListener('change', function () {
|
||||
var isCsv = this.value === 'csv';
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
<div class="alert alert-warning alert-permanent d-flex gap-3 align-items-start mb-3">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-4 flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>Destructive operation — this cannot be undone.</strong>
|
||||
<strong>Destructive operation — this cannot be undone.</strong>
|
||||
Purging permanently deletes records from the database. Soft-deleted records are hidden from users but still occupy database space. Use this tool periodically to reclaim space and keep the database clean.
|
||||
Job photo blobs in Azure Storage are also deleted when purging job photo records.
|
||||
</div>
|
||||
@@ -88,8 +88,8 @@
|
||||
<th style="width:36px"></th>
|
||||
<th>Entity</th>
|
||||
<th class="text-end" style="width:90px">Total</th>
|
||||
<th class="text-end" style="width:100px">0–30d</th>
|
||||
<th class="text-end" style="width:100px">30–90d</th>
|
||||
<th class="text-end" style="width:100px">0–30d</th>
|
||||
<th class="text-end" style="width:100px">30–90d</th>
|
||||
<th class="text-end" style="width:100px">>90d</th>
|
||||
<th style="width:130px">Oldest</th>
|
||||
<th style="width:42px">
|
||||
@@ -114,7 +114,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@@ -122,24 +122,24 @@
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success">@s.DeletedLast30Days</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (s.Deleted30To90Days > 0)
|
||||
{
|
||||
<span class="badge bg-warning-subtle text-warning">@s.Deleted30To90Days</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (s.DeletedOlderThan90Days > 0)
|
||||
{
|
||||
<span class="badge bg-danger-subtle text-danger">@s.DeletedOlderThan90Days</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-muted">
|
||||
@(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")
|
||||
@(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<input type="checkbox" class="form-check-input entity-select"
|
||||
@@ -154,7 +154,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card view for this group — shown on screens < 992px -->
|
||||
<!-- Mobile card view for this group — shown on screens < 992px -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="px-3 pt-2 pb-1">
|
||||
<span class="text-muted text-uppercase fw-semibold" style="font-size:0.7rem;letter-spacing:.05em">@group.Key</span>
|
||||
@@ -169,7 +169,7 @@
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@s.Label</h6>
|
||||
<small>Oldest: @(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")</small>
|
||||
<small>Oldest: @(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
@@ -180,11 +180,11 @@
|
||||
{
|
||||
<span class="badge bg-secondary">@s.Total</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">0–30d / 30–90d / >90d</span>
|
||||
<span class="mobile-card-label">0–30d / 30–90d / >90d</span>
|
||||
<span class="mobile-card-value">@s.DeletedLast30Days / @s.Deleted30To90Days / @s.DeletedOlderThan90Days</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -349,7 +349,7 @@
|
||||
}
|
||||
|
||||
previewBtn.disabled = true;
|
||||
previewBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Loading…';
|
||||
previewBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Loading…';
|
||||
|
||||
const days = document.getElementById('olderThanDays').value;
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Web.Controllers.DiagnosticsInfo
|
||||
@model PowderCoating.Web.Controllers.DiagnosticsInfo
|
||||
@{
|
||||
ViewData["Title"] = "System Diagnostics";
|
||||
ViewData["PageIcon"] = "bi-activity";
|
||||
@@ -52,11 +52,11 @@
|
||||
<td>
|
||||
@if (Model.CanWriteToAppPath)
|
||||
{
|
||||
<span class="badge bg-success">✓ YES</span>
|
||||
<span class="badge bg-success">âœ" YES</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">✗ NO - PERMISSION ISSUE</span>
|
||||
<span class="badge bg-danger">✗ NO - PERMISSION ISSUE</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -65,11 +65,11 @@
|
||||
<td>
|
||||
@if (Model.LogsDirectoryExists)
|
||||
{
|
||||
<span class="badge bg-success">✓ YES</span>
|
||||
<span class="badge bg-success">âœ" YES</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-warning">✗ NO</span>
|
||||
<span class="badge bg-warning">✗ NO</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -78,11 +78,11 @@
|
||||
<td>
|
||||
@if (Model.CanWriteToLogsPath)
|
||||
{
|
||||
<span class="badge bg-success">✓ YES</span>
|
||||
<span class="badge bg-success">âœ" YES</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">✗ NO - PERMISSION ISSUE</span>
|
||||
<span class="badge bg-danger">✗ NO - PERMISSION ISSUE</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@using PowderCoating.Web.Controllers
|
||||
@model AdminEmailPreviewModel
|
||||
@{
|
||||
ViewData["Title"] = "Preview Admin Email";
|
||||
@@ -78,7 +78,7 @@
|
||||
<div class="small text-muted">@(string.IsNullOrWhiteSpace(row.RecipientEmail) ? "No primary contact email configured" : row.RecipientEmail)</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>@(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "—" : row.CompanyAdminName)</div>
|
||||
<div>@(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "—" : row.CompanyAdminName)</div>
|
||||
@if (!string.IsNullOrWhiteSpace(row.CompanyAdminEmail))
|
||||
{
|
||||
<div class="small text-muted">@row.CompanyAdminEmail</div>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<div class="fw-semibold">@company.CompanyName</div>
|
||||
<div class="small text-muted">#@company.CompanyId</div>
|
||||
</td>
|
||||
<td>@(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "—" : company.PrimaryContactName)</td>
|
||||
<td>@(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "—" : company.PrimaryContactName)</td>
|
||||
<td>
|
||||
@if (string.IsNullOrWhiteSpace(company.PrimaryContactEmail))
|
||||
{
|
||||
@@ -87,7 +87,7 @@
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div>@(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "—" : company.CompanyAdminName)</div>
|
||||
<div>@(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "—" : company.CompanyAdminName)</div>
|
||||
@if (!string.IsNullOrWhiteSpace(company.CompanyAdminEmail))
|
||||
{
|
||||
<div class="small text-muted">@company.CompanyAdminEmail</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
ViewData["Title"] = "Add New Equipment";
|
||||
ViewData["PageIcon"] = "bi-tools";
|
||||
ViewData["PageHelpTitle"] = "Add New Equipment";
|
||||
ViewData["PageHelpContent"] = "Equipment records track physical assets in your shop — ovens, spray booths, compressors, and other machinery. Enter a name, type, and location at minimum. Serial number and warranty details help with service claims. The maintenance interval drives the Next Scheduled date shown on the equipment list.";
|
||||
ViewData["PageHelpContent"] = "Equipment records track physical assets in your shop — ovens, spray booths, compressors, and other machinery. Enter a name, type, and location at minimum. Serial number and warranty details help with service claims. The maintenance interval drives the Next Scheduled date shown on the equipment list.";
|
||||
var statusList = ViewBag.StatusList as Array ?? Array.Empty<object>();
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Location</label>
|
||||
<p class="mb-0">@(Model.Location ?? "—")</p>
|
||||
<p class="mb-0">@(Model.Location ?? "—")</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Status</label>
|
||||
@@ -76,15 +76,15 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small mb-1">Manufacturer</label>
|
||||
<p class="mb-0">@(Model.Manufacturer ?? "—")</p>
|
||||
<p class="mb-0">@(Model.Manufacturer ?? "—")</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small mb-1">Model</label>
|
||||
<p class="mb-0">@(Model.Model ?? "—")</p>
|
||||
<p class="mb-0">@(Model.Model ?? "—")</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small mb-1">Serial Number</label>
|
||||
<p class="mb-0">@(Model.SerialNumber ?? "—")</p>
|
||||
<p class="mb-0">@(Model.SerialNumber ?? "—")</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,15 +94,15 @@
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small mb-1">Manufacturer</label>
|
||||
<p class="mb-0">@(Model.Manufacturer ?? "—")</p>
|
||||
<p class="mb-0">@(Model.Manufacturer ?? "—")</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small mb-1">Model</label>
|
||||
<p class="mb-0">@(Model.Model ?? "—")</p>
|
||||
<p class="mb-0">@(Model.Model ?? "—")</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small mb-1">Serial Number</label>
|
||||
<p class="mb-0">@(Model.SerialNumber ?? "—")</p>
|
||||
<p class="mb-0">@(Model.SerialNumber ?? "—")</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +157,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Maintenance Schedule"
|
||||
data-bs-content="Maintenance Interval is how many days between scheduled services — set this on the Edit page. Next Scheduled is calculated automatically as Last Maintenance date plus the interval. If no maintenance has been completed yet, Next Scheduled will be blank until the first service is recorded as Completed.">
|
||||
data-bs-content="Maintenance Interval is how many days between scheduled services — set this on the Edit page. Next Scheduled is calculated automatically as Last Maintenance date plus the interval. If no maintenance has been completed yet, Next Scheduled will be blank until the first service is recorded as Completed.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
ViewData["Title"] = "Equipment";
|
||||
ViewData["PageIcon"] = "bi-tools";
|
||||
ViewData["PageHelpTitle"] = "Equipment";
|
||||
ViewData["PageHelpContent"] = "Track all shop equipment — ovens, spray booths, compressors, and other machinery. Status shows whether each piece is Operational, Needs Maintenance, Under Maintenance, or Out of Service. Next Maintenance date is calculated from the last completed maintenance plus the equipment's maintenance interval. Click any row to view full details and maintenance history.";
|
||||
ViewData["PageHelpContent"] = "Track all shop equipment — ovens, spray booths, compressors, and other machinery. Status shows whether each piece is Operational, Needs Maintenance, Under Maintenance, or Out of Service. Next Maintenance date is calculated from the last completed maintenance plus the equipment's maintenance interval. Click any row to view full details and maintenance history.";
|
||||
}
|
||||
|
||||
<div class="pcl-metric-strip">
|
||||
@@ -123,7 +123,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
ViewData["Title"] = "New Expense";
|
||||
ViewData["PageIcon"] = "bi-receipt";
|
||||
ViewData["PageHelpTitle"] = "New Expense";
|
||||
ViewData["PageHelpContent"] = "Use this for purchases paid immediately — credit card swipes, cash payments, debit transactions. For vendor invoices paid later, use Bills instead. Select the expense account (what was bought) and the payment account (where the money came from).";
|
||||
ViewData["PageHelpContent"] = "Use this for purchases paid immediately — credit card swipes, cash payments, debit transactions. For vendor invoices paid later, use Bills instead. Select the expense account (what was bought) and the payment account (where the money came from).";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-start mb-4">
|
||||
@@ -40,13 +40,13 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Expense Account"
|
||||
data-bs-content="The expense category this purchase belongs to — e.g. Supplies, Materials, Utilities, Fuel. This account is debited when the expense is saved. Choose the most specific account that fits to keep your reports accurate.">
|
||||
data-bs-content="The expense category this purchase belongs to — e.g. Supplies, Materials, Utilities, Fuel. This account is debited when the expense is saved. Choose the most specific account that fits to keep your reports accurate.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<select asp-for="ExpenseAccountId" asp-items="ViewBag.ExpenseAccounts" class="form-select" id="expenseAccountSelect">
|
||||
<option value="">— Select Account —</option>
|
||||
<option value="">— Select Account —</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary text-nowrap" id="expAiSuggestBtn" title="AI-suggest expense account">
|
||||
<i class="bi bi-stars me-1"></i>AI Suggest
|
||||
@@ -66,12 +66,12 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Paid From"
|
||||
data-bs-content="The bank or cash account the money came out of — e.g. Business Checking, Petty Cash, Company Credit Card. This account is credited when the expense is saved. Used for bank reconciliation.">
|
||||
data-bs-content="The bank or cash account the money came out of — e.g. Business Checking, Petty Cash, Company Credit Card. This account is credited when the expense is saved. Used for bank reconciliation.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="PaymentAccountId" asp-items="ViewBag.PaymentAccounts" class="form-select">
|
||||
<option value="">— Select Account —</option>
|
||||
<option value="">— Select Account —</option>
|
||||
</select>
|
||||
<span asp-validation-for="PaymentAccountId" class="text-danger small"></span>
|
||||
</div>
|
||||
@@ -84,8 +84,8 @@
|
||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-muted small">(optional)</span></label>
|
||||
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
|
||||
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
||||
<option value="">— None —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
<option value="">— None —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
@@ -99,7 +99,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="JobId" asp-items="ViewBag.Jobs" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<option value="">— None —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── AI Suggest Account ────────────────────────────────────────────────
|
||||
// -- AI Suggest Account ------------------------------------------------
|
||||
let _expAiSuggestedAccountId = null;
|
||||
|
||||
document.getElementById('expAiSuggestBtn').addEventListener('click', async function () {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
ViewData["Title"] = $"Expense {Model.ExpenseNumber}";
|
||||
ViewData["PageIcon"] = "bi-receipt";
|
||||
ViewData["PageHelpTitle"] = "Expense";
|
||||
ViewData["PageHelpContent"] = "A direct purchase paid at the time of transaction. Expense Account shows what was bought; Paid From shows which bank/cash account was debited. Edit to correct any details. Delete permanently removes the record — there is no Void for expenses.";
|
||||
ViewData["PageHelpContent"] = "A direct purchase paid at the time of transaction. Expense Account shows what was bought; Paid From shows which bank/cash account was debited. Edit to correct any details. Delete permanently removes the record — there is no Void for expenses.";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
@@ -170,7 +170,7 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="receiptModalLabel">
|
||||
<i class="bi bi-receipt me-2"></i>Receipt — @Model.ExpenseNumber
|
||||
<i class="bi bi-receipt me-2"></i>Receipt — @Model.ExpenseNumber
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
@@ -40,12 +40,12 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Expense Account"
|
||||
data-bs-content="The expense category this purchase belongs to — e.g. Supplies, Materials, Utilities, Fuel. This account is debited when the expense is saved. Choose the most specific account that fits to keep your reports accurate.">
|
||||
data-bs-content="The expense category this purchase belongs to — e.g. Supplies, Materials, Utilities, Fuel. This account is debited when the expense is saved. Choose the most specific account that fits to keep your reports accurate.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="ExpenseAccountId" asp-items="ViewBag.ExpenseAccounts" class="form-select">
|
||||
<option value="">— Select Account —</option>
|
||||
<option value="">— Select Account —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
@@ -54,12 +54,12 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Paid From"
|
||||
data-bs-content="The bank or cash account the money came out of — e.g. Business Checking, Petty Cash, Company Credit Card. This account is credited when the expense is saved. Used for bank reconciliation.">
|
||||
data-bs-content="The bank or cash account the money came out of — e.g. Business Checking, Petty Cash, Company Credit Card. This account is credited when the expense is saved. Used for bank reconciliation.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="PaymentAccountId" asp-items="ViewBag.PaymentAccounts" class="form-select">
|
||||
<option value="">— Select Account —</option>
|
||||
<option value="">— Select Account —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
@@ -70,8 +70,8 @@
|
||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor</label>
|
||||
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
|
||||
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
||||
<option value="">— None —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
<option value="">— None —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
@@ -85,7 +85,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="JobId" asp-items="ViewBag.Jobs" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<option value="">— None —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<input type="search" name="search" value="@ViewBag.Search" class="form-control"
|
||||
placeholder="Search memo or vendor…" />
|
||||
placeholder="Search memo or vendor…" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="accountId" class="form-select">
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Asset Account</label>
|
||||
<select name="AssetAccountId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<option value="">— None —</option>
|
||||
@if (ViewBag.AssetAccounts != null)
|
||||
{
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AssetAccounts)
|
||||
@@ -85,7 +85,7 @@
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Depreciation Expense Account</label>
|
||||
<select name="DepreciationExpenseAccountId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<option value="">— None —</option>
|
||||
@if (ViewBag.ExpenseAccounts != null)
|
||||
{
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||
@@ -99,7 +99,7 @@
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Accumulated Depreciation Account</label>
|
||||
<select name="AccumDepreciationAccountId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<option value="">— None —</option>
|
||||
@if (ViewBag.AccumDeprecAccounts != null)
|
||||
{
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AccumDeprecAccounts)
|
||||
@@ -108,7 +108,7 @@
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<div class="form-text">Contra-asset account (e.g., 1510 Accum. Depreciation — Equipment).</div>
|
||||
<div class="form-text">Contra-asset account (e.g., 1510 Accum. Depreciation — Equipment).</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -142,21 +142,21 @@
|
||||
{
|
||||
<div class="mb-2">
|
||||
<div class="text-muted small">Asset Account</div>
|
||||
<div class="fw-semibold">@Model.AssetAccount.AccountNumber – @Model.AssetAccount.Name</div>
|
||||
<div class="fw-semibold">@Model.AssetAccount.AccountNumber – @Model.AssetAccount.Name</div>
|
||||
</div>
|
||||
}
|
||||
@if (Model.DepreciationExpenseAccount != null)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<div class="text-muted small">Depreciation Expense</div>
|
||||
<div class="fw-semibold">@Model.DepreciationExpenseAccount.AccountNumber – @Model.DepreciationExpenseAccount.Name</div>
|
||||
<div class="fw-semibold">@Model.DepreciationExpenseAccount.AccountNumber – @Model.DepreciationExpenseAccount.Name</div>
|
||||
</div>
|
||||
}
|
||||
@if (Model.AccumDepreciationAccount != null)
|
||||
{
|
||||
<div>
|
||||
<div class="text-muted small">Accumulated Depreciation</div>
|
||||
<div class="fw-semibold">@Model.AccumDepreciationAccount.AccountNumber – @Model.AccumDepreciationAccount.Name</div>
|
||||
<div class="fw-semibold">@Model.AccumDepreciationAccount.AccountNumber – @Model.AccumDepreciationAccount.Name</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -206,7 +206,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-muted small">@e.CreatedAt.ToLocalTime().ToString("MM/dd/yyyy")</td>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<div class="col-md-4">
|
||||
<label asp-for="AssetAccountId" class="form-label">Asset Account</label>
|
||||
<select asp-for="AssetAccountId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<option value="">— None —</option>
|
||||
@if (ViewBag.AssetAccounts != null)
|
||||
{
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AssetAccounts)
|
||||
@@ -88,7 +88,7 @@
|
||||
<div class="col-md-4">
|
||||
<label asp-for="DepreciationExpenseAccountId" class="form-label">Depreciation Expense Account</label>
|
||||
<select asp-for="DepreciationExpenseAccountId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<option value="">— None —</option>
|
||||
@if (ViewBag.ExpenseAccounts != null)
|
||||
{
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||
@@ -101,7 +101,7 @@
|
||||
<div class="col-md-4">
|
||||
<label asp-for="AccumDepreciationAccountId" class="form-label">Accumulated Depreciation Account</label>
|
||||
<select asp-for="AccumDepreciationAccountId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<option value="">— None —</option>
|
||||
@if (ViewBag.AccumDeprecAccounts != null)
|
||||
{
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AccumDeprecAccounts)
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="RecipientEmail" class="form-label fw-semibold"></label>
|
||||
<input asp-for="RecipientEmail" type="email" class="form-control" placeholder="Optional — for emailing the certificate" />
|
||||
<input asp-for="RecipientEmail" type="email" class="form-control" placeholder="Optional — for emailing the certificate" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<strong>@statusLabel</strong>
|
||||
@if (isActive)
|
||||
{
|
||||
<span class="ms-2">— <strong class="fs-5">@Model.RemainingBalance.ToString("C")</strong> remaining of @Model.OriginalAmount.ToString("C") face value</span>
|
||||
<span class="ms-2">— <strong class="fs-5">@Model.RemainingBalance.ToString("C")</strong> remaining of @Model.OriginalAmount.ToString("C") face value</span>
|
||||
}
|
||||
@if (Model.ExpiryDate.HasValue)
|
||||
{
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<p>
|
||||
When you make a payment to a vendor, you record it against the bill and the balance reduces.
|
||||
This gives you a clear, real-time picture of your upcoming financial obligations and helps you
|
||||
avoid late payments — and the late fees or strained vendor relationships that come with them.
|
||||
avoid late payments — and the late fees or strained vendor relationships that come with them.
|
||||
</p>
|
||||
<p>
|
||||
Bills can be created manually or generated automatically from a received Purchase Order.
|
||||
@@ -46,14 +46,14 @@
|
||||
<p>
|
||||
The fastest and most accurate way to create a bill is from a received Purchase Order. Open the
|
||||
received PO and click <strong>Create Bill</strong>. The system generates a bill pre-filled with
|
||||
all line items, quantities, and prices from the PO — linked to the vendor and expense accounts
|
||||
all line items, quantities, and prices from the PO — linked to the vendor and expense accounts
|
||||
automatically. See the <a asp-controller="Help" asp-action="PurchaseOrders">Purchase Orders help page</a>
|
||||
for step-by-step instructions.
|
||||
</p>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Manually</h3>
|
||||
<p>
|
||||
To create a bill that is not linked to a PO — for example, a utility bill, a service invoice,
|
||||
To create a bill that is not linked to a PO — for example, a utility bill, a service invoice,
|
||||
or a vendor charge that arrived without a matching order:
|
||||
</p>
|
||||
<ol class="mb-3">
|
||||
@@ -64,8 +64,8 @@
|
||||
<li class="mb-2">
|
||||
Add one or more <strong>line items</strong>:
|
||||
<ul class="mt-1">
|
||||
<li><strong>Expense Account</strong> — the accounting category this cost belongs to (e.g., Cost of Goods Sold, Shop Supplies, Equipment Maintenance).</li>
|
||||
<li><strong>Description</strong> — a brief note about what this line covers.</li>
|
||||
<li><strong>Expense Account</strong> — the accounting category this cost belongs to (e.g., Cost of Goods Sold, Shop Supplies, Equipment Maintenance).</li>
|
||||
<li><strong>Description</strong> — a brief note about what this line covers.</li>
|
||||
<li><strong>Quantity</strong> and <strong>Unit Price</strong>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -129,8 +129,8 @@
|
||||
<i class="bi bi-check-circle text-primary me-2"></i>Marking a Bill as Open
|
||||
</h2>
|
||||
<p>
|
||||
When you have verified that a bill is accurate — it matches the goods you received and the
|
||||
prices you agreed on — you mark it as Open to post it to your AP ledger.
|
||||
When you have verified that a bill is accurate — it matches the goods you received and the
|
||||
prices you agreed on — you mark it as Open to post it to your AP ledger.
|
||||
</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Open the Draft bill from <strong>Accounting › Bills</strong>.</li>
|
||||
@@ -160,7 +160,7 @@
|
||||
<i class="bi bi-cash-coin text-primary me-2"></i>Recording a Payment
|
||||
</h2>
|
||||
<p>
|
||||
When you pay a vendor — whether in full or as a partial payment — you record the payment against
|
||||
When you pay a vendor — whether in full or as a partial payment — you record the payment against
|
||||
the open bill. The system supports multiple partial payments on a single bill.
|
||||
</p>
|
||||
<ol class="mb-3">
|
||||
@@ -169,11 +169,11 @@
|
||||
<li class="mb-2">
|
||||
Enter the payment details:
|
||||
<ul class="mt-1">
|
||||
<li><strong>Amount</strong> — how much you are paying now. Can be less than the full balance for partial payments.</li>
|
||||
<li><strong>Payment Method</strong> — Check, ACH / Bank Transfer, Credit Card, Cash, or Wire Transfer.</li>
|
||||
<li><strong>Payment Date</strong> — the date the payment was made or will be made.</li>
|
||||
<li><strong>Reference Number</strong> — check number, wire confirmation, or ACH batch ID. Always fill this in for non-cash payments to simplify reconciliation.</li>
|
||||
<li><strong>Notes</strong> — any additional information about this payment.</li>
|
||||
<li><strong>Amount</strong> — how much you are paying now. Can be less than the full balance for partial payments.</li>
|
||||
<li><strong>Payment Method</strong> — Check, ACH / Bank Transfer, Credit Card, Cash, or Wire Transfer.</li>
|
||||
<li><strong>Payment Date</strong> — the date the payment was made or will be made.</li>
|
||||
<li><strong>Reference Number</strong> — check number, wire confirmation, or ACH batch ID. Always fill this in for non-cash payments to simplify reconciliation.</li>
|
||||
<li><strong>Notes</strong> — any additional information about this payment.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-2">Click <strong>Save Payment</strong>.</li>
|
||||
@@ -197,7 +197,7 @@
|
||||
<li class="mb-2">Go to <strong>Accounting › Bills</strong> and click <strong>Scan Receipt</strong>.</li>
|
||||
<li class="mb-2">Upload a photo (JPG/PNG) or a PDF of the vendor's invoice.</li>
|
||||
<li class="mb-2">The AI reads the document and pre-fills the vendor, bill date, line items, and amounts into a new draft bill.</li>
|
||||
<li class="mb-2">Review the extracted data — correct any fields that were misread — and save the bill.</li>
|
||||
<li class="mb-2">Review the extracted data — correct any fields that were misread — and save the bill.</li>
|
||||
</ol>
|
||||
<p>
|
||||
The uploaded file is automatically attached to the bill so you always have the original
|
||||
@@ -245,13 +245,13 @@
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1"><strong>Vendor name</strong> and detected frequency (monthly, quarterly, biannual, annual).</li>
|
||||
<li class="mb-1"><strong>Typical amount</strong> — the usual charge from that vendor.</li>
|
||||
<li class="mb-1"><strong>Next expected date</strong> — Claude's estimate of when the next bill is likely to arrive.</li>
|
||||
<li class="mb-1"><strong>Confidence badge</strong> — High (4+ consistent occurrences), Medium (2–3 occurrences or variable timing), Low (weak pattern, worth monitoring).</li>
|
||||
<li class="mb-1"><strong>Suggested action</strong> — for example, "Set a monthly reminder for this bill."</li>
|
||||
<li class="mb-1"><strong>Typical amount</strong> — the usual charge from that vendor.</li>
|
||||
<li class="mb-1"><strong>Next expected date</strong> — Claude's estimate of when the next bill is likely to arrive.</li>
|
||||
<li class="mb-1"><strong>Confidence badge</strong> — High (4+ consistent occurrences), Medium (2–3 occurrences or variable timing), Low (weak pattern, worth monitoring).</li>
|
||||
<li class="mb-1"><strong>Suggested action</strong> — for example, "Set a monthly reminder for this bill."</li>
|
||||
</ul>
|
||||
<p>
|
||||
This is useful for cash flow planning — knowing that a $1,200 electricity bill arrives on the
|
||||
This is useful for cash flow planning — knowing that a $1,200 electricity bill arrives on the
|
||||
15th every month, or that your insurance renews every January, lets you reserve funds in advance
|
||||
and avoid surprises. High-confidence patterns are reliable enough to act on; Low-confidence
|
||||
patterns are worth keeping an eye on but should not be treated as certain.
|
||||
@@ -261,7 +261,7 @@
|
||||
<div>
|
||||
Recurring bill detection requires at least 2 occurrences of a vendor bill at a similar
|
||||
interval to detect a pattern. Shops with less than 2 months of history will see few or no
|
||||
results. The scan covers bills only — direct expenses are not included.
|
||||
results. The scan covers bills only — direct expenses are not included.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -271,17 +271,17 @@
|
||||
<i class="bi bi-folder2-open text-primary me-2"></i>Expense Accounts
|
||||
</h2>
|
||||
<p>
|
||||
Each line item on a bill must be assigned to an <strong>expense account</strong> — an accounting
|
||||
Each line item on a bill must be assigned to an <strong>expense account</strong> — an accounting
|
||||
category that determines where the cost appears in your financial reports. Common expense accounts
|
||||
used in a powder coating shop include:
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1"><strong>Cost of Goods Sold (COGS)</strong> — raw materials, powder coatings, and consumables that go directly into jobs.</li>
|
||||
<li class="mb-1"><strong>Shop Supplies</strong> — items used in the shop that are not directly tied to a specific job (masking tape, gloves, cleaning solvents).</li>
|
||||
<li class="mb-1"><strong>Equipment Maintenance</strong> — service, repairs, and parts for your oven, sandblaster, and coating booths.</li>
|
||||
<li class="mb-1"><strong>Utilities</strong> — gas, electricity, and water bills that power the shop.</li>
|
||||
<li class="mb-1"><strong>Rent / Occupancy</strong> — monthly rent or lease payments for your shop premises.</li>
|
||||
<li class="mb-1"><strong>Operating Expenses</strong> — general overhead that does not fit another specific category.</li>
|
||||
<li class="mb-1"><strong>Cost of Goods Sold (COGS)</strong> — raw materials, powder coatings, and consumables that go directly into jobs.</li>
|
||||
<li class="mb-1"><strong>Shop Supplies</strong> — items used in the shop that are not directly tied to a specific job (masking tape, gloves, cleaning solvents).</li>
|
||||
<li class="mb-1"><strong>Equipment Maintenance</strong> — service, repairs, and parts for your oven, sandblaster, and coating booths.</li>
|
||||
<li class="mb-1"><strong>Utilities</strong> — gas, electricity, and water bills that power the shop.</li>
|
||||
<li class="mb-1"><strong>Rent / Occupancy</strong> — monthly rent or lease payments for your shop premises.</li>
|
||||
<li class="mb-1"><strong>Operating Expenses</strong> — general overhead that does not fit another specific category.</li>
|
||||
</ul>
|
||||
<p>
|
||||
The <strong>vendor's default expense account</strong> is set on the vendor record and pre-fills
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</h2>
|
||||
<p>
|
||||
The Customer Intake Kiosk lets walk-in customers fill out their own intake form on a front-desk tablet
|
||||
— no staff assistance required. When they're done, a <strong>customer record</strong> is automatically
|
||||
— no staff assistance required. When they're done, a <strong>customer record</strong> is automatically
|
||||
created (or matched to an existing one), a <strong>Draft Quote or Pending Job</strong> is created
|
||||
depending on your setting, and your team receives an in-app notification.
|
||||
</p>
|
||||
@@ -45,7 +45,7 @@
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
On the tablet, open a browser and navigate to <code>/Kiosk/Welcome</code>. You'll see your
|
||||
company logo and a "Ready" indicator — the tablet is now in kiosk mode.
|
||||
company logo and a "Ready" indicator — the tablet is now in kiosk mode.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Add to Home Screen</strong> on iOS/Android for a full-screen, app-like experience that
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">In-Person (tablet at front desk)</h3>
|
||||
<ol>
|
||||
<li class="mb-1">The tablet sits on the Welcome screen — the customer sees your logo and a "Ready" status dot.</li>
|
||||
<li class="mb-1">The tablet sits on the Welcome screen — the customer sees your logo and a "Ready" status dot.</li>
|
||||
<li class="mb-1">A staff member clicks <strong>Start Intake</strong> on the Dashboard (in the Kiosk card).</li>
|
||||
<li class="mb-1">The tablet detects the new session within 3 seconds and automatically navigates to the intake form.</li>
|
||||
<li class="mb-1">The customer fills out <strong>3 steps</strong>: Contact info → Job description → Terms & signature.</li>
|
||||
@@ -85,7 +85,7 @@
|
||||
<li class="mb-1">Or use the <strong>Send Intake Link</strong> button on the Dashboard Kiosk card.</li>
|
||||
<li class="mb-1">Enter the customer's email address and send.</li>
|
||||
<li class="mb-1">The customer receives an email with a secure link and completes the same 3-step form on their own device.</li>
|
||||
<li class="mb-1">Remote sessions don't require a drawn signature — a checkbox agreement is used instead.</li>
|
||||
<li class="mb-1">Remote sessions don't require a drawn signature — a checkbox agreement is used instead.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
@@ -99,12 +99,12 @@
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Create a Quote</strong> (default) — a Draft quote is created for staff to review and price
|
||||
<strong>Create a Quote</strong> (default) — a Draft quote is created for staff to review and price
|
||||
before work begins. The terms shown to the customer will say "subject to a formal quote." Use this
|
||||
if you price after seeing the parts.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Create a Job</strong> — a Pending job is created immediately. The terms will say "a team
|
||||
<strong>Create a Job</strong> — a Pending job is created immediately. The terms will say "a team
|
||||
member will reach out about pricing." Use this if you price on the spot and want the work order
|
||||
ready right away.
|
||||
</li>
|
||||
@@ -117,16 +117,16 @@
|
||||
</h2>
|
||||
<p>When a customer submits their intake form, the system automatically:</p>
|
||||
<ul>
|
||||
<li><strong>Matches or creates a Customer</strong> — searches by email first, then phone. If no match, a new non-commercial customer record is created.</li>
|
||||
<li><strong>Matches or creates a Customer</strong> — searches by email first, then phone. If no match, a new non-commercial customer record is created.</li>
|
||||
<li>
|
||||
<strong>Creates a Draft Quote or Pending Job</strong> — depending on your
|
||||
<strong>Creates a Draft Quote or Pending Job</strong> — depending on your
|
||||
<a href="/CompanySettings?tab=kiosk">Kiosk Output Setting</a>. Quote mode creates a Draft quote
|
||||
(Normal priority); Job mode creates a Pending job with the customer's description and intake source
|
||||
in Special Instructions.
|
||||
</li>
|
||||
<li><strong>Applies SMS consent</strong> — if the customer opted in, their customer record is updated with <code>NotifyBySms = true</code> and the consent timestamp (TCPA-compliant).</li>
|
||||
<li><strong>Applies SMS consent</strong> — if the customer opted in, their customer record is updated with <code>NotifyBySms = true</code> and the consent timestamp (TCPA-compliant).</li>
|
||||
<li>
|
||||
<strong>Fires an in-app notification</strong> — your team's notification bell shows
|
||||
<strong>Fires an in-app notification</strong> — your team's notification bell shows
|
||||
"Walk-in Intake Submitted" (or "Remote Intake Submitted" for remote sessions) with a link to
|
||||
the Intakes page.
|
||||
</li>
|
||||
@@ -147,9 +147,9 @@
|
||||
<li>Job description snippet</li>
|
||||
<li>Session type (In-Person or Remote) and status badge</li>
|
||||
<li>SMS opt-in indicator</li>
|
||||
<li><strong>View Quote</strong> button — appears when the kiosk is set to Quote mode; opens the auto-created draft quote</li>
|
||||
<li><strong>View Job</strong> button — appears when the kiosk is set to Job mode; opens the auto-created job</li>
|
||||
<li><strong>Customer</strong> button — opens the matched or created customer record</li>
|
||||
<li><strong>View Quote</strong> button — appears when the kiosk is set to Quote mode; opens the auto-created draft quote</li>
|
||||
<li><strong>View Job</strong> button — appears when the kiosk is set to Job mode; opens the auto-created job</li>
|
||||
<li><strong>Customer</strong> button — opens the matched or created customer record</li>
|
||||
</ul>
|
||||
<div class="alert alert-info alert-permanent">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
@@ -164,23 +164,23 @@
|
||||
<i class="bi bi-exclamation-triangle text-primary me-2"></i>Troubleshooting
|
||||
</h2>
|
||||
<dl>
|
||||
<dt>Kiosk Welcome screen shows "Connection issue — retrying…"</dt>
|
||||
<dd class="mb-3">The tablet can't reach the server. Check the tablet's Wi-Fi connection. Once connectivity is restored the status dot automatically turns green — no refresh needed.</dd>
|
||||
<dt>Kiosk Welcome screen shows "Connection issue — retrying…"</dt>
|
||||
<dd class="mb-3">The tablet can't reach the server. Check the tablet's Wi-Fi connection. Once connectivity is restored the status dot automatically turns green — no refresh needed.</dd>
|
||||
|
||||
<dt>Kiosk doesn't respond when staff clicks Start Intake</dt>
|
||||
<dd class="mb-3">The tablet polls every 3 seconds. Wait up to 3 seconds after clicking Start Intake. If it still doesn't respond, reload the Welcome page on the tablet. Make sure the tablet is on the same domain as the server (use HTTPS).</dd>
|
||||
|
||||
<dt>The tablet shows the wrong company logo or no logo</dt>
|
||||
<dd class="mb-3">Upload your company logo at Settings → Company Settings → Logo. The kiosk reads your logo directly — no separate kiosk logo setting is needed.</dd>
|
||||
<dd class="mb-3">Upload your company logo at Settings → Company Settings → Logo. The kiosk reads your logo directly — no separate kiosk logo setting is needed.</dd>
|
||||
|
||||
<dt>Signature pad doesn't work on the tablet</dt>
|
||||
<dd class="mb-3">Use a capacitive stylus or fingertip — the signature pad requires touch input. Make sure the browser isn't in desktop mode (check "Request Desktop Site" is off). The signature is only required for In-Person sessions.</dd>
|
||||
<dd class="mb-3">Use a capacitive stylus or fingertip — the signature pad requires touch input. Make sure the browser isn't in desktop mode (check "Request Desktop Site" is off). The signature is only required for In-Person sessions.</dd>
|
||||
|
||||
<dt>Submission fails — no job or customer created</dt>
|
||||
<dt>Submission fails — no job or customer created</dt>
|
||||
<dd class="mb-3">This usually means Seed Data hasn't been run for your company. Ask your administrator to go to Platform Management → Seed Data and run the seed. This creates the required job status and priority lookup rows.</dd>
|
||||
|
||||
<dt>AI quote on the quote wizard times out on mobile</dt>
|
||||
<dd class="mb-3">Photos are automatically compressed before upload. If it still times out, your connection may be slow — the spinner will say "Still analyzing…" if it's taking longer than 30 seconds. Try again on a stronger connection.</dd>
|
||||
<dd class="mb-3">Photos are automatically compressed before upload. If it still times out, your connection may be slow — the spinner will say "Still analyzing…" if it's taking longer than 30 seconds. Try again on a stronger connection.</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<p>
|
||||
The Customers section is the starting point for all work in the shop. Every quote, job, and invoice
|
||||
belongs to a customer record. Keeping your customer list accurate and up to date ensures you can
|
||||
quickly pull up a customer's full history — all their past jobs, outstanding quotes, and unpaid
|
||||
quickly pull up a customer's full history — all their past jobs, outstanding quotes, and unpaid
|
||||
invoices in one place.
|
||||
</p>
|
||||
<p>
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text small mb-0">
|
||||
Use this for businesses — auto body shops, fabricators, manufacturers, or any company
|
||||
Use this for businesses — auto body shops, fabricators, manufacturers, or any company
|
||||
that sends you work regularly. Commercial customers can have a <strong>pricing tier</strong>
|
||||
applied (e.g., volume discounts) and a <strong>credit limit</strong> set. They typically
|
||||
have a company name in addition to a contact person.
|
||||
@@ -64,7 +64,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text small mb-0">
|
||||
Use this for individuals — homeowners who bring in patio furniture, hobbyists with
|
||||
Use this for individuals — homeowners who bring in patio furniture, hobbyists with
|
||||
motorcycle parts, or anyone who is not representing a business. Non-commercial customers
|
||||
typically do not have a company name and are priced at standard retail rates.
|
||||
</p>
|
||||
@@ -81,17 +81,17 @@
|
||||
<p>Follow these steps to add a new customer:</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Go to <strong>Operations › Customers</strong> and click the <strong>New Customer</strong> button in the top-right corner.</li>
|
||||
<li class="mb-2">Choose the <strong>Customer Type</strong> — Commercial or Non-Commercial.</li>
|
||||
<li class="mb-2">Choose the <strong>Customer Type</strong> — Commercial or Non-Commercial.</li>
|
||||
<li class="mb-2">
|
||||
Fill in the customer details:
|
||||
<ul class="mt-1">
|
||||
<li><strong>Company Name</strong> — required for Commercial customers.</li>
|
||||
<li><strong>Contact Name</strong> — the person you deal with day to day.</li>
|
||||
<li><strong>Email</strong> — used for quote and invoice notifications.</li>
|
||||
<li><strong>Phone</strong> — primary contact number.</li>
|
||||
<li><strong>Mobile Phone</strong> — used for SMS notifications. Required if you want to text this customer.</li>
|
||||
<li><strong>SMS Opt-In</strong> — check this only after you have obtained the customer's consent to receive text messages. Visible only when SMS is enabled for your company.</li>
|
||||
<li><strong>Address</strong> — billing and shipping address fields.</li>
|
||||
<li><strong>Company Name</strong> — required for Commercial customers.</li>
|
||||
<li><strong>Contact Name</strong> — the person you deal with day to day.</li>
|
||||
<li><strong>Email</strong> — used for quote and invoice notifications.</li>
|
||||
<li><strong>Phone</strong> — primary contact number.</li>
|
||||
<li><strong>Mobile Phone</strong> — used for SMS notifications. Required if you want to text this customer.</li>
|
||||
<li><strong>SMS Opt-In</strong> — check this only after you have obtained the customer's consent to receive text messages. Visible only when SMS is enabled for your company.</li>
|
||||
<li><strong>Address</strong> — billing and shipping address fields.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-2">For Commercial customers, optionally set a <strong>Pricing Tier</strong> and <strong>Credit Limit</strong>.</li>
|
||||
@@ -136,24 +136,24 @@
|
||||
</p>
|
||||
<p>The details page shows:</p>
|
||||
<ul>
|
||||
<li><strong>Contact information</strong> — name, email, phone, and address.</li>
|
||||
<li><strong>Account summary</strong> — current balance, credit limit, and pricing tier.</li>
|
||||
<li><strong>Contact information</strong> — name, email, phone, and address.</li>
|
||||
<li><strong>Account summary</strong> — current balance, credit limit, and pricing tier.</li>
|
||||
<li>
|
||||
<strong>Jobs tab</strong> — every job created for this customer, with status and date. Click
|
||||
<strong>Jobs tab</strong> — every job created for this customer, with status and date. Click
|
||||
a job number to open it.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Quotes tab</strong> — all quotes sent to this customer, including pending and
|
||||
<strong>Quotes tab</strong> — all quotes sent to this customer, including pending and
|
||||
historical quotes. Click a quote number to open it.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Invoices tab</strong> — all invoices with payment status. Quickly see who owes money
|
||||
<strong>Invoices tab</strong> — all invoices with payment status. Quickly see who owes money
|
||||
and how much.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Deposits tab</strong> — all deposits recorded for this customer across any job or quote.
|
||||
<strong>Deposits tab</strong> — all deposits recorded for this customer across any job or quote.
|
||||
</li>
|
||||
<li><strong>Notes</strong> — any notes saved against the customer record.</li>
|
||||
<li><strong>Notes</strong> — any notes saved against the customer record.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
<p>
|
||||
When a customer's outstanding balance approaches or exceeds their credit limit, the system displays
|
||||
a warning flag on their record and on any new jobs or invoices you try to create for them. This is
|
||||
a visual warning only — the system does not automatically block new work — but it gives your team
|
||||
a visual warning only — the system does not automatically block new work — but it gives your team
|
||||
a clear signal to follow up on payment before starting more jobs.
|
||||
</p>
|
||||
<p>
|
||||
@@ -189,7 +189,7 @@
|
||||
</p>
|
||||
<p>
|
||||
When a tax-exempt customer is selected on a new quote or invoice, the tax rate automatically
|
||||
defaults to <strong>0%</strong> — no manual adjustment needed. Tax-exempt customers are marked
|
||||
defaults to <strong>0%</strong> — no manual adjustment needed. Tax-exempt customers are marked
|
||||
with a ★ in the customer dropdown when creating quotes and invoices so your team can spot them
|
||||
at a glance.
|
||||
</p>
|
||||
|
||||
@@ -20,14 +20,14 @@
|
||||
<i class="bi bi-info-circle text-primary me-2"></i>Overview
|
||||
</h2>
|
||||
<p>
|
||||
The Equipment module lets you keep a full register of every piece of machinery in your shop —
|
||||
The Equipment module lets you keep a full register of every piece of machinery in your shop —
|
||||
curing ovens, sandblasters, coating booths, compressors, conveyors, and anything else you
|
||||
rely on to do the work. Each equipment record includes its current operating status and a
|
||||
complete maintenance log.
|
||||
</p>
|
||||
<p>
|
||||
Keeping this information up to date pays off in two ways. First, your team always knows
|
||||
which machines are available and which are down for service — preventing jobs from being
|
||||
which machines are available and which are down for service — preventing jobs from being
|
||||
scheduled on equipment that is not ready. Second, the maintenance history gives you a
|
||||
paper trail for warranty claims, insurance, and resale.
|
||||
</p>
|
||||
@@ -46,14 +46,14 @@
|
||||
<li class="mb-2">
|
||||
Fill in the equipment details:
|
||||
<ul class="mt-1">
|
||||
<li><strong>Name</strong> — a short, descriptive name (e.g., "Main Curing Oven" or "Blast Cabinet #2").</li>
|
||||
<li><strong>Model / Serial Number</strong> — from the manufacturer's plate on the machine. Useful for warranty and service calls.</li>
|
||||
<li><strong>Manufacturer</strong> — who made the equipment.</li>
|
||||
<li><strong>Purchase Date</strong> — when your shop acquired it.</li>
|
||||
<li><strong>Last Service Date</strong> — the date it was last professionally serviced or inspected.</li>
|
||||
<li><strong>Next Service Due</strong> — when the next scheduled service is due. This triggers alerts on the Dashboard.</li>
|
||||
<li><strong>Location</strong> — where in the shop the equipment is located (e.g., "Bay 1", "Back Room").</li>
|
||||
<li><strong>Notes</strong> — any important operational notes, quirks, or warnings for the team.</li>
|
||||
<li><strong>Name</strong> — a short, descriptive name (e.g., "Main Curing Oven" or "Blast Cabinet #2").</li>
|
||||
<li><strong>Model / Serial Number</strong> — from the manufacturer's plate on the machine. Useful for warranty and service calls.</li>
|
||||
<li><strong>Manufacturer</strong> — who made the equipment.</li>
|
||||
<li><strong>Purchase Date</strong> — when your shop acquired it.</li>
|
||||
<li><strong>Last Service Date</strong> — the date it was last professionally serviced or inspected.</li>
|
||||
<li><strong>Next Service Due</strong> — when the next scheduled service is due. This triggers alerts on the Dashboard.</li>
|
||||
<li><strong>Location</strong> — where in the shop the equipment is located (e.g., "Bay 1", "Back Room").</li>
|
||||
<li><strong>Notes</strong> — any important operational notes, quirks, or warnings for the team.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-2">Set the initial <strong>Status</strong> (see below).</li>
|
||||
@@ -67,7 +67,7 @@
|
||||
</h2>
|
||||
<p>
|
||||
Every piece of equipment has a status that reflects its current condition. Update the status
|
||||
whenever the equipment's situation changes — this keeps the Dashboard accurate and lets
|
||||
whenever the equipment's situation changes — this keeps the Dashboard accurate and lets
|
||||
supervisors quickly see what is available.
|
||||
</p>
|
||||
|
||||
@@ -146,14 +146,14 @@
|
||||
<li class="mb-2">
|
||||
Fill in the details:
|
||||
<ul class="mt-1">
|
||||
<li><strong>Task Description</strong> — what was done or needs to be done (e.g., "Replace heating element", "Annual burner service", "Belt tension check").</li>
|
||||
<li><strong>Type</strong> — choose <strong>Scheduled</strong> for planned preventive maintenance, or <strong>Corrective</strong> for repairs to fix a problem.</li>
|
||||
<li><strong>Scheduled Date</strong> — when the task is planned for (or when it was done).</li>
|
||||
<li><strong>Completion Date</strong> — leave blank if the task has not been done yet.</li>
|
||||
<li><strong>Cost</strong> — what the service cost (parts, labor, contractor fees).</li>
|
||||
<li><strong>Priority</strong> — how urgent this task is (see below).</li>
|
||||
<li><strong>Assigned To</strong> — the shop worker responsible for this task.</li>
|
||||
<li><strong>Notes</strong> — parts used, observations, instructions for next time.</li>
|
||||
<li><strong>Task Description</strong> — what was done or needs to be done (e.g., "Replace heating element", "Annual burner service", "Belt tension check").</li>
|
||||
<li><strong>Type</strong> — choose <strong>Scheduled</strong> for planned preventive maintenance, or <strong>Corrective</strong> for repairs to fix a problem.</li>
|
||||
<li><strong>Scheduled Date</strong> — when the task is planned for (or when it was done).</li>
|
||||
<li><strong>Completion Date</strong> — leave blank if the task has not been done yet.</li>
|
||||
<li><strong>Cost</strong> — what the service cost (parts, labor, contractor fees).</li>
|
||||
<li><strong>Priority</strong> — how urgent this task is (see below).</li>
|
||||
<li><strong>Assigned To</strong> — the shop worker responsible for this task.</li>
|
||||
<li><strong>Notes</strong> — parts used, observations, instructions for next time.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-2">Click <strong>Save</strong>.</li>
|
||||
@@ -167,7 +167,7 @@
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Recurring Maintenance</h3>
|
||||
<p>
|
||||
For maintenance that happens on a regular schedule (e.g., monthly filter cleaning, quarterly
|
||||
burner service), set the <strong>Recurrence</strong> field when creating the record — choose
|
||||
burner service), set the <strong>Recurrence</strong> field when creating the record — choose
|
||||
from Daily, Weekly, Monthly, Quarterly, or Annually. When you complete the record, the system
|
||||
automatically creates the next maintenance record at the appropriate interval. This ensures
|
||||
preventive maintenance tasks never fall through the cracks.
|
||||
@@ -248,7 +248,7 @@
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Go to the Oven Scheduler from the sidebar.</li>
|
||||
<li class="mb-2">Select the oven you are loading.</li>
|
||||
<li class="mb-2">Add jobs to the batch — the remaining capacity updates as you add items.</li>
|
||||
<li class="mb-2">Add jobs to the batch — the remaining capacity updates as you add items.</li>
|
||||
<li class="mb-2">Progress batches through Loading → In Progress → Completed as work is done.</li>
|
||||
</ol>
|
||||
<p>
|
||||
@@ -262,7 +262,7 @@
|
||||
<i class="bi bi-person-gear text-primary me-2"></i>Assigning Maintenance Tasks
|
||||
</h2>
|
||||
<p>
|
||||
Each maintenance record can be assigned to one shop worker — typically someone in the
|
||||
Each maintenance record can be assigned to one shop worker — typically someone in the
|
||||
<strong>Maintenance</strong> role, or a <strong>Supervisor</strong> who will coordinate
|
||||
with an outside service technician.
|
||||
</p>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</h2>
|
||||
<p>
|
||||
This system is purpose-built to run a powder coating shop from end to end. At its core it helps you
|
||||
track every job that comes through the door — from the first quote all the way through coating,
|
||||
track every job that comes through the door — from the first quote all the way through coating,
|
||||
curing, quality check, and final delivery. Along the way it manages your customers, vendors,
|
||||
inventory of powders and supplies, shop equipment, and the workers on your floor.
|
||||
</p>
|
||||
@@ -54,7 +54,7 @@
|
||||
<li class="mb-1">Click <strong>Sign In</strong>. You will land on the Dashboard.</li>
|
||||
</ol>
|
||||
|
||||
<h5 class="fw-semibold">First login — set your permanent password</h5>
|
||||
<h5 class="fw-semibold">First login — set your permanent password</h5>
|
||||
<p>
|
||||
When a new company account is created, the system generates a secure temporary password and emails
|
||||
it to the address used during signup. The first time you log in with that temporary password, you
|
||||
@@ -73,7 +73,7 @@
|
||||
<i class="bi bi-envelope-check-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>Didn't receive the welcome email?</strong> Check your spam or junk folder first.
|
||||
If it's still not there, contact your company administrator — they can reset your password
|
||||
If it's still not there, contact your company administrator — they can reset your password
|
||||
from the User Management section.
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@
|
||||
instructions in the email. Reset links expire after a short time, so use them promptly.
|
||||
</p>
|
||||
<p>
|
||||
If you do not receive an email or cannot log in, contact your company administrator — they can reset
|
||||
If you do not receive an email or cannot log in, contact your company administrator — they can reset
|
||||
your password from the User Management section.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
|
||||
@@ -103,7 +103,7 @@
|
||||
</h2>
|
||||
<p>
|
||||
The left sidebar is your main navigation. It is divided into sections so related features are grouped
|
||||
together. Click any item to go to that section. On smaller screens the sidebar collapses — tap the
|
||||
together. Click any item to go to that section. On smaller screens the sidebar collapses — tap the
|
||||
menu icon at the top to open it.
|
||||
</p>
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-briefcase me-1 text-primary"></i><strong>Operations</strong></td>
|
||||
<td>Customers, Quotes, Jobs, Invoices, Appointments/Calendar, Job Priority Board, Gift Certificates, and Shop Workers — the day-to-day work of the shop.</td>
|
||||
<td>Customers, Quotes, Jobs, Invoices, Appointments/Calendar, Job Priority Board, Gift Certificates, and Shop Workers — the day-to-day work of the shop.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-box-seam me-1 text-primary"></i><strong>Inventory</strong></td>
|
||||
@@ -155,7 +155,7 @@
|
||||
</p>
|
||||
<p>
|
||||
The <strong>notification bell</strong> (<i class="bi bi-bell-fill"></i>) alerts you to customer
|
||||
actions in real time — a quote approved or declined online, an invoice or deposit paid online,
|
||||
actions in real time — a quote approved or declined online, an invoice or deposit paid online,
|
||||
and any platform announcements from the Powder Coating Logix team. A red badge shows the count of
|
||||
unread items. Click the bell to see recent notifications and navigate directly to the related record.
|
||||
Notifications are stored persistently so you will not miss one if you were not logged in when it
|
||||
@@ -170,7 +170,7 @@
|
||||
<p>
|
||||
Every user is assigned a role that controls what they can see and do. Your administrator assigns
|
||||
your role when they create your account. If you cannot access something you expect to, ask your
|
||||
administrator — they may need to update your role.
|
||||
administrator — they may need to update your role.
|
||||
</p>
|
||||
|
||||
<div class="table-responsive">
|
||||
@@ -197,7 +197,7 @@
|
||||
<span class="badge bg-warning text-dark">Manager</span>
|
||||
</td>
|
||||
<td>
|
||||
Can manage all operational data — customers, jobs, quotes, invoices, inventory,
|
||||
Can manage all operational data — customers, jobs, quotes, invoices, inventory,
|
||||
equipment, and workers. Can view reports and adjust settings, but cannot manage
|
||||
user accounts or change platform-level configuration.
|
||||
</td>
|
||||
@@ -248,13 +248,13 @@
|
||||
<div>
|
||||
<strong>New to the system?</strong> Use the <a href="/SetupWizard">Setup Wizard</a> to
|
||||
configure your company profile, operating costs, named ovens, and notifications in a guided
|
||||
5-step walkthrough — takes about 5–10 minutes. After the wizard, the Dashboard will show
|
||||
5-step walkthrough — takes about 5–10 minutes. After the wizard, the Dashboard will show
|
||||
a progress checklist to guide you through your first live workflow.
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
If you prefer to configure things manually, or if you are returning to complete partial setup,
|
||||
work through these steps in order. Each step builds on the last — for example, inventory items
|
||||
work through these steps in order. Each step builds on the last — for example, inventory items
|
||||
need vendors before you can record purchases, and jobs need customers before you can create them.
|
||||
</p>
|
||||
|
||||
@@ -330,7 +330,7 @@
|
||||
|
||||
<section id="after-the-wizard" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-rocket-takeoff text-primary me-2"></i>After the Wizard — Your First Workflow
|
||||
<i class="bi bi-rocket-takeoff text-primary me-2"></i>After the Wizard — Your First Workflow
|
||||
</h2>
|
||||
<p>
|
||||
Once the Setup Wizard is complete, two things appear on your Dashboard to guide you through your
|
||||
@@ -339,13 +339,13 @@
|
||||
|
||||
<h5 class="fw-semibold">Guided Activation</h5>
|
||||
<p>
|
||||
A banner prompts you to run a short first-workflow walkthrough. Choose a starting path — either
|
||||
A banner prompts you to run a short first-workflow walkthrough. Choose a starting path — either
|
||||
<strong>Quote First</strong> (create a quote, get it approved, convert it to a job) or
|
||||
<strong>Job First</strong> (create a job directly). The system creates a sample customer
|
||||
record for you to use during the walkthrough.
|
||||
</p>
|
||||
<p>
|
||||
After the job is created you are taken to the <strong>Daily Board</strong> — your shop in real
|
||||
After the job is created you are taken to the <strong>Daily Board</strong> — your shop in real
|
||||
time. Every active job appears on the board by stage. The new job is highlighted so you can
|
||||
find it easily. Drag it to its next stage to see how your workflow updates live. Once you have
|
||||
moved the job, the board prompts you to create the invoice when the work is done.
|
||||
@@ -367,7 +367,7 @@
|
||||
<p>
|
||||
Each incomplete step shows a description and a button that takes you directly to the right place.
|
||||
The next recommended step is highlighted. The widget disappears once all six steps are done.
|
||||
You can collapse it using the chevron button — the collapsed state is saved in your browser.
|
||||
You can collapse it using the chevron button — the collapsed state is saved in your browser.
|
||||
</p>
|
||||
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||
@@ -388,21 +388,21 @@
|
||||
gives your shop floor workers a much better experience:
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1">Opens full-screen with no browser chrome — feels like a native app.</li>
|
||||
<li class="mb-1">Opens full-screen with no browser chrome — feels like a native app.</li>
|
||||
<li class="mb-1">The camera (used by the inventory label scanner) only asks for permission <strong>once</strong> after installation, instead of every browser session.</li>
|
||||
<li class="mb-1">Faster to launch — one tap from the home screen.</li>
|
||||
<li class="mb-1">Faster to launch — one tap from the home screen.</li>
|
||||
</ul>
|
||||
|
||||
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-4" role="alert">
|
||||
<i class="bi bi-apple flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>iPhone / iPad users must use Safari.</strong> Adding to the home screen from Chrome,
|
||||
Firefox, or other iOS browsers creates a regular bookmark that opens in that browser — not
|
||||
Firefox, or other iOS browsers creates a regular bookmark that opens in that browser — not
|
||||
a standalone app. Only Safari on iOS supports the full home screen install.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-apple me-1"></i>iOS (iPhone / iPad) — Safari only</h3>
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-apple me-1"></i>iOS (iPhone / iPad) — Safari only</h3>
|
||||
<ol class="mb-4">
|
||||
<li class="mb-1">Open the app in <strong>Safari</strong>.</li>
|
||||
<li class="mb-1">Tap the <strong>Share</strong> button <i class="bi bi-box-arrow-up"></i> at the bottom of the screen.</li>
|
||||
@@ -411,10 +411,10 @@
|
||||
<li class="mb-1">The app icon appears on your home screen. Tap it to open in full-screen mode.</li>
|
||||
</ol>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-android2 me-1"></i>Android — Chrome</h3>
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-android2 me-1"></i>Android — Chrome</h3>
|
||||
<ol class="mb-4">
|
||||
<li class="mb-1">Open the app in <strong>Chrome</strong>.</li>
|
||||
<li class="mb-1">Chrome may show an <strong>Install App</strong> banner at the bottom automatically — tap it to install.</li>
|
||||
<li class="mb-1">Chrome may show an <strong>Install App</strong> banner at the bottom automatically — tap it to install.</li>
|
||||
<li class="mb-1">If no banner appears, tap the <strong>menu (⋮)</strong> in the top-right corner and choose <strong>Add to Home Screen</strong> or <strong>Install App</strong>.</li>
|
||||
<li class="mb-1">Confirm and the icon is added to your home screen.</li>
|
||||
</ol>
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="card-title mb-1">Reports</h5>
|
||||
<p class="card-text text-muted small mb-2">Financial summaries, AR aging, job throughput, inventory levels, and equipment status — all in one place.</p>
|
||||
<p class="card-text text-muted small mb-2">Financial summaries, AR aging, job throughput, inventory levels, and equipment status — all in one place.</p>
|
||||
<a asp-controller="Help" asp-action="Reports" class="btn btn-sm btn-outline-primary">Read more <i class="bi bi-arrow-right ms-1"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</p>
|
||||
<p>
|
||||
Keeping inventory accurate matters for two reasons. First, your job and quote pricing is only
|
||||
as accurate as the unit costs stored in inventory — outdated costs lead to under-pricing.
|
||||
as accurate as the unit costs stored in inventory — outdated costs lead to under-pricing.
|
||||
Second, knowing how much powder you have on hand before a job starts prevents the frustrating
|
||||
situation of running out of material mid-job.
|
||||
</p>
|
||||
@@ -45,19 +45,19 @@
|
||||
<li class="mb-2">
|
||||
Fill in the item details:
|
||||
<ul class="mt-1">
|
||||
<li><strong>Item Name</strong> — a clear, descriptive name (e.g., "Gloss Black Powder — Tiger Drylac 49/90005").</li>
|
||||
<li><strong>SKU / Part Number</strong> — the manufacturer's part number or your internal SKU.</li>
|
||||
<li><strong>Category</strong> — Powder, Primer, Consumable, Shop Supply, or other category as appropriate.</li>
|
||||
<li><strong>Unit of Measure</strong> — lbs, kg, each, litre, etc.</li>
|
||||
<li><strong>Unit Cost</strong> — your purchase cost per unit. Used in quote and job pricing calculations.</li>
|
||||
<li><strong>Current Quantity on Hand</strong> — the number of units you have right now. This becomes the opening stock level.</li>
|
||||
<li><strong>Reorder Point</strong> — the quantity at which you want to be alerted to reorder. See the Reorder Points section below.</li>
|
||||
<li><strong>Vendor / Supplier</strong> — the vendor you purchase this item from. Linking a vendor lets you quickly see who to call when stock runs low.</li>
|
||||
<li><strong>Item Name</strong> — a clear, descriptive name (e.g., "Gloss Black Powder — Tiger Drylac 49/90005").</li>
|
||||
<li><strong>SKU / Part Number</strong> — the manufacturer's part number or your internal SKU.</li>
|
||||
<li><strong>Category</strong> — Powder, Primer, Consumable, Shop Supply, or other category as appropriate.</li>
|
||||
<li><strong>Unit of Measure</strong> — lbs, kg, each, litre, etc.</li>
|
||||
<li><strong>Unit Cost</strong> — your purchase cost per unit. Used in quote and job pricing calculations.</li>
|
||||
<li><strong>Current Quantity on Hand</strong> — the number of units you have right now. This becomes the opening stock level.</li>
|
||||
<li><strong>Reorder Point</strong> — the quantity at which you want to be alerted to reorder. See the Reorder Points section below.</li>
|
||||
<li><strong>Vendor / Supplier</strong> — the vendor you purchase this item from. Linking a vendor lets you quickly see who to call when stock runs low.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
For powder coatings, the <strong>Coverage Rate</strong> (sq ft per lb) and <strong>Transfer Efficiency %</strong>
|
||||
default to <strong>30 sq ft/lb</strong> and <strong>65%</strong> respectively — typical starting values for most powder
|
||||
default to <strong>30 sq ft/lb</strong> and <strong>65%</strong> respectively — typical starting values for most powder
|
||||
and application setups. Adjust these to match your specific powder and equipment. Both values are used when
|
||||
calculating powder needed on quotes and jobs.
|
||||
</li>
|
||||
@@ -86,12 +86,12 @@
|
||||
<p>
|
||||
Click the <strong>Lookup</strong> button next to the SKU/Part Number field. Type a color name,
|
||||
SKU, or part number and the system searches a built-in catalog of thousands of Prismatic Powders
|
||||
and other manufacturer SKUs. Select a match and the form fills in automatically — item name,
|
||||
and other manufacturer SKUs. Select a match and the form fills in automatically — item name,
|
||||
manufacturer, color code, finish, coverage rate, SDS/TDS links, and cure specifications.
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1">The catalog only shows products <strong>not already in your inventory</strong>, preventing duplicates. When editing an existing item, its own catalog entry is always shown.</li>
|
||||
<li class="mb-1">If no catalog match is found, the lookup falls back to <strong>AI Lookup</strong> — Claude searches the web for product specs and fills in whatever it can find.</li>
|
||||
<li class="mb-1">If no catalog match is found, the lookup falls back to <strong>AI Lookup</strong> — Claude searches the web for product specs and fills in whatever it can find.</li>
|
||||
<li class="mb-1">If a vendor name is selected in the Vendor field before searching, results are scoped to that vendor first, then broadened automatically if nothing matches.</li>
|
||||
</ul>
|
||||
|
||||
@@ -102,11 +102,11 @@
|
||||
The scanner reads the code and attempts to identify the product:
|
||||
</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-1">If the QR code matches a product in the platform catalog, the form fills in automatically — same as a manual catalog lookup.</li>
|
||||
<li class="mb-1">If the QR code matches a product in the platform catalog, the form fills in automatically — same as a manual catalog lookup.</li>
|
||||
<li class="mb-1">If no catalog match is found, the AI analyzes the label image and fills in whatever details it can extract (color name, SKU, manufacturer, finish).</li>
|
||||
<li class="mb-1">
|
||||
If the scanned product is <strong>already in your inventory</strong>, a prompt appears to
|
||||
<strong>Add Stock</strong> to the existing item instead — enter the quantity received and an
|
||||
<strong>Add Stock</strong> to the existing item instead — enter the quantity received and an
|
||||
optional updated unit cost, then save. No duplicate item is created.
|
||||
</li>
|
||||
</ol>
|
||||
@@ -127,7 +127,7 @@
|
||||
</h2>
|
||||
<p>
|
||||
The <strong>quantity on hand</strong> for each item is updated automatically whenever a transaction
|
||||
is recorded — a purchase receipt increases stock, a job consumption decreases it, and a manual
|
||||
is recorded — a purchase receipt increases stock, a job consumption decreases it, and a manual
|
||||
adjustment sets it to the corrected count.
|
||||
</p>
|
||||
<p>
|
||||
@@ -142,8 +142,8 @@
|
||||
A good reorder point accounts for two factors:
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1"><strong>Lead time</strong> — how many days it typically takes for your vendor to deliver after you place an order. If lead time is 5 days, your reorder point should cover at least 5 days of usage.</li>
|
||||
<li class="mb-1"><strong>Daily usage rate</strong> — how much of the item you typically consume per day based on your job volume.</li>
|
||||
<li class="mb-1"><strong>Lead time</strong> — how many days it typically takes for your vendor to deliver after you place an order. If lead time is 5 days, your reorder point should cover at least 5 days of usage.</li>
|
||||
<li class="mb-1"><strong>Daily usage rate</strong> — how much of the item you typically consume per day based on your job volume.</li>
|
||||
</ul>
|
||||
<p>
|
||||
For example, if you use 3 lbs of a powder per day and your vendor takes 5 days to deliver, a
|
||||
@@ -163,9 +163,9 @@
|
||||
</p>
|
||||
<p>Click <strong>Stock Adjustment</strong> in the Actions panel and choose one of three modes:</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-2"><strong>Add Stock</strong> — increases the current quantity by the amount you enter. Use for received goods, returns, or found stock.</li>
|
||||
<li class="mb-2"><strong>Remove Stock</strong> — decreases the current quantity by the amount you enter. Use for waste, spillage, or damage write-offs.</li>
|
||||
<li class="mb-2"><strong>Set Exact</strong> — sets the quantity on hand to the exact number you enter, regardless of the current value. Use after a physical inventory count to correct the balance.</li>
|
||||
<li class="mb-2"><strong>Add Stock</strong> — increases the current quantity by the amount you enter. Use for received goods, returns, or found stock.</li>
|
||||
<li class="mb-2"><strong>Remove Stock</strong> — decreases the current quantity by the amount you enter. Use for waste, spillage, or damage write-offs.</li>
|
||||
<li class="mb-2"><strong>Set Exact</strong> — sets the quantity on hand to the exact number you enter, regardless of the current value. Use after a physical inventory count to correct the balance.</li>
|
||||
</ul>
|
||||
<p>
|
||||
A <strong>reason</strong> is required for every adjustment. Common reasons are listed in the dropdown
|
||||
@@ -230,7 +230,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Job Usage</strong></td>
|
||||
<td>Powder consumed during a job — recorded automatically when actual usage is entered on a job coat.</td>
|
||||
<td>Powder consumed during a job — recorded automatically when actual usage is entered on a job coat.</td>
|
||||
<td class="text-danger fw-semibold">− Decreases</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -268,12 +268,12 @@
|
||||
<p>It has two tabs:</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-2">
|
||||
<strong>Stock Transactions</strong> — every transaction recorded against your inventory items,
|
||||
<strong>Stock Transactions</strong> — every transaction recorded against your inventory items,
|
||||
showing date, type, quantity (green for additions, red for deductions), unit cost, total cost,
|
||||
running balance after the transaction, and a link to the source Purchase Order if applicable.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Powder Usage by Job</strong> — every instance of powder being consumed on a job coat,
|
||||
<strong>Powder Usage by Job</strong> — every instance of powder being consumed on a job coat,
|
||||
showing the job number (linked to the job), customer, color applied, estimated vs actual pounds
|
||||
used, and the variance. A totals row at the bottom summarises the full filtered selection.
|
||||
</li>
|
||||
@@ -287,7 +287,7 @@
|
||||
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
To see the history for a single powder, open its Details page and click
|
||||
<strong>View Activity History</strong> — the Inventory Activity page will open pre-filtered
|
||||
<strong>View Activity History</strong> — the Inventory Activity page will open pre-filtered
|
||||
to that item.
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,14 +299,14 @@
|
||||
</h2>
|
||||
<p>
|
||||
Every inventory item has a printable <strong>QR code label</strong>. Stick it on the bag, bin,
|
||||
or shelf and shop floor workers can scan it with their phone to log how much they used —
|
||||
or shelf and shop floor workers can scan it with their phone to log how much they used —
|
||||
without ever touching a desktop.
|
||||
</p>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-4 mb-2">Printing a label</h3>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-1">Open the inventory item's Details page.</li>
|
||||
<li class="mb-1">Click <strong>Print QR Label</strong> in the Actions panel — the label opens in a new tab.</li>
|
||||
<li class="mb-1">Click <strong>Print QR Label</strong> in the Actions panel — the label opens in a new tab.</li>
|
||||
<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>
|
||||
</ol>
|
||||
|
||||
@@ -316,9 +316,9 @@
|
||||
<li class="mb-1">
|
||||
<strong>Select a job</strong> (optional but recommended):
|
||||
<ul class="mt-1">
|
||||
<li><em>My Jobs</em> — active jobs assigned to your account appear first.</li>
|
||||
<li><em>Other Jobs</em> — any other open job in the system.</li>
|
||||
<li><em>No Job</em> — log usage without a job reference (e.g. a waste event).</li>
|
||||
<li><em>My Jobs</em> — active jobs assigned to your account appear first.</li>
|
||||
<li><em>Other Jobs</em> — any other open job in the system.</li>
|
||||
<li><em>No Job</em> — log usage without a job reference (e.g. a waste event).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-1">Enter the <strong>quantity</strong> used. A live preview shows what the new stock balance will be.</li>
|
||||
@@ -331,8 +331,8 @@
|
||||
The success screen gives you two options:
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1"><strong>Log Another Item for This Job</strong> — returns to the scan page with the same job pre-selected, so you can quickly log the next powder without re-picking the job.</li>
|
||||
<li class="mb-1"><strong>Back to Inventory</strong> or <strong>View Item Details</strong> — returns to a neutral state.</li>
|
||||
<li class="mb-1"><strong>Log Another Item for This Job</strong> — returns to the scan page with the same job pre-selected, so you can quickly log the next powder without re-picking the job.</li>
|
||||
<li class="mb-1"><strong>Back to Inventory</strong> or <strong>View Item Details</strong> — returns to a neutral state.</li>
|
||||
</ul>
|
||||
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||
@@ -341,7 +341,7 @@
|
||||
Every scan-based usage log is recorded as a <strong>JobUsage</strong> or <strong>Adjustment</strong>
|
||||
transaction and immediately reduces the item's quantity on hand. You can review it on the
|
||||
<a href="/Inventory/Ledger" class="alert-link">Inventory Activity</a> page.
|
||||
The first time a worker scans on a new device they will be asked to log in — after that the
|
||||
The first time a worker scans on a new device they will be asked to log in — after that the
|
||||
browser keeps them signed in.
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,7 +374,7 @@
|
||||
<div>
|
||||
An item continues to show as Low Stock or Out of Stock even after you have placed a Purchase
|
||||
Order, until the goods are physically received and the PO is marked as Received in the system.
|
||||
This is intentional — it reminds you that stock has not yet arrived.
|
||||
This is intentional — it reminds you that stock has not yet arrived.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -428,12 +428,12 @@
|
||||
<li class="mb-2">Go to <strong><a asp-controller="CompanySettings" asp-action="Index">Company Settings</a> › Data Lookups › Inventory Categories</strong>.</li>
|
||||
<li class="mb-2">Find the category that contains your powder coating colors (e.g. "Powder Coatings").</li>
|
||||
<li class="mb-2">Click the edit icon and check the <strong>Is Coating</strong> checkbox.</li>
|
||||
<li class="mb-2">Save. Items in that category will immediately appear in the powder color dropdown on all quotes and jobs — no restart required.</li>
|
||||
<li class="mb-2">Save. Items in that category will immediately appear in the powder color dropdown on all quotes and jobs — no restart required.</li>
|
||||
</ol>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Which categories should have "Is Coating" enabled?</h3>
|
||||
<p>
|
||||
Only categories that contain actual powder coating colors — the materials that go into the oven
|
||||
Only categories that contain actual powder coating colors — the materials that go into the oven
|
||||
and bond to the part. Do <strong>not</strong> enable this on categories for primers, masking
|
||||
supplies, consumables, or equipment. Enabling it on non-coating categories will pollute the
|
||||
color dropdown with irrelevant items and make it harder to find the right powder.
|
||||
@@ -443,7 +443,7 @@
|
||||
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
The "Is Coating" flag also controls where the <strong>sample panel toggle</strong> appears
|
||||
on an item's Details page. The toggle — "I have a swatch/sample of this color" — only
|
||||
on an item's Details page. The toggle — "I have a swatch/sample of this color" — only
|
||||
shows up for items in a coating category, and those items are the ones tracked on the
|
||||
<strong>Sample Panels</strong> page.
|
||||
</div>
|
||||
@@ -459,9 +459,9 @@
|
||||
will be needed before the job begins. This estimate is based on three values:
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1"><strong>Surface area</strong> — the total square footage to be coated (entered per item in the job or quote wizard).</li>
|
||||
<li class="mb-1"><strong>Coverage rate</strong> — how many square feet one pound of the selected powder covers (set on the inventory item).</li>
|
||||
<li class="mb-1"><strong>Number of coats</strong> — selected when you add the coating to the item.</li>
|
||||
<li class="mb-1"><strong>Surface area</strong> — the total square footage to be coated (entered per item in the job or quote wizard).</li>
|
||||
<li class="mb-1"><strong>Coverage rate</strong> — how many square feet one pound of the selected powder covers (set on the inventory item).</li>
|
||||
<li class="mb-1"><strong>Number of coats</strong> — selected when you add the coating to the item.</li>
|
||||
</ul>
|
||||
<p>
|
||||
The <strong>Powder Needed</strong> figure appears in the item wizard as you build the quote or job,
|
||||
@@ -486,30 +486,30 @@
|
||||
The AI Price Check reviews every active, priced item in your
|
||||
<a asp-controller="CatalogItems" asp-action="Index">Catalog Items</a> list against your
|
||||
shop's actual operating costs. It estimates a realistic surface area and processing time
|
||||
for each item, calculates a cost floor, and compares that to your current price — flagging
|
||||
for each item, calculates a cost floor, and compares that to your current price — flagging
|
||||
anything that may be losing money, leaving margin on the table, or priced above market rates.
|
||||
</p>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Verdicts</h3>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-2"><strong>Below Cost</strong> — price is at or below the estimated cost floor. The shop loses money on every sale of this item.</li>
|
||||
<li class="mb-2"><strong>Thin Margin</strong> — price covers costs but falls below your target margin percentage.</li>
|
||||
<li class="mb-2"><strong>High</strong> — price appears significantly above typical market rates, which may cost you work.</li>
|
||||
<li class="mb-2"><strong>OK</strong> — price is within a reasonable range given your costs and market context.</li>
|
||||
<li class="mb-2"><strong>Below Cost</strong> — price is at or below the estimated cost floor. The shop loses money on every sale of this item.</li>
|
||||
<li class="mb-2"><strong>Thin Margin</strong> — price covers costs but falls below your target margin percentage.</li>
|
||||
<li class="mb-2"><strong>High</strong> — price appears significantly above typical market rates, which may cost you work.</li>
|
||||
<li class="mb-2"><strong>OK</strong> — price is within a reasonable range given your costs and market context.</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">How to run it</h3>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Make sure your <a asp-controller="CompanySettings" asp-action="Index">operating costs</a> are up to date — stale rates produce inaccurate verdicts.</li>
|
||||
<li class="mb-2">Make sure your <a asp-controller="CompanySettings" asp-action="Index">operating costs</a> are up to date — stale rates produce inaccurate verdicts.</li>
|
||||
<li class="mb-2">Go to <a asp-controller="CatalogItems" asp-action="Index">Catalog Items</a> and click <strong>AI Price Check</strong> in the top-right.</li>
|
||||
<li class="mb-2">Click <strong>Analyze Catalog with AI</strong>. A progress overlay appears while the analysis runs (allow 7–10 minutes for large catalogs).</li>
|
||||
<li class="mb-2">Review results sorted by severity — Below Cost items appear first. Click <strong>Edit Price</strong> on any item to update it directly from the results page.</li>
|
||||
<li class="mb-2">Click <strong>Analyze Catalog with AI</strong>. A progress overlay appears while the analysis runs (allow 7–10 minutes for large catalogs).</li>
|
||||
<li class="mb-2">Review results sorted by severity — Below Cost items appear first. Click <strong>Edit Price</strong> on any item to update it directly from the results page.</li>
|
||||
</ol>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Things to know</h3>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-2"><strong>Run limit:</strong> Analysis can be run once per quarter (90 days). The button shows the next available date when a recent run exists.</li>
|
||||
<li class="mb-2"><strong>Confidence levels:</strong> Each result shows High, Medium, or Low confidence. Vague item names like "Custom Part" will be Low — verify those manually.</li>
|
||||
<li class="mb-2"><strong>Confidence levels:</strong> Each result shows High, Medium, or Low confidence. Vague item names like "Custom Part" will be Low — verify those manually.</li>
|
||||
<li class="mb-2"><strong>Category paths matter:</strong> The AI uses the full category path (e.g. "Cerakote › Firearms") to determine the coating type. Make sure specialty items are in the correct category.</li>
|
||||
<li class="mb-2"><strong>$0 items skipped:</strong> Placeholder items and category headers with no price are automatically excluded from analysis.</li>
|
||||
</ul>
|
||||
@@ -518,7 +518,7 @@
|
||||
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
Results are estimates based on industry knowledge and your shop's rates. Always apply
|
||||
your own judgment before changing prices — especially for items flagged as Low confidence.
|
||||
your own judgment before changing prices — especially for items flagged as Low confidence.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
</h2>
|
||||
<p>
|
||||
Invoices are the formal request for payment you send to customers after their work is complete.
|
||||
Each job can have one invoice. The system tracks payment status in real time — you can see at a
|
||||
Each job can have one invoice. The system tracks payment status in real time — you can see at a
|
||||
glance which customers owe money, how much, and how long the balance has been outstanding.
|
||||
</p>
|
||||
<p>
|
||||
Invoices can be emailed to customers directly from the system (when email is configured) and
|
||||
downloaded as PDFs to print or send manually. Payments — whether in full or in partial
|
||||
installments — are logged against the invoice, and the customer's outstanding balance on their
|
||||
downloaded as PDFs to print or send manually. Payments — whether in full or in partial
|
||||
installments — are logged against the invoice, and the customer's outstanding balance on their
|
||||
account is updated automatically with every transaction.
|
||||
</p>
|
||||
<p>
|
||||
@@ -64,20 +64,46 @@
|
||||
|
||||
<p>
|
||||
Invoice numbers are generated in the format <code>INV-YYMM-####</code>
|
||||
(for example, <code>INV-2503-0007</code>). Each job can only have one invoice — if an invoice
|
||||
(for example, <code>INV-2503-0007</code>). Each job can only have one invoice — if an invoice
|
||||
already exists for a job, the Create Invoice button on the Job Details page is replaced with a
|
||||
link to the existing invoice.
|
||||
</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>
|
||||
You can create an invoice for a job at any time — you do not need to wait until the job
|
||||
You can create an invoice for a job at any time — you do not need to wait until the job
|
||||
reaches a Completed or Delivered status. Some shops invoice on deposit when a job is
|
||||
approved; others invoice on pickup. The system is flexible.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="inline-price-editing" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>Inline Price Editing
|
||||
</h2>
|
||||
<p>
|
||||
While an invoice is in <strong>Draft</strong> status, you can edit line item prices,
|
||||
quantities, and descriptions directly on the Invoice Details page — without
|
||||
opening the full Edit form.
|
||||
</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Click a <strong>unit price</strong>, <strong>quantity</strong>, or <strong>description</strong> cell. The cell turns into an input field.</li>
|
||||
<li class="mb-2">Type the new value.</li>
|
||||
<li class="mb-2">Press <kbd>Enter</kbd> or click anywhere outside the field to save. Press <kbd>Esc</kbd> to cancel.</li>
|
||||
</ol>
|
||||
<p>
|
||||
The line total and the invoice grand total update immediately without reloading the page.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
Once an invoice is marked <strong>Sent</strong>, line items are locked and inline
|
||||
editing is disabled. To correct a sent invoice, void it and create a new one.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="invoice-statuses" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-tag text-primary me-2"></i>Invoice Statuses
|
||||
@@ -161,7 +187,7 @@
|
||||
<i class="bi bi-cash-coin text-primary me-2"></i>Recording a Payment
|
||||
</h2>
|
||||
<p>
|
||||
When a customer pays — whether in full or as a partial payment — you record it against the invoice.
|
||||
When a customer pays — whether in full or as a partial payment — you record it against the invoice.
|
||||
The system supports multiple partial payments on a single invoice.
|
||||
</p>
|
||||
<ol class="mb-3">
|
||||
@@ -169,17 +195,17 @@
|
||||
<li class="mb-2">
|
||||
Fill in the payment details:
|
||||
<ul class="mt-1">
|
||||
<li><strong>Amount</strong> — how much was received this time. Can be less than the full balance for partial payments.</li>
|
||||
<li><strong>Payment Method</strong> — Cash, Check, Credit/Debit Card, Bank Transfer / ACH, or Digital Payment.</li>
|
||||
<li><strong>Payment Date</strong> — defaults to today.</li>
|
||||
<li><strong>Reference Number</strong> — optional. Use for check numbers, transaction IDs, or wire reference numbers.</li>
|
||||
<li><strong>Notes</strong> — any additional notes about this payment.</li>
|
||||
<li><strong>Amount</strong> — how much was received this time. Can be less than the full balance for partial payments.</li>
|
||||
<li><strong>Payment Method</strong> — Cash, Check, Credit/Debit Card, Bank Transfer / ACH, or Digital Payment.</li>
|
||||
<li><strong>Payment Date</strong> — defaults to today.</li>
|
||||
<li><strong>Reference Number</strong> — optional. Use for check numbers, transaction IDs, or wire reference numbers.</li>
|
||||
<li><strong>Notes</strong> — any additional notes about this payment.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-2">Click <strong>Save Payment</strong>.</li>
|
||||
</ol>
|
||||
<p>
|
||||
The invoice status updates automatically — to <strong>Partially Paid</strong> if there is still
|
||||
The invoice status updates automatically — to <strong>Partially Paid</strong> if there is still
|
||||
a remaining balance, or <strong>Paid</strong> if the full amount has been received. The customer's
|
||||
outstanding balance on their account is reduced by the payment amount. All payments are shown in
|
||||
a payment log on the invoice Details page.
|
||||
@@ -199,8 +225,8 @@
|
||||
<i class="bi bi-x-circle text-primary me-2"></i>Voiding an Invoice
|
||||
</h2>
|
||||
<p>
|
||||
If an invoice was created in error — for example, against the wrong job or with incorrect line
|
||||
items that cannot be corrected — you can void it to remove it from the customer's outstanding
|
||||
If an invoice was created in error — for example, against the wrong job or with incorrect line
|
||||
items that cannot be corrected — you can void it to remove it from the customer's outstanding
|
||||
balance.
|
||||
</p>
|
||||
<ol class="mb-3">
|
||||
@@ -215,7 +241,7 @@
|
||||
<strong>Restrictions:</strong> Only invoices in <strong>Draft</strong> or <strong>Sent</strong>
|
||||
status can be voided. If an invoice has payments recorded against it, you must delete those
|
||||
payments first before you can void the invoice. Invoices that are Partially Paid or Paid
|
||||
cannot be voided directly — consider writing off the remaining balance instead if needed.
|
||||
cannot be voided directly — consider writing off the remaining balance instead if needed.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -238,7 +264,7 @@
|
||||
<p>
|
||||
If a customer's outstanding balance is approaching or has exceeded their credit limit, a warning
|
||||
flag is shown on their customer record, on new jobs you try to create for them, and on new invoices.
|
||||
This is a visual warning only — the system does not automatically block new work — but it provides
|
||||
This is a visual warning only — the system does not automatically block new work — but it provides
|
||||
a clear signal to follow up on payment.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||
@@ -263,7 +289,7 @@
|
||||
<p>
|
||||
When you create an invoice from a job, <strong>all unapplied deposits are automatically applied
|
||||
as payments</strong> on the new invoice. The invoice's Amount Paid and status update accordingly
|
||||
— you may find the invoice is already partially or fully paid at creation time.
|
||||
— you may find the invoice is already partially or fully paid at creation time.
|
||||
</p>
|
||||
<p>
|
||||
Each deposit generates a receipt (receipt number format: <code>DEP-YYMM-####</code>) that can
|
||||
@@ -281,7 +307,7 @@
|
||||
</p>
|
||||
<p>
|
||||
To apply a gift certificate to an invoice, open the Invoice Details page and click
|
||||
<strong>Apply Gift Certificate</strong>. Enter the certificate code — the system looks up
|
||||
<strong>Apply Gift Certificate</strong>. Enter the certificate code — the system looks up
|
||||
the remaining balance and applies it as a payment up to the invoice amount.
|
||||
</p>
|
||||
</section>
|
||||
@@ -307,7 +333,7 @@
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Go to <strong>Settings › Billing</strong> (<a href="/Billing">/Billing</a>).</li>
|
||||
<li class="mb-2">Click <strong>Connect with Stripe</strong> (or <em>Set Up Online Payments</em>).</li>
|
||||
<li class="mb-2">You are redirected to Stripe — create a new Stripe account or connect an existing one.</li>
|
||||
<li class="mb-2">You are redirected to Stripe — create a new Stripe account or connect an existing one.</li>
|
||||
<li class="mb-2">Complete Stripe’s onboarding: enter your business details, add a bank account for payouts, and verify your identity as required by Stripe.</li>
|
||||
<li class="mb-2">Once Stripe approves the account, you are returned to the app and Stripe Connect status shows <strong>Active</strong>.</li>
|
||||
<li class="mb-2">Payment links now appear on Invoice Details and on the Online Payments page.</li>
|
||||
@@ -326,8 +352,8 @@
|
||||
Once Stripe Connect is active, open any Invoice Details page and use one of these options:
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1"><strong>Copy Payment Link</strong> — copies the URL to your clipboard so you can paste it into an email, text, or any other message.</li>
|
||||
<li class="mb-1"><strong>Send Payment Link</strong> — emails the payment link directly to the customer’s email address on file, with a brief message and the invoice amount.</li>
|
||||
<li class="mb-1"><strong>Copy Payment Link</strong> — copies the URL to your clipboard so you can paste it into an email, text, or any other message.</li>
|
||||
<li class="mb-1"><strong>Send Payment Link</strong> — emails the payment link directly to the customer’s email address on file, with a brief message and the invoice amount.</li>
|
||||
</ul>
|
||||
<p>
|
||||
The link is unique to each invoice and does not expire as long as the invoice remains unpaid.
|
||||
@@ -340,17 +366,17 @@
|
||||
<i class="bi bi-bell text-primary me-2"></i>Automated Payment Reminders
|
||||
</h2>
|
||||
<p>
|
||||
The system can automatically email customers when their invoices become overdue — without you
|
||||
The system can automatically email customers when their invoices become overdue — without you
|
||||
having to remember to follow up manually. This feature is controlled from
|
||||
<strong>Settings › Notifications › Automated Payment Reminders</strong>.
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-2">
|
||||
<strong>Enable Payment Reminders</strong> — turn the feature on or off for your company at any time.
|
||||
<strong>Enable Payment Reminders</strong> — turn the feature on or off for your company at any time.
|
||||
When off, no automated emails are sent.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Reminder Days</strong> — a comma-separated list of day milestones past the due date at which
|
||||
<strong>Reminder Days</strong> — a comma-separated list of day milestones past the due date at which
|
||||
reminders are sent. The default is <code>7,14,30</code> (one reminder at 7 days overdue, another at
|
||||
14, and a final one at 30).
|
||||
</li>
|
||||
|
||||
@@ -25,16 +25,20 @@
|
||||
a priority level, a due date, and one or more line items describing the work to be performed.
|
||||
</p>
|
||||
<p>
|
||||
You can find Jobs under <strong>Operations › Jobs</strong> in the left sidebar. The list is
|
||||
searchable and sortable by job number, status, priority, scheduled date, due date, and price. Jobs
|
||||
can be created manually or converted automatically from an approved quote — no need to re-enter
|
||||
You can find Jobs under <strong>Operations › Jobs</strong> in the left sidebar. The list
|
||||
opens on the <strong>On Floor</strong> filter by default, showing only active in-progress work
|
||||
so completed jobs don’t clutter the screen. Use the filter pills at the top to switch
|
||||
views: <strong>All</strong>, <strong>On Floor</strong>, <strong>Overdue</strong>,
|
||||
<strong>Ready</strong> (awaiting pickup), and <strong>Completed</strong>. The list is sortable
|
||||
and searchable by job number, status, priority, scheduled date, due date, and price. Jobs can be
|
||||
created manually or converted automatically from an approved quote — no need to re-enter
|
||||
information that is already in the system.
|
||||
</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 fastest way to create a job is to start with a quote. Build and approve the quote, then
|
||||
click <strong>Convert to Job</strong> — all items, coatings, and pricing carry over automatically.
|
||||
click <strong>Convert to Job</strong> — all items, coatings, and pricing carry over automatically.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -46,14 +50,19 @@
|
||||
<p>To create a job manually:</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Go to <strong>Operations › Jobs</strong> and click the <strong>New Job</strong> button in the top-right corner.</li>
|
||||
<li class="mb-2">Select the <strong>Customer</strong> — this field is required. Type to search by name or company.</li>
|
||||
<li class="mb-2">Enter a <strong>Job Description</strong> summarising the work (e.g., "Powder coat motorcycle frame — gloss black").</li>
|
||||
<li class="mb-2">Select the <strong>Customer</strong> — this field is required. Type to search by name or company.</li>
|
||||
<li class="mb-2">Enter a <strong>Job Description</strong> summarising the work (e.g., "Powder coat motorcycle frame — gloss black").</li>
|
||||
<li class="mb-2">Set the <strong>Scheduled Date</strong> (when work is expected to begin) and the <strong>Due Date</strong> (when the customer expects pickup or delivery).</li>
|
||||
<li class="mb-2">Choose a <strong>Priority</strong> — Normal is the default; see the Job Priority section below for all levels.</li>
|
||||
<li class="mb-2">Choose a <strong>Priority</strong> — Normal is the default; see the Job Priority section below for all levels.</li>
|
||||
<li class="mb-2">Optionally assign a <strong>Worker</strong> from your shop workers list.</li>
|
||||
<li class="mb-2">Enter the customer's <strong>PO Number</strong> if they require one for their own records.</li>
|
||||
<li class="mb-2">Add any <strong>Special Instructions</strong> your team needs to know before starting work.</li>
|
||||
<li class="mb-2">Add one or more <strong>Line Items</strong> describing each piece being coated. See the Job Items section below.</li>
|
||||
<li class="mb-2">
|
||||
Optionally expand <strong>Oven & Batch Settings</strong> to assign a named oven, set the
|
||||
number of batches, and enter the cure cycle time. These values feed directly into the oven cost
|
||||
calculation so the job’s pricing reflects the actual oven run.
|
||||
</li>
|
||||
<li class="mb-2">Click <strong>Save Job</strong>.</li>
|
||||
</ol>
|
||||
<p>
|
||||
@@ -72,7 +81,7 @@
|
||||
at any time by opening the job and editing it.
|
||||
</p>
|
||||
<p>
|
||||
The two special statuses — <strong>On Hold</strong> and <strong>Cancelled</strong> — can be applied
|
||||
The two special statuses — <strong>On Hold</strong> and <strong>Cancelled</strong> — can be applied
|
||||
at any point in the workflow regardless of the current status. Use On Hold to pause a job temporarily
|
||||
(waiting for customer approval, missing materials, etc.) and Cancelled to formally close a job that
|
||||
will not be completed.
|
||||
@@ -80,7 +89,7 @@
|
||||
<p>
|
||||
On the Jobs list, click any status badge to open a quick-change modal. The modal includes a
|
||||
<strong>Notify customer via email</strong> toggle. If the customer has email notifications turned off,
|
||||
that toggle is automatically disabled and a warning note is shown — no email will be sent regardless.
|
||||
that toggle is automatically disabled and a warning note is shown — no email will be sent regardless.
|
||||
</p>
|
||||
|
||||
<div class="table-responsive">
|
||||
@@ -167,7 +176,7 @@
|
||||
</h2>
|
||||
<p>
|
||||
Every job has a priority level that tells your team how urgently a job needs to move through the shop.
|
||||
Priorities are color-coded throughout the system — in the job list, on the job details page, and on
|
||||
Priorities are color-coded throughout the system — in the job list, on the job details page, and on
|
||||
any dashboards. Set or change the priority in the Create or Edit form.
|
||||
</p>
|
||||
|
||||
@@ -225,21 +234,21 @@
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Item Types</h3>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-2">
|
||||
<strong>Calculated Item</strong> — enter surface area, quantity, and complexity. The system
|
||||
<strong>Calculated Item</strong> — enter surface area, quantity, and complexity. The system
|
||||
calculates material, labor, and equipment costs automatically. Select one or more powder
|
||||
coatings and optional prep services (sandblasting, masking, cleaning).
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Custom Work Item</strong> — enter a free-text description and a manual price. Use this for
|
||||
<strong>Custom Work Item</strong> — enter a free-text description and a manual price. Use this for
|
||||
one-off work that does not fit the standard calculation model.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>AI Photo Quote Item</strong> — upload photos of the parts and let our AI agent estimate
|
||||
<strong>AI Photo Quote Item</strong> — upload photos of the parts and let our AI agent estimate
|
||||
the surface area, complexity, and labor time. Review and override any value before accepting.
|
||||
Up to two follow-up rounds of questions are supported.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Labor Item</strong> — a line item representing time and labor charges only, without
|
||||
<strong>Labor Item</strong> — a line item representing time and labor charges only, without
|
||||
material costs.
|
||||
</li>
|
||||
</ul>
|
||||
@@ -259,8 +268,8 @@
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Prep Services</h3>
|
||||
<p>
|
||||
Each item can also include optional <strong>prep services</strong> — sandblasting, masking, or
|
||||
chemical cleaning — that will be carried out before coating. These are selected in the wizard and
|
||||
Each item can also include optional <strong>prep services</strong> — sandblasting, masking, or
|
||||
chemical cleaning — that will be carried out before coating. These are selected in the wizard and
|
||||
appear as sub-lines under the item on the job details page.
|
||||
</p>
|
||||
|
||||
@@ -269,37 +278,66 @@
|
||||
After completing the coatings and prep services steps, <strong>Calculated</strong> and
|
||||
<strong>AI Photo Quote</strong> items include one final optional step: <strong>Save to Product Catalog</strong>.
|
||||
This lets you turn the item you just configured into a reusable catalog entry so it can be selected
|
||||
instantly on future quotes or jobs — without re-entering dimensions, coatings, or prep services.
|
||||
instantly on future quotes or jobs — without re-entering dimensions, coatings, or prep services.
|
||||
</p>
|
||||
<p>The wizard pre-fills the catalog form with:</p>
|
||||
<ul class="mb-3">
|
||||
<li><strong>Name</strong> — taken from the item description (or AI-generated description); you can edit it</li>
|
||||
<li><strong>Default Price</strong> — copied from the item's calculated or manually adjusted unit price</li>
|
||||
<li><strong>Description</strong> — the item description</li>
|
||||
<li><strong>Sandblasting / Masking</strong> — automatically checked if those prep services were selected</li>
|
||||
<li><strong>Category</strong> — choose from your active catalog categories</li>
|
||||
<li><strong>Name</strong> — taken from the item description (or AI-generated description); you can edit it</li>
|
||||
<li><strong>Default Price</strong> — copied from the item's calculated or manually adjusted unit price</li>
|
||||
<li><strong>Description</strong> — the item description</li>
|
||||
<li><strong>Sandblasting / Masking</strong> — automatically checked if those prep services were selected</li>
|
||||
<li><strong>Category</strong> — choose from your active catalog categories</li>
|
||||
</ul>
|
||||
<p>At the bottom of the step you have two options:</p>
|
||||
<ul class="mb-3">
|
||||
<li><strong>Save to Catalog & Add</strong> — saves the catalog item immediately and then adds the line item to the job.</li>
|
||||
<li><strong>Skip — Add to Job Only</strong> — skips the save and adds the item to the job without creating a catalog entry.</li>
|
||||
<li><strong>Save to Catalog & Add</strong> — saves the catalog item immediately and then adds the line item to the job.</li>
|
||||
<li><strong>Skip — Add to Job Only</strong> — skips the save and adds the item to the job without creating a catalog entry.</li>
|
||||
</ul>
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
|
||||
<i class="bi bi-info-circle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
The catalog save happens immediately when you click <strong>Save to Catalog & Add</strong> — before the job form is submitted. The catalog item is preserved even if you later discard the job. You can view and manage all saved items at <a asp-controller="CatalogItems" asp-action="Index">Catalog Items</a>.
|
||||
The catalog save happens immediately when you click <strong>Save to Catalog & Add</strong> — before the job form is submitted. The catalog item is preserved even if you later discard the job. You can view and manage all saved items at <a asp-controller="CatalogItems" asp-action="Index">Catalog Items</a>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
Items on the job details page are grouped by type — Catalog Items, Custom Work, and Labor — to
|
||||
Items on the job details page are grouped by type — Catalog Items, Custom Work, and Labor — to
|
||||
make it easy to see exactly what work is being performed and which materials are required.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="inline-price-editing" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>Inline Price Editing
|
||||
</h2>
|
||||
<p>
|
||||
On the Job Details page you can edit any item’s unit price, quantity, or description
|
||||
directly in the table — without opening the full Edit form.
|
||||
</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Click a <strong>unit price</strong>, <strong>quantity</strong>, or <strong>description</strong> cell in the Items table. The cell turns into an input field.</li>
|
||||
<li class="mb-2">Type the new value.</li>
|
||||
<li class="mb-2">Press <kbd>Enter</kbd> or click anywhere outside the field to save. Press <kbd>Esc</kbd> to cancel without saving.</li>
|
||||
</ol>
|
||||
<p>
|
||||
After saving, the line total updates immediately and the pricing summary card (Items
|
||||
Subtotal, Subtotal, Tax, and Grand Total) refreshes — no page reload required.
|
||||
The <strong>Job Costing</strong> card also recalculates automatically so your
|
||||
profit margin estimate stays current.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-info-circle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
The <strong>Job Costing</strong> section calculates revenue from the job’s
|
||||
current Final Price — not from the linked invoice total. Inline price edits
|
||||
are reflected in the costing analysis immediately, even before an invoice is created.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="converting-from-quote" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-arrow-left-right text-primary me-2"></i>Converting from a Quote
|
||||
@@ -341,7 +379,7 @@
|
||||
<i class="bi bi-receipt text-primary me-2"></i>Creating an Invoice from a Job
|
||||
</h2>
|
||||
<p>
|
||||
Once a job is complete — or at any time when you are ready to bill the customer — you can create an
|
||||
Once a job is complete — or at any time when you are ready to bill the customer — you can create an
|
||||
invoice directly from the job's Details page. There is no need to manually re-enter pricing.
|
||||
</p>
|
||||
<ol class="mb-3">
|
||||
@@ -353,7 +391,7 @@
|
||||
<li>If an invoice already exists, you will see a link to open it.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates a new invoice pre-filled with all the job's line items and the final pricing.</li>
|
||||
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates a new invoice pre-filled with all the job’s line items and the final pricing. Line item descriptions include the coat color(s) for each item (e.g., <em>Gloss Black / Satin Clear</em>), which helps customers distinguish repeated items such as multiple sets of calipers.</li>
|
||||
<li class="mb-2">Review the invoice, confirm the due date, and save it.</li>
|
||||
</ol>
|
||||
<p>
|
||||
@@ -370,6 +408,40 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="completing-a-job" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-check-circle text-primary me-2"></i>Completing a Job
|
||||
</h2>
|
||||
<p>
|
||||
When work is done and the parts have passed quality check, click the <strong>Complete Job</strong>
|
||||
button on the Job Details page. A modal opens where you confirm:
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li><strong>Completion date</strong> — defaults to today.</li>
|
||||
<li><strong>Actual hours spent</strong> on the job.</li>
|
||||
<li><strong>Final price</strong> — pre-filled from the job; adjust if needed.</li>
|
||||
<li>
|
||||
<strong>Powder usage</strong> — if the job used powder from inventory, you are asked
|
||||
to enter actual lbs used. The modal groups all coats by <em>unique powder color</em> and
|
||||
shows one input row per powder, regardless of how many items or coats used that color.
|
||||
This avoids entering the same number repeatedly. Any lbs already scanned via QR code on
|
||||
the shop floor are credited automatically — you only enter the remaining amount.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Once confirmed, the job advances to <span class="badge bg-success">Completed</span> status,
|
||||
inventory is updated, and you are prompted to create an invoice if one does not already exist.
|
||||
</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>
|
||||
Shop floor workers who complete a job via QR scan bypass the modal — the SMS
|
||||
notification is sent immediately using the configured template. Managers and admins
|
||||
get the full modal with a compose step before the SMS goes out.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="photos-notes" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-camera text-primary me-2"></i>Photos and Notes
|
||||
@@ -385,7 +457,7 @@
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Job Notes</h3>
|
||||
<p>
|
||||
Add internal notes to a job from the Details page. Notes are private — they are not visible
|
||||
Add internal notes to a job from the Details page. Notes are private — they are not visible
|
||||
to the customer. Use them for team communication, special handling instructions, or to log
|
||||
anything notable that happened during production.
|
||||
</p>
|
||||
@@ -417,7 +489,7 @@
|
||||
<i class="bi bi-clipboard2-check text-primary me-2"></i>Job Templates
|
||||
</h2>
|
||||
<p>
|
||||
If you do the same type of work repeatedly — for example, a standard wheel refinish package —
|
||||
If you do the same type of work repeatedly — for example, a standard wheel refinish package —
|
||||
you can save a job's line items as a template and reuse it for future jobs.
|
||||
</p>
|
||||
<p>Templates are managed at <strong>/JobTemplates</strong>. To use a template:</p>
|
||||
@@ -457,7 +529,7 @@
|
||||
</h2>
|
||||
<p>
|
||||
The <strong>Part Intake</strong> workflow lets you formally check in a customer's parts at
|
||||
drop-off — before any work begins. This creates a timestamped record of how many pieces
|
||||
drop-off — before any work begins. This creates a timestamped record of how many pieces
|
||||
arrived, their condition, and who received them, which protects the shop if a customer later
|
||||
disputes pre-existing damage or a missing piece.
|
||||
</p>
|
||||
@@ -470,10 +542,10 @@
|
||||
On the intake form you will see:
|
||||
<ul class="mt-1">
|
||||
<li>A <strong>Job Summary</strong> card showing expected part count (from line items), due date, and any special instructions.</li>
|
||||
<li><strong>Actual Part Count</strong> — enter the number of pieces physically received. If this differs from the expected count, a warning appears prompting you to note the discrepancy.</li>
|
||||
<li><strong>Condition Notes</strong> — describe the condition of the parts (existing scratches, rust, missing hardware, special handling requirements, etc.).</li>
|
||||
<li><strong>Advance to In Preparation</strong> — toggle on to automatically move the job status to <em>In Preparation</em> at the same time. Leave off if the customer is dropping parts off early and work hasn't started yet.</li>
|
||||
<li><strong>Before Photos</strong> — upload photos documenting the condition at drop-off. Photos are saved as "Before" type on the job and appear in the Photos section of Job Details.</li>
|
||||
<li><strong>Actual Part Count</strong> — enter the number of pieces physically received. If this differs from the expected count, a warning appears prompting you to note the discrepancy.</li>
|
||||
<li><strong>Condition Notes</strong> — describe the condition of the parts (existing scratches, rust, missing hardware, special handling requirements, etc.).</li>
|
||||
<li><strong>Advance to In Preparation</strong> — toggle on to automatically move the job status to <em>In Preparation</em> at the same time. Leave off if the customer is dropping parts off early and work hasn't started yet.</li>
|
||||
<li><strong>Before Photos</strong> — upload photos documenting the condition at drop-off. Photos are saved as "Before" type on the job and appear in the Photos section of Job Details.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-2">Click <strong>Complete Intake</strong>.</li>
|
||||
@@ -490,7 +562,7 @@
|
||||
<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 intake form is optimised for use on a tablet at the front desk — the layout is
|
||||
The intake form is optimised for use on a tablet at the front desk — the layout is
|
||||
touch-friendly and the photo upload works directly from a tablet camera.
|
||||
</div>
|
||||
</div>
|
||||
@@ -504,7 +576,7 @@
|
||||
<strong>Shop Mobile</strong> (<a href="/Jobs/ShopMobile">/Jobs/ShopMobile</a>) is a
|
||||
phone and tablet-optimised view of all active jobs, designed for workers on the shop floor.
|
||||
Unlike the Shop Display (which is a passive TV view) and the full desktop UI, Shop Mobile is
|
||||
built for one-handed use — large touch targets, no sidebar, and instant status advancement
|
||||
built for one-handed use — large touch targets, no sidebar, and instant status advancement
|
||||
with a single tap.
|
||||
</p>
|
||||
|
||||
@@ -516,8 +588,8 @@
|
||||
<li>Assigned worker, sandblasting/masking flags, and due date</li>
|
||||
<li>Line items (up to 3 shown; tap the details button for the full list)</li>
|
||||
<li>Powder colours</li>
|
||||
<li>Special instructions (amber callout — hard to miss)</li>
|
||||
<li>An <strong>intake indicator</strong> — a green box icon means parts were checked in; an amber box icon means intake is still pending (tap it to go directly to the intake form)</li>
|
||||
<li>Special instructions (amber callout — hard to miss)</li>
|
||||
<li>An <strong>intake indicator</strong> — a green box icon means parts were checked in; an amber box icon means intake is still pending (tap it to go directly to the intake form)</li>
|
||||
<li>An <strong>overdue badge</strong> when the due date has passed</li>
|
||||
</ul>
|
||||
|
||||
@@ -555,7 +627,7 @@
|
||||
<i class="bi bi-person-gear text-primary me-2"></i>Changing the Customer
|
||||
</h2>
|
||||
<p>
|
||||
The customer on a job can be changed at any time from the Job Details page — no need to
|
||||
The customer on a job can be changed at any time from the Job Details page — no need to
|
||||
delete and re-create the job. This is useful when:
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
@@ -567,16 +639,16 @@
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">How to change the customer</h3>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Open the job from <strong>Operations › Jobs</strong> and go to its Details page.</li>
|
||||
<li class="mb-2">Find the <strong>Customer</strong> field in the job header — it appears as a dropdown showing the current customer.</li>
|
||||
<li class="mb-2">Find the <strong>Customer</strong> field in the job header — it appears as a dropdown showing the current customer.</li>
|
||||
<li class="mb-2">Select a different customer from the dropdown.</li>
|
||||
<li class="mb-2">A confirmation banner appears: <em>"Change customer to [Name]?"</em> — click <strong>Save</strong> to confirm or <strong>Cancel</strong> to revert.</li>
|
||||
<li class="mb-2">A confirmation banner appears: <em>"Change customer to [Name]?"</em> — click <strong>Save</strong> to confirm or <strong>Cancel</strong> to revert.</li>
|
||||
</ol>
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
The customer can also be changed on the <strong>Edit Job</strong> page using the Customer
|
||||
dropdown there. Any invoices or deposits already linked to the job are not automatically
|
||||
moved — update those separately if needed.
|
||||
moved — update those separately if needed.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -586,24 +658,24 @@
|
||||
<i class="bi bi-qr-code text-primary me-2"></i>Work Order QR Codes
|
||||
</h2>
|
||||
<p>
|
||||
Every printed job work order includes two tiers of QR codes — one for <strong>viewing</strong>
|
||||
Every printed job work order includes two tiers of QR codes — one for <strong>viewing</strong>
|
||||
the job and a separate set for <strong>acting</strong> on it. This gives shop workers everything
|
||||
they need from a printed sheet without touching the desktop app.
|
||||
All QR codes require a logged-in account.
|
||||
</p>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-eye me-1"></i>Top QR — View Job</h3>
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-eye me-1"></i>Top QR — View Job</h3>
|
||||
<p>
|
||||
Located in the work order header, next to the job number. Scan it with your phone to open the
|
||||
full <strong>Job Details</strong> page — items, catalog product images, powder specs, coatings,
|
||||
full <strong>Job Details</strong> page — items, catalog product images, powder specs, coatings,
|
||||
prep services, and special instructions. Use it to verify you're working the right job or to
|
||||
see catalog item images on your phone without hunting through the app.
|
||||
</p>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-arrow-right-circle me-1"></i>Bottom QR — Update Status</h3>
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-arrow-right-circle me-1"></i>Bottom QR — Update Status</h3>
|
||||
<p>
|
||||
Scan to open a mobile-friendly status bump page for this job. Tap the button to advance to the
|
||||
next stage (or put the job on hold). The status change is recorded in history with your name —
|
||||
next stage (or put the job on hold). The status change is recorded in history with your name —
|
||||
no anonymous bumps.
|
||||
</p>
|
||||
|
||||
@@ -643,7 +715,7 @@
|
||||
<h2 class="h5 fw-semibold mb-3">Blank Work Order</h2>
|
||||
<p>
|
||||
The Blank Work Order feature lets you instantly print a pre-formatted paper work order to hand to a
|
||||
customer at drop-off — before a digital job record has been created. It uses your company logo,
|
||||
customer at drop-off — before a digital job record has been created. It uses your company logo,
|
||||
address, and customizable terms so it looks professional right out of the box.
|
||||
</p>
|
||||
|
||||
@@ -651,7 +723,7 @@
|
||||
<ol>
|
||||
<li>Go to <strong>Jobs</strong> in the sidebar.</li>
|
||||
<li>Click the <strong><i class="bi bi-printer"></i> Blank Work Order</strong> button in the top-right toolbar (next to the Jobs Board button).</li>
|
||||
<li>A PDF opens in a new browser tab — print it or save it to PDF.</li>
|
||||
<li>A PDF opens in a new browser tab — print it or save it to PDF.</li>
|
||||
</ol>
|
||||
<p>You can also navigate directly to <a href="/WorkOrder/Blank">/WorkOrder/Blank</a> to open the PDF at any time.</p>
|
||||
|
||||
@@ -671,8 +743,8 @@
|
||||
Go to <strong>Company Settings → PDF Templates → Work Order</strong> to customize:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Accent Color</strong> — the color used for table headers, the title bar, and section labels. Defaults to dark gray.</li>
|
||||
<li><strong>Terms & Conditions</strong> — up to 2,000 characters of text printed in italic below the notes box. Use this for your shop's standard policies, liability disclaimer, or payment terms.</li>
|
||||
<li><strong>Accent Color</strong> — the color used for table headers, the title bar, and section labels. Defaults to dark gray.</li>
|
||||
<li><strong>Terms & Conditions</strong> — up to 2,000 characters of text printed in italic below the notes box. Use this for your shop's standard policies, liability disclaimer, or payment terms.</li>
|
||||
</ul>
|
||||
<p>Click <strong>Save</strong> to apply changes, or <strong>Preview</strong> to open the PDF instantly without saving.</p>
|
||||
|
||||
@@ -699,6 +771,7 @@
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#job-items">Job Items</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#converting-from-quote">Converting from a Quote</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#creating-an-invoice">Creating an Invoice</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#completing-a-job">Completing a Job</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#photos-notes">Photos and Notes</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#time-and-rework">Time Entries and Rework</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#job-templates">Job Templates</a>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<p>
|
||||
The PO workflow is designed to eliminate double data entry. When you receive a PO, stock levels
|
||||
update automatically for any linked inventory items. When you are ready to pay, you can convert
|
||||
the received PO directly into a vendor bill in Accounts Payable — all the line items, quantities,
|
||||
the received PO directly into a vendor bill in Accounts Payable — all the line items, quantities,
|
||||
and prices carry over without retyping.
|
||||
</p>
|
||||
<p>
|
||||
@@ -46,7 +46,7 @@
|
||||
<li class="mb-2">Select the <strong>Vendor</strong> you are ordering from. The vendor's contact information and default payment terms are pulled in automatically.</li>
|
||||
<li class="mb-2">Set the <strong>Order Date</strong> (defaults to today) and an optional <strong>Expected Delivery Date</strong>.</li>
|
||||
<li class="mb-2">
|
||||
Add line items — click <strong>Add Line</strong> for each product you are ordering:
|
||||
Add line items — click <strong>Add Line</strong> for each product you are ordering:
|
||||
<ul class="mt-1">
|
||||
<li>Start typing an item name in the search field to look up items from your inventory. If the item exists, select it to link the PO line directly to your stock record.</li>
|
||||
<li>If the item is not in your inventory yet (a new product), enter a description manually. You can create the inventory item later after receiving the goods.</li>
|
||||
@@ -54,7 +54,7 @@
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-2">Enter a <strong>Shipping Cost</strong> if the vendor charges for delivery. This is added to the PO total.</li>
|
||||
<li class="mb-2">Add any <strong>Notes for the Vendor</strong> — these appear on the printed PO document.</li>
|
||||
<li class="mb-2">Add any <strong>Notes for the Vendor</strong> — these appear on the printed PO document.</li>
|
||||
<li class="mb-2">Add any internal <strong>Notes</strong> for your own team (not shown to the vendor).</li>
|
||||
<li class="mb-2">Click <strong>Save PO</strong>. The PO is saved as a Draft.</li>
|
||||
</ol>
|
||||
@@ -126,7 +126,7 @@
|
||||
</h2>
|
||||
<p>
|
||||
After a PO has been received, you can convert it into a vendor bill in Accounts Payable with
|
||||
one click — no need to re-enter any of the line item details.
|
||||
one click — no need to re-enter any of the line item details.
|
||||
</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-2">Open the received PO and click <strong>Create Bill</strong>.</li>
|
||||
@@ -138,7 +138,7 @@
|
||||
<li>The vendor's payment terms and a calculated due date</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-2">Review the bill — confirm the due date, check that expense accounts are correct, and add any notes.</li>
|
||||
<li class="mb-2">Review the bill — confirm the due date, check that expense accounts are correct, and add any notes.</li>
|
||||
<li class="mb-2">Click <strong>Save Bill</strong>. The bill is saved as a Draft in Accounts Payable.</li>
|
||||
<li class="mb-2">When you have verified the bill against the vendor's paper invoice, click <strong>Mark as Open</strong> to post it to your AP ledger. See the <a asp-controller="Help" asp-action="AccountsPayable">Accounts Payable help page</a> for details.</li>
|
||||
</ol>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user