ui bug fixers
This commit is contained in:
@@ -65,4 +65,15 @@ public sealed class VendorsController : ApiControllerBase
|
|||||||
await _vendors.SetStatusAsync(vendorId, request.Status, ct);
|
await _vendors.SetStatusAsync(vendorId, request.Status, ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{vendorId:int}")]
|
||||||
|
[Authorize(Policy = NavPolicies.Prefix + "vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int vendorId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _vendors.DeleteAsync(vendorId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ public sealed class CreatePurchaseInvoicePaymentRequest
|
|||||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||||
[Required] public long GlBankAccountId { get; set; }
|
[Required] public long GlBankAccountId { get; set; }
|
||||||
[StringLength(100)] public string? Reference { get; set; }
|
[StringLength(100)] public string? Reference { get; set; }
|
||||||
|
/// <summary>When the vendor was paid; also the GL entry date. Omitted ⇒ today. Never in the future.</summary>
|
||||||
|
public DateOnly? PaymentDate { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class CreatePurchaseInvoiceRequest
|
public sealed class CreatePurchaseInvoiceRequest
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ public interface IVendorService
|
|||||||
Task<ETagged<VendorDto>> CreateAsync(CreateVendorRequest request, CancellationToken ct = default);
|
Task<ETagged<VendorDto>> CreateAsync(CreateVendorRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<VendorDto>> UpdateAsync(int vendorId, UpdateVendorRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<VendorDto>> UpdateAsync(int vendorId, UpdateVendorRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
Task SetStatusAsync(int vendorId, EntityStatus status, CancellationToken ct = default);
|
Task SetStatusAsync(int vendorId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(int vendorId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
|
|||||||
|
|
||||||
var actor = _currentUser.AuditUserId;
|
var actor = _currentUser.AuditUserId;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now);
|
||||||
|
|
||||||
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
{
|
{
|
||||||
@@ -82,7 +83,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
|
|||||||
{
|
{
|
||||||
PurchaseInvoiceId = invoice.PurchaseInvoiceId,
|
PurchaseInvoiceId = invoice.PurchaseInvoiceId,
|
||||||
Amount = request.Amount,
|
Amount = request.Amount,
|
||||||
PaymentDate = now,
|
PaymentDate = paymentDate,
|
||||||
GlBankAccountId = account.AccountId,
|
GlBankAccountId = account.AccountId,
|
||||||
BankAccountName = account.AccountName,
|
BankAccountName = account.AccountName,
|
||||||
Reference = request.Reference,
|
Reference = request.Reference,
|
||||||
@@ -101,6 +102,21 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
|
|||||||
return Map(payment);
|
return Map(payment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The payment's calendar day (also the GL entry date) and the timestamp stored on it — same
|
||||||
|
/// rule as <see cref="SalesInvoicePaymentService"/>. Omitted ⇒ today. One day of slack on
|
||||||
|
/// "future": the client sends its local date, which runs ahead of UTC for users east of it.
|
||||||
|
/// Today keeps the actual time of entry; a back-dated payment is stamped at that day's start.
|
||||||
|
/// </summary>
|
||||||
|
private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now)
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(now);
|
||||||
|
var day = requested ?? today;
|
||||||
|
if (day > today.AddDays(1))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400);
|
||||||
|
return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<PurchaseInvoicePaymentDto>> ListAsync(int purchaseInvoiceId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<PurchaseInvoicePaymentDto>> ListAsync(int purchaseInvoiceId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!await _invoices.Query().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct))
|
if (!await _invoices.Query().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct))
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ public sealed class RequisitionService : IRequisitionService
|
|||||||
{
|
{
|
||||||
await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct);
|
await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct);
|
||||||
var actor = _currentUser.AuditUserId;
|
var actor = _currentUser.AuditUserId;
|
||||||
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
// Branch is optional on a requisition: an unset one falls back to the default branch
|
||||||
|
// rather than being rejected (this also covers reorder-alert requisitions, which carry none).
|
||||||
|
var branchId = request.BranchId > 0
|
||||||
|
? await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct)
|
||||||
|
: await _branchConfig.ResolveDefaultBranchIdAsync(ct);
|
||||||
|
|
||||||
var req = await _uow.ExecuteInTransactionAsync(async token =>
|
var req = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -120,6 +120,26 @@ public sealed class VendorService : IVendorService
|
|||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hard delete. Every FK to vendors (POs, GRNs, invoices, returns, quotations, item
|
||||||
|
/// default vendor) is Restrict, so a vendor in use is refused with 409 — deactivate it instead.
|
||||||
|
/// </summary>
|
||||||
|
public async Task DeleteAsync(int vendorId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var vendor = await _vendors.GetByIdAsync(vendorId, ct)
|
||||||
|
?? throw new NotFoundException($"Vendor {vendorId} was not found.");
|
||||||
|
|
||||||
|
_vendors.Remove(vendor);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.MasterInUse, "This vendor is used by purchase documents or items and cannot be deleted. Deactivate it instead.", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static VendorDto Map(Vendor v) => new(
|
private static VendorDto Map(Vendor v) => new(
|
||||||
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
|
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,8 @@
|
|||||||
},
|
},
|
||||||
"AuthHex": {
|
"AuthHex": {
|
||||||
"BaseUrl": "http://localhost:5011"
|
"BaseUrl": "http://localhost:5011"
|
||||||
|
},
|
||||||
|
"GeneralLedgerService": {
|
||||||
|
"BaseUrl": "http://localhost:5200/api/v1/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -33,6 +33,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
import { EditCustomerDialog } from "@/components/customers/EditCustomerDialog"
|
||||||
|
|
||||||
type StatusFilter = EntityStatus | "All"
|
type StatusFilter = EntityStatus | "All"
|
||||||
type TypeFilter = CustomerType | "All"
|
type TypeFilter = CustomerType | "All"
|
||||||
@@ -63,6 +64,10 @@ export default function CustomersPage() {
|
|||||||
const [newCustomerType, setNewCustomerType] = useState<CustomerType>("B2C")
|
const [newCustomerType, setNewCustomerType] = useState<CustomerType>("B2C")
|
||||||
const [phone, setPhone] = useState("")
|
const [phone, setPhone] = useState("")
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
|
const [addressLine1, setAddressLine1] = useState("")
|
||||||
|
const [addressLine2, setAddressLine2] = useState("")
|
||||||
|
const [city, setCity] = useState("")
|
||||||
|
const [country, setCountry] = useState("")
|
||||||
const [creditLimit, setCreditLimit] = useState("")
|
const [creditLimit, setCreditLimit] = useState("")
|
||||||
const [creditDays, setCreditDays] = useState("")
|
const [creditDays, setCreditDays] = useState("")
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
@@ -72,6 +77,8 @@ export default function CustomersPage() {
|
|||||||
// customer's own code (mirrors the vendor create dialog).
|
// customer's own code (mirrors the vendor create dialog).
|
||||||
const [allCustomerCodes, setAllCustomerCodes] = useState<string[]>([])
|
const [allCustomerCodes, setAllCustomerCodes] = useState<string[]>([])
|
||||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
// The customer whose edit popup is open.
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||||
|
|
||||||
const generatedCode = name.trim() ? generateCustomerCode(name, allCustomerCodes) : ""
|
const generatedCode = name.trim() ? generateCustomerCode(name, allCustomerCodes) : ""
|
||||||
@@ -135,6 +142,10 @@ export default function CustomersPage() {
|
|||||||
setNewCustomerType("B2C")
|
setNewCustomerType("B2C")
|
||||||
setPhone("")
|
setPhone("")
|
||||||
setEmail("")
|
setEmail("")
|
||||||
|
setAddressLine1("")
|
||||||
|
setAddressLine2("")
|
||||||
|
setCity("")
|
||||||
|
setCountry("")
|
||||||
setCreditLimit("")
|
setCreditLimit("")
|
||||||
setCreditDays("")
|
setCreditDays("")
|
||||||
setBranchFilterId(null)
|
setBranchFilterId(null)
|
||||||
@@ -156,6 +167,10 @@ export default function CustomersPage() {
|
|||||||
name,
|
name,
|
||||||
phone: phone || null,
|
phone: phone || null,
|
||||||
email: email || null,
|
email: email || null,
|
||||||
|
addressLine1: addressLine1 || null,
|
||||||
|
addressLine2: addressLine2 || null,
|
||||||
|
city: city || null,
|
||||||
|
country: country || null,
|
||||||
creditLimit: Number(creditLimit) || 0,
|
creditLimit: Number(creditLimit) || 0,
|
||||||
creditDays: Number(creditDays) || 0,
|
creditDays: Number(creditDays) || 0,
|
||||||
branchId: effectiveBranchId ?? 0,
|
branchId: effectiveBranchId ?? 0,
|
||||||
@@ -187,6 +202,17 @@ export default function CustomersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row click → profile page. React bubbles events out of portals along the component tree,
|
||||||
|
* so a click on the delete confirmation (portaled to <body>) still reaches the row; only
|
||||||
|
* navigate for events whose DOM target really is inside the row and not an action cell.
|
||||||
|
*/
|
||||||
|
function openRow(e: React.MouseEvent | React.KeyboardEvent, customerId: number) {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!e.currentTarget.contains(target) || target.closest("[data-no-row-nav]")) return
|
||||||
|
router.push(`/dashboard/customers/${customerId}`)
|
||||||
|
}
|
||||||
|
|
||||||
const hasFilters = query.length > 0 || status !== "All" || customerType !== "All"
|
const hasFilters = query.length > 0 || status !== "All" || customerType !== "All"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -213,7 +239,7 @@ export default function CustomersPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.name}>
|
<Field data-invalid={!!errors.name}>
|
||||||
<FieldLabel htmlFor="c-name">Name</FieldLabel>
|
<FieldLabel htmlFor="c-name" required>Name</FieldLabel>
|
||||||
<Input id="c-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Nimal Perera" aria-invalid={!!errors.name} />
|
<Input id="c-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Nimal Perera" aria-invalid={!!errors.name} />
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
</Field>
|
</Field>
|
||||||
@@ -243,6 +269,24 @@ export default function CustomersPage() {
|
|||||||
<Input id="c-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
|
<Input id="c-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="c-addr1">Address line 1 (optional)</FieldLabel>
|
||||||
|
<Input id="c-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} placeholder="No. 12, Galle Road" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="c-addr2">Address line 2 (optional)</FieldLabel>
|
||||||
|
<Input id="c-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} placeholder="Wellawatte" />
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="c-city">City (optional)</FieldLabel>
|
||||||
|
<Input id="c-city" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Colombo" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="c-country">Country (optional)</FieldLabel>
|
||||||
|
<Input id="c-country" value={country} onChange={(e) => setCountry(e.target.value)} placeholder="Sri Lanka" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
|
<FieldLabel htmlFor="c-credit-limit">Credit limit (LKR)</FieldLabel>
|
||||||
@@ -255,7 +299,7 @@ export default function CustomersPage() {
|
|||||||
</div>
|
</div>
|
||||||
{showBranchPicker && (
|
{showBranchPicker && (
|
||||||
<Field data-invalid={!!errors.branch}>
|
<Field data-invalid={!!errors.branch}>
|
||||||
<FieldLabel htmlFor="c-branch">Branch</FieldLabel>
|
<FieldLabel htmlFor="c-branch" required>Branch</FieldLabel>
|
||||||
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
|
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
|
||||||
<SelectTrigger id="c-branch" className="w-full" aria-invalid={!!errors.branch}>
|
<SelectTrigger id="c-branch" className="w-full" aria-invalid={!!errors.branch}>
|
||||||
<SelectValue placeholder="Select branch" />
|
<SelectValue placeholder="Select branch" />
|
||||||
@@ -357,12 +401,9 @@ export default function CustomersPage() {
|
|||||||
key={c.customerId}
|
key={c.customerId}
|
||||||
role="link"
|
role="link"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
onClick={(e) => openRow(e, c.customerId)}
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/customers/${c.customerId}`)
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") router.push(`/dashboard/customers/${c.customerId}`)
|
if (e.key === "Enter") openRow(e, c.customerId)
|
||||||
}}
|
}}
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||||
>
|
>
|
||||||
@@ -398,14 +439,15 @@ export default function CustomersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
||||||
<Link
|
<Button
|
||||||
href={`/dashboard/customers/${c.customerId}`}
|
variant="ghost"
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
size="icon-sm"
|
||||||
aria-label={`Edit ${c.customerCode}`}
|
aria-label={`Edit ${c.customerCode}`}
|
||||||
title="Edit"
|
title="Edit"
|
||||||
|
onClick={() => setEditingId(c.customerId)}
|
||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Link>
|
</Button>
|
||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<AlertDialogTrigger
|
||||||
@@ -460,10 +502,7 @@ export default function CustomersPage() {
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={c.customerId}
|
key={c.customerId}
|
||||||
className="align-top cursor-pointer hover:bg-muted/20"
|
className="align-top cursor-pointer hover:bg-muted/20"
|
||||||
onClick={(e) => {
|
onClick={(e) => openRow(e, c.customerId)}
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/customers/${c.customerId}`)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link href={`/dashboard/customers/${c.customerId}`} className="font-semibold text-foreground hover:underline">
|
<Link href={`/dashboard/customers/${c.customerId}`} className="font-semibold text-foreground hover:underline">
|
||||||
@@ -497,14 +536,15 @@ export default function CustomersPage() {
|
|||||||
>
|
>
|
||||||
<Eye className="size-4" />
|
<Eye className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Button
|
||||||
href={`/dashboard/customers/${c.customerId}`}
|
variant="ghost"
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
size="icon-sm"
|
||||||
aria-label={`Edit ${c.customerCode}`}
|
aria-label={`Edit ${c.customerCode}`}
|
||||||
title="Edit"
|
title="Edit"
|
||||||
|
onClick={() => setEditingId(c.customerId)}
|
||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Link>
|
</Button>
|
||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<AlertDialogTrigger
|
||||||
@@ -549,6 +589,21 @@ export default function CustomersPage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingId !== null && (
|
||||||
|
<EditCustomerDialog
|
||||||
|
key={editingId}
|
||||||
|
customerId={editingId}
|
||||||
|
onClose={() => setEditingId(null)}
|
||||||
|
onSaved={(saved) => {
|
||||||
|
// The code is editable here — swap it so later creates de-dupe against the new one.
|
||||||
|
const oldCode = customers?.find((c) => c.customerId === saved.customerId)?.customerCode
|
||||||
|
setAllCustomerCodes((codes) => [...codes.filter((c) => c !== oldCode), saved.customerCode])
|
||||||
|
setEditingId(null)
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { DashboardStats } from "@/types/dashboard"
|
|||||||
import { LedgerEntry } from "@/types/stock"
|
import { LedgerEntry } from "@/types/stock"
|
||||||
import { Warehouse } from "@/types/master-data"
|
import { Warehouse } from "@/types/master-data"
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { StatCard } from "@/components/ui/stat-card"
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -33,6 +34,8 @@ import BarChart from "@/components/ui/bar-chart"
|
|||||||
import LineChart from "@/components/ui/line-chart"
|
import LineChart from "@/components/ui/line-chart"
|
||||||
|
|
||||||
const TREND_DAYS = 14
|
const TREND_DAYS = 14
|
||||||
|
/** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */
|
||||||
|
const CHART_MIN_SLOTS = 5
|
||||||
|
|
||||||
function isoDate(d: Date) {
|
function isoDate(d: Date) {
|
||||||
return d.toISOString().slice(0, 10)
|
return d.toISOString().slice(0, 10)
|
||||||
@@ -114,6 +117,13 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [trend])
|
}, [trend])
|
||||||
|
|
||||||
|
// Same fixed-axis padding as the product page's chart: with fewer than CHART_MIN_SLOTS
|
||||||
|
// warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep
|
||||||
|
// the bars at the start. Skipped on mobile, where the bars should stretch to fill the card.
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const valuationRows = stats?.stockValuationByWarehouse ?? []
|
||||||
|
const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length)
|
||||||
|
|
||||||
const loaded = stats && movements && trend && warehouses
|
const loaded = stats && movements && trend && warehouses
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -172,16 +182,18 @@ export default function DashboardPage() {
|
|||||||
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
||||||
<div className="h-64 sm:h-72">
|
<div className="h-64 sm:h-72">
|
||||||
<BarChart
|
<BarChart
|
||||||
labels={stats.stockValuationByWarehouse.map(
|
labels={[
|
||||||
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
|
...valuationRows.map((w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`),
|
||||||
)}
|
...Array<string>(valuationPad).fill(""),
|
||||||
|
]}
|
||||||
datasets={[
|
datasets={[
|
||||||
{
|
{
|
||||||
label: "Stock Value (LKR)",
|
label: "Stock Value (LKR)",
|
||||||
data: stats.stockValuationByWarehouse.map((w) => w.total),
|
data: [...valuationRows.map((w) => w.total), ...Array<null>(valuationPad).fill(null)],
|
||||||
backgroundColor: "#E11D48",
|
backgroundColor: "#E11D48",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
barPercentage={0.6}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -294,7 +294,13 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<PurchaseInvoicePaymentDialog invoice={invoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
<PurchaseInvoicePaymentDialog
|
||||||
|
invoice={invoice}
|
||||||
|
open={payDialogOpen}
|
||||||
|
onOpenChange={setPayDialogOpen}
|
||||||
|
onPaid={handlePaid}
|
||||||
|
onFullyPaid={() => router.push("/dashboard/receiving/grn")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -433,7 +433,13 @@ export default function PurchaseInvoicesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PurchaseInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
<PurchaseInvoicePaymentDialog
|
||||||
|
invoice={payDialogInvoice}
|
||||||
|
open={payDialogOpen}
|
||||||
|
onOpenChange={setPayDialogOpen}
|
||||||
|
onPaid={handlePaid}
|
||||||
|
onFullyPaid={() => router.push("/dashboard/receiving/grn")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { purchaseReturnsApi } from "@/lib/api/purchase-returns"
|
|||||||
import { grnsApi } from "@/lib/api/grns"
|
import { grnsApi } from "@/lib/api/grns"
|
||||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { vendorsApi } from "@/lib/api/vendors"
|
||||||
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validateReturnLine } from "@/lib/validations/procurement"
|
import { validateReturnLine } from "@/lib/validations/procurement"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -23,11 +25,17 @@ import { Label } from "@/components/ui/label"
|
|||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { FieldError } from "@/components/ui/field"
|
import { FieldError } from "@/components/ui/field"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Combobox } from "@/components/ui/combobox"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import { HoldStatusBadge } from "@/components/receiving/status-badges"
|
import { HoldStatusBadge } from "@/components/receiving/status-badges"
|
||||||
|
|
||||||
|
// The stock checkbox (16px, faint border) and qty box are easy to miss on this form, so both are
|
||||||
|
// enlarged and given a solid border here.
|
||||||
|
const LINE_CHECKBOX = "size-5 shrink-0 border-2 border-muted-foreground/60"
|
||||||
|
const RETURN_QTY_INPUT = "h-11 border-2 border-muted-foreground/40 bg-background text-base font-medium md:text-base"
|
||||||
|
|
||||||
interface LineState {
|
interface LineState {
|
||||||
selected: boolean
|
selected: boolean
|
||||||
qty: string
|
qty: string
|
||||||
@@ -42,6 +50,8 @@ function NewPurchaseReturnContent() {
|
|||||||
const [grns, setGrns] = useState<Grn[] | null>(null)
|
const [grns, setGrns] = useState<Grn[] | null>(null)
|
||||||
const [items, setItems] = useState<ItemListItem[]>([])
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
||||||
|
const [vendorNames, setVendorNames] = useState<Map<number, string>>(new Map())
|
||||||
|
const [warehouseNames, setWarehouseNames] = useState<Map<number, string>>(new Map())
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [grnId, setGrnId] = useState<number | null>(presetGrnId)
|
const [grnId, setGrnId] = useState<number | null>(presetGrnId)
|
||||||
@@ -62,8 +72,18 @@ function NewPurchaseReturnContent() {
|
|||||||
setReasonCodes(rc.items)
|
setReasonCodes(rc.items)
|
||||||
})
|
})
|
||||||
.catch((err) => setLoadError(errorMessage(err)))
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
|
|
||||||
|
// Names only make the GRN picker searchable by vendor/warehouse — if these fail the
|
||||||
|
// labels fall back to "#id", so they don't block the form.
|
||||||
|
vendorsApi.list({ pageSize: 200 }).then((r) => setVendorNames(new Map(r.items.map((v) => [v.vendorId, v.name])))).catch(() => {})
|
||||||
|
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouseNames(new Map(r.items.map((w) => [w.warehouseId, w.name])))).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const grnOptions = (grns ?? []).map((g) => ({
|
||||||
|
value: g.grnId,
|
||||||
|
label: `${g.docNo} — ${vendorNames.get(g.vendorId) ?? `Vendor #${g.vendorId}`}, ${warehouseNames.get(g.warehouseId) ?? `Warehouse #${g.warehouseId}`}`,
|
||||||
|
}))
|
||||||
|
|
||||||
const selectedGrn = grns?.find((g) => g.grnId === grnId) ?? null
|
const selectedGrn = grns?.find((g) => g.grnId === grnId) ?? null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -87,14 +107,18 @@ function NewPurchaseReturnContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleLine(line: GrnLine) {
|
function toggleLine(line: GrnLine) {
|
||||||
setLineState((prev) => ({
|
setLineState((prev) => {
|
||||||
...prev,
|
const selected = !prev[line.grnLineId]?.selected
|
||||||
[line.grnLineId]: { selected: !prev[line.grnLineId]?.selected, qty: prev[line.grnLineId]?.qty || String(line.qty) },
|
// Ticking pre-fills the full received qty; unticking clears it, so a qty is only ever
|
||||||
}))
|
// shown on lines that will actually be returned.
|
||||||
|
return { ...prev, [line.grnLineId]: { selected, qty: selected ? prev[line.grnLineId]?.qty || String(line.qty) : "" } }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Typing a quantity ticks the line (and clearing it / entering 0 unticks it), so the qty box
|
||||||
|
// can stay editable instead of sitting greyed-out until the checkbox is found and ticked.
|
||||||
function setQty(grnLineId: number, qty: string) {
|
function setQty(grnLineId: number, qty: string) {
|
||||||
setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty } }))
|
setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty, selected: Number(qty) > 0 } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
@@ -173,18 +197,15 @@ function NewPurchaseReturnContent() {
|
|||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||||
<Label className="text-base">Goods Receipt Note</Label>
|
<Label className="text-base">Goods Receipt Note</Label>
|
||||||
<Select<number | null> value={grnId} onValueChange={setGrnId} disabled={!!presetGrnId}>
|
<Combobox<number>
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
items={grnOptions}
|
||||||
<SelectValue placeholder="Select a confirmed GRN" />
|
value={grnId}
|
||||||
</SelectTrigger>
|
onValueChange={setGrnId}
|
||||||
<SelectContent>
|
disabled={!!presetGrnId}
|
||||||
{(grns ?? []).map((g) => (
|
placeholder="Search GRN — number, vendor or warehouse…"
|
||||||
<SelectItem key={g.grnId} value={g.grnId} className="text-base">
|
emptyText="No confirmed GRNs match."
|
||||||
{g.docNo} — Vendor #{g.vendorId}, Warehouse #{g.warehouseId}
|
className="h-12 text-base"
|
||||||
</SelectItem>
|
/>
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Reason</Label>
|
<Label className="text-base">Reason</Label>
|
||||||
@@ -218,9 +239,12 @@ function NewPurchaseReturnContent() {
|
|||||||
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
|
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
|
||||||
const errors = lineErrors[line.grnLineId] ?? {}
|
const errors = lineErrors[line.grnLineId] ?? {}
|
||||||
return (
|
return (
|
||||||
<div key={line.grnLineId} className="flex flex-col gap-3 rounded-xl border p-4">
|
<div
|
||||||
<label className="flex items-start gap-3">
|
key={line.grnLineId}
|
||||||
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} className="mt-0.5 shrink-0" />
|
className={cn("flex flex-col gap-3 rounded-xl border p-4", state.selected && "border-primary bg-primary/5")}
|
||||||
|
>
|
||||||
|
<label className="flex cursor-pointer items-start gap-3">
|
||||||
|
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} className={cn(LINE_CHECKBOX, "mt-0.5")} />
|
||||||
<span className="min-w-0 truncate text-base font-medium text-foreground">
|
<span className="min-w-0 truncate text-base font-medium text-foreground">
|
||||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||||
</span>
|
</span>
|
||||||
@@ -238,17 +262,18 @@ function NewPurchaseReturnContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm">Return qty</Label>
|
<Label className="text-sm font-medium text-foreground">Return qty</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
max={line.qty}
|
||||||
step="any"
|
step="any"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={state.qty}
|
value={state.qty}
|
||||||
disabled={!state.selected}
|
placeholder={`0 – ${line.qty}`}
|
||||||
aria-invalid={!!errors.qty}
|
aria-invalid={!!errors.qty}
|
||||||
onChange={(e) => setQty(line.grnLineId, e.target.value)}
|
onChange={(e) => setQty(line.grnLineId, e.target.value)}
|
||||||
className="h-11 text-base"
|
className={RETURN_QTY_INPUT}
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||||
</div>
|
</div>
|
||||||
@@ -275,9 +300,14 @@ function NewPurchaseReturnContent() {
|
|||||||
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
|
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
|
||||||
const errors = lineErrors[line.grnLineId] ?? {}
|
const errors = lineErrors[line.grnLineId] ?? {}
|
||||||
return (
|
return (
|
||||||
<TableRow key={line.grnLineId}>
|
<TableRow key={line.grnLineId} className={cn(state.selected && "bg-primary/5 hover:bg-primary/10")}>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} />
|
<Checkbox
|
||||||
|
checked={state.selected}
|
||||||
|
onCheckedChange={() => toggleLine(line)}
|
||||||
|
className={LINE_CHECKBOX}
|
||||||
|
aria-label={`Return ${item?.sku ?? `item #${line.itemId}`}`}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||||
@@ -288,12 +318,14 @@ function NewPurchaseReturnContent() {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
max={line.qty}
|
||||||
step="any"
|
step="any"
|
||||||
value={state.qty}
|
value={state.qty}
|
||||||
disabled={!state.selected}
|
placeholder={`0 – ${line.qty}`}
|
||||||
aria-invalid={!!errors.qty}
|
aria-invalid={!!errors.qty}
|
||||||
|
aria-label="Return qty"
|
||||||
onChange={(e) => setQty(line.grnLineId, e.target.value)}
|
onChange={(e) => setQty(line.grnLineId, e.target.value)}
|
||||||
className="h-11 text-base"
|
className={RETURN_QTY_INPUT}
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -86,10 +86,6 @@ export default function NewRequisitionPage() {
|
|||||||
setSubmitError("Add at least one line.")
|
setSubmitError("Add at least one line.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (branchingEnabled && !effectiveBranchId) {
|
|
||||||
setSubmitError("Select a branch for this requisition.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLineErrors: Record<string, Record<string, string>> = {}
|
const nextLineErrors: Record<string, Record<string, string>> = {}
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
@@ -142,11 +138,11 @@ export default function NewRequisitionPage() {
|
|||||||
<>
|
<>
|
||||||
{branchingEnabled && isAdmin && (
|
{branchingEnabled && isAdmin && (
|
||||||
<div className="flex flex-col gap-2 sm:max-w-xs">
|
<div className="flex flex-col gap-2 sm:max-w-xs">
|
||||||
<Label className="text-base">Branch</Label>
|
<Label className="text-base">Branch (optional)</Label>
|
||||||
{showBranchPicker ? (
|
{showBranchPicker ? (
|
||||||
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
|
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
<SelectValue placeholder="All branches" />
|
<SelectValue placeholder="Default branch" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{branches.map((b) => (
|
{branches.map((b) => (
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import { toast } from "@/components/ui/toast"
|
|||||||
import LineChart from "@/components/ui/line-chart"
|
import LineChart from "@/components/ui/line-chart"
|
||||||
import BarChart from "@/components/ui/bar-chart"
|
import BarChart from "@/components/ui/bar-chart"
|
||||||
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
|
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
|
||||||
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
|
|
||||||
|
|
||||||
function docLink(sourceDocType: string, sourceDocId: number): string | null {
|
function docLink(sourceDocType: string, sourceDocId: number): string | null {
|
||||||
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
|
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
|
||||||
@@ -420,16 +419,6 @@ export default function ItemDetailPage() {
|
|||||||
dialog closes so this copy picks up what changed there. */}
|
dialog closes so this copy picks up what changed there. */}
|
||||||
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
|
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
|
||||||
|
|
||||||
<ItemReorderPanel
|
|
||||||
key={item.itemId}
|
|
||||||
itemId={item.itemId}
|
|
||||||
stockNature={item.stockNature}
|
|
||||||
settings={item.reorder}
|
|
||||||
warehouses={warehouses}
|
|
||||||
uomName={uomName(item.baseUomId)}
|
|
||||||
onSaved={(settings) => setItem((prev) => (prev ? { ...prev, reorder: settings } : prev))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -288,12 +288,24 @@ export default function NewItemPage() {
|
|||||||
*/
|
*/
|
||||||
const productLabel = productName.trim()
|
const productLabel = productName.trim()
|
||||||
|
|
||||||
|
/** Drops the given submit errors once the user edits the field they belong to. */
|
||||||
|
function clearErrors(...keys: string[]) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!keys.some((k) => prev[k])) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const k of keys) delete next[k]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function handleCategoryChange(value: number | null) {
|
function handleCategoryChange(value: number | null) {
|
||||||
setCategoryId(value)
|
setCategoryId(value)
|
||||||
setSubCategoryId(null)
|
setSubCategoryId(null)
|
||||||
|
clearErrors("categoryId")
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleItemType(itemTypeId: number) {
|
function toggleItemType(itemTypeId: number) {
|
||||||
|
clearErrors("variants", "measurable", "variantContent")
|
||||||
setCheckedItemTypeIds((prev) =>
|
setCheckedItemTypeIds((prev) =>
|
||||||
prev.includes(itemTypeId) ? prev.filter((id) => id !== itemTypeId) : [...prev, itemTypeId]
|
prev.includes(itemTypeId) ? prev.filter((id) => id !== itemTypeId) : [...prev, itemTypeId]
|
||||||
)
|
)
|
||||||
@@ -338,6 +350,7 @@ export default function NewItemPage() {
|
|||||||
}
|
}
|
||||||
setItemOnlyWarrantyMonths(parsed.months)
|
setItemOnlyWarrantyMonths(parsed.months)
|
||||||
setWarrantyPeriodMonths(parsed.months)
|
setWarrantyPeriodMonths(parsed.months)
|
||||||
|
clearErrors("warrantyPeriodMonths")
|
||||||
closeCustomPeriod()
|
closeCustomPeriod()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,9 +580,18 @@ export default function NewItemPage() {
|
|||||||
const nextPriceErrors =
|
const nextPriceErrors =
|
||||||
config?.sellingPricesEnabled && priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {}
|
config?.sellingPricesEnabled && priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {}
|
||||||
setPriceErrors(nextPriceErrors)
|
setPriceErrors(nextPriceErrors)
|
||||||
if (Object.keys(nextErrors).length > 0 || Object.keys(nextPriceErrors).length > 0) return
|
const messages = [...new Set([...Object.values(nextErrors), ...Object.values(nextPriceErrors)])]
|
||||||
|
if (messages.length > 0) {
|
||||||
|
toast.error(
|
||||||
|
"Please fix the highlighted fields",
|
||||||
|
messages.length === 1 ? messages[0] : `${messages[0]} (and ${messages.length - 1} more)`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (baseUomId === null) {
|
if (baseUomId === null) {
|
||||||
setSubmitError("No unit of measure exists yet — create one under Products → UOM before adding items.")
|
const message = "No unit of measure exists yet — create one under Products → UOM before adding items."
|
||||||
|
setSubmitError(message)
|
||||||
|
toast.error("Could not create item", message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,10 +711,13 @@ export default function NewItemPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Product name</Label>
|
<Label className="text-base" required>Product name</Label>
|
||||||
<Input
|
<Input
|
||||||
value={productName}
|
value={productName}
|
||||||
onChange={(e) => setProductName(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setProductName(e.target.value)
|
||||||
|
clearErrors("productName")
|
||||||
|
}}
|
||||||
placeholder="e.g. Coca Cola"
|
placeholder="e.g. Coca Cola"
|
||||||
aria-invalid={!!errors.productName}
|
aria-invalid={!!errors.productName}
|
||||||
className="h-12! text-base"
|
className="h-12! text-base"
|
||||||
@@ -705,7 +730,7 @@ export default function NewItemPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Category</Label>
|
<Label className="text-base" required>Category</Label>
|
||||||
<Select<number | null>
|
<Select<number | null>
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onValueChange={handleCategoryChange}
|
onValueChange={handleCategoryChange}
|
||||||
@@ -789,7 +814,7 @@ export default function NewItemPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Base UOM</Label>
|
<Label className="text-base" required>Base UOM</Label>
|
||||||
<Select<number | null>
|
<Select<number | null>
|
||||||
value={baseUomId}
|
value={baseUomId}
|
||||||
onValueChange={setBaseUomId}
|
onValueChange={setBaseUomId}
|
||||||
@@ -846,14 +871,20 @@ export default function NewItemPage() {
|
|||||||
min="0"
|
min="0"
|
||||||
step="any"
|
step="any"
|
||||||
value={contentQty}
|
value={contentQty}
|
||||||
onChange={(e) => setContentQty(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setContentQty(e.target.value)
|
||||||
|
clearErrors("contentQty", "contentUnit")
|
||||||
|
}}
|
||||||
placeholder="e.g. 500"
|
placeholder="e.g. 500"
|
||||||
aria-invalid={!!errors.contentQty}
|
aria-invalid={!!errors.contentQty}
|
||||||
className="h-12! text-base"
|
className="h-12! text-base"
|
||||||
/>
|
/>
|
||||||
<Select<MeasureUnit | null>
|
<Select<MeasureUnit | null>
|
||||||
value={contentUnit}
|
value={contentUnit}
|
||||||
onValueChange={setContentUnit}
|
onValueChange={(v) => {
|
||||||
|
setContentUnit(v)
|
||||||
|
clearErrors("contentQty", "contentUnit")
|
||||||
|
}}
|
||||||
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
|
items={CONTENT_UNITS.map((u) => ({ label: u, value: u }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}>
|
<SelectTrigger className="h-12! w-28 shrink-0 text-base" aria-invalid={!!errors.contentUnit}>
|
||||||
@@ -1004,7 +1035,10 @@ export default function NewItemPage() {
|
|||||||
value={warranty}
|
value={warranty}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setWarranty(v as typeof warranty)
|
setWarranty(v as typeof warranty)
|
||||||
if (v === "NonWarranty") setWarrantyPeriodMonths(null)
|
if (v === "NonWarranty") {
|
||||||
|
setWarrantyPeriodMonths(null)
|
||||||
|
clearErrors("warrantyPeriodMonths")
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="flex flex-col gap-3"
|
className="flex flex-col gap-3"
|
||||||
>
|
>
|
||||||
@@ -1020,10 +1054,14 @@ export default function NewItemPage() {
|
|||||||
|
|
||||||
{warranty === "Warranty" && (
|
{warranty === "Warranty" && (
|
||||||
<div className="mt-2 flex flex-col gap-1.5">
|
<div className="mt-2 flex flex-col gap-1.5">
|
||||||
<Label>Warranty period +</Label>
|
<Label required>Warranty period +</Label>
|
||||||
<Select<WarrantyPeriodMonths>
|
<Select<WarrantyPeriodMonths>
|
||||||
value={warrantyPeriodMonths}
|
value={warrantyPeriodMonths}
|
||||||
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
|
onValueChange={(v) => {
|
||||||
|
if (!v) return
|
||||||
|
setWarrantyPeriodMonths(v)
|
||||||
|
clearErrors("warrantyPeriodMonths")
|
||||||
|
}}
|
||||||
items={warrantySelectGroups.flatMap((g) => g.months).map((m) => ({
|
items={warrantySelectGroups.flatMap((g) => g.months).map((m) => ({
|
||||||
label: formatWarrantyPeriod(m),
|
label: formatWarrantyPeriod(m),
|
||||||
value: m,
|
value: m,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useParams } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, Plus, ShieldAlert, XCircle } from "lucide-react"
|
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, Plus, ShieldAlert, XCircle } from "lucide-react"
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ import { errorMessage } from "@/lib/error-map"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ConfirmGrnResponse, Grn } from "@/types/grn"
|
import { ConfirmGrnResponse, Grn } from "@/types/grn"
|
||||||
import { Bin, ItemListItem, Uom } from "@/types/master-data"
|
import { Bin, ItemListItem, Uom } from "@/types/master-data"
|
||||||
import { PurchaseInvoice } from "@/types/purchase-invoices"
|
import { PurchaseInvoice, PurchaseInvoicePayment, PurchaseInvoiceStatus } from "@/types/purchase-invoices"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
@@ -24,9 +24,12 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
|
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
|
||||||
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
|
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
|
||||||
|
import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint"
|
||||||
|
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
|
||||||
|
|
||||||
export default function GrnDetailPage() {
|
export default function GrnDetailPage() {
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
const grnId = Number(params.id)
|
const grnId = Number(params.id)
|
||||||
|
|
||||||
const [grn, setGrn] = useState<Grn | null>(null)
|
const [grn, setGrn] = useState<Grn | null>(null)
|
||||||
@@ -40,6 +43,28 @@ export default function GrnDetailPage() {
|
|||||||
const [releasingLineId, setReleasingLineId] = useState<number | null>(null)
|
const [releasingLineId, setReleasingLineId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [linkedInvoice, setLinkedInvoice] = useState<PurchaseInvoice | null>(null)
|
const [linkedInvoice, setLinkedInvoice] = useState<PurchaseInvoice | null>(null)
|
||||||
|
const [viewInvoiceOpen, setViewInvoiceOpen] = useState(false)
|
||||||
|
const [payOpen, setPayOpen] = useState(false)
|
||||||
|
// Bumped after each payment so the invoice preview reloads with the new paid/balance.
|
||||||
|
const [invoiceRefreshKey, setInvoiceRefreshKey] = useState(0)
|
||||||
|
|
||||||
|
/** Keeps the "Balance due" line here, and the invoice preview, in step with payments recorded. */
|
||||||
|
function handleInvoicePaid(payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) {
|
||||||
|
setInvoiceRefreshKey((k) => k + 1)
|
||||||
|
setLinkedInvoice((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
status: newStatus,
|
||||||
|
totals: {
|
||||||
|
...prev.totals,
|
||||||
|
paidAmount: prev.totals.paidAmount + payment.amount,
|
||||||
|
balanceAmount: prev.totals.balanceAmount - payment.amount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Stable per detail-page-session key so a retried confirm click doesn't double-post.
|
// Stable per detail-page-session key so a retried confirm click doesn't double-post.
|
||||||
const idempotencyKey = useRef(crypto.randomUUID())
|
const idempotencyKey = useRef(crypto.randomUUID())
|
||||||
@@ -429,13 +454,10 @@ export default function GrnDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Button variant="outline" size="sm" onClick={() => setViewInvoiceOpen(true)}>
|
||||||
href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
|
||||||
>
|
|
||||||
<FileText className="size-4" />
|
<FileText className="size-4" />
|
||||||
View invoice
|
View invoice
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -451,6 +473,28 @@ export default function GrnDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PurchaseInvoicePrintDialog
|
||||||
|
purchaseInvoiceId={grn.purchaseInvoiceId ?? null}
|
||||||
|
invoiceNo={linkedInvoice?.docNo}
|
||||||
|
open={viewInvoiceOpen}
|
||||||
|
onOpenChange={setViewInvoiceOpen}
|
||||||
|
refreshKey={invoiceRefreshKey}
|
||||||
|
actions={
|
||||||
|
linkedInvoice && linkedInvoice.status !== "Cancelled" ? (
|
||||||
|
<Button size="sm" onClick={() => setPayOpen(true)} disabled={linkedInvoice.totals.balanceAmount <= 0}>
|
||||||
|
{linkedInvoice.totals.balanceAmount <= 0 ? "Fully paid" : "Record payment"}
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PurchaseInvoicePaymentDialog
|
||||||
|
invoice={linkedInvoice}
|
||||||
|
open={payOpen}
|
||||||
|
onOpenChange={setPayOpen}
|
||||||
|
onPaid={handleInvoicePaid}
|
||||||
|
onFullyPaid={() => router.push("/dashboard/receiving/grn")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-3
@@ -77,9 +77,21 @@ export default function VendorDetailPage() {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [vendorId])
|
}, [vendorId])
|
||||||
|
|
||||||
|
/** Drops fields' errors once the user starts editing them. */
|
||||||
|
function clearErrors(...keys: string[]) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!keys.some((k) => prev[k])) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const k of keys) delete next[k]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function handleNameChange(value: string) {
|
function handleNameChange(value: string) {
|
||||||
setName(value)
|
setName(value)
|
||||||
setCode(value.trim() ? generateVendorCode(value, otherVendorCodes) : "")
|
setCode(value.trim() ? generateVendorCode(value, otherVendorCodes) : "")
|
||||||
|
// The code is regenerated from the name, so its error is stale too.
|
||||||
|
clearErrors("name", "code")
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyVendor(data: Vendor) {
|
function applyVendor(data: Vendor) {
|
||||||
@@ -457,7 +469,7 @@ export default function VendorDetailPage() {
|
|||||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Name</Label>
|
<Label className="text-base" required>Name</Label>
|
||||||
<Input value={name} onChange={(e) => handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
<Input value={name} onChange={(e) => handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
</div>
|
</div>
|
||||||
@@ -470,8 +482,8 @@ export default function VendorDetailPage() {
|
|||||||
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Currency</Label>
|
<Label className="text-base" required>Currency</Label>
|
||||||
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} />
|
<Input value={currency} onChange={(e) => { setCurrency(e.target.value); clearErrors("currency") }} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} />
|
||||||
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
|
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+71
-34
@@ -33,6 +33,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
import { EditVendorDialog } from "@/components/vendors/EditVendorDialog"
|
||||||
|
|
||||||
type StatusFilter = EntityStatus | "All"
|
type StatusFilter = EntityStatus | "All"
|
||||||
|
|
||||||
@@ -69,6 +70,8 @@ export default function VendorsPage() {
|
|||||||
const [allVendorCodes, setAllVendorCodes] = useState<string[]>([])
|
const [allVendorCodes, setAllVendorCodes] = useState<string[]>([])
|
||||||
|
|
||||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
// The vendor whose edit popup is open.
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||||
|
|
||||||
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
|
const generatedCode = name.trim() ? generateVendorCode(name, allVendorCodes) : ""
|
||||||
@@ -130,6 +133,16 @@ export default function VendorsPage() {
|
|||||||
setErrors({})
|
setErrors({})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drops a field's error once the user starts editing it. */
|
||||||
|
function clearError(key: string) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!prev[key]) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[key]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
if (!name.trim()) nextErrors.name = "Vendor name is required"
|
if (!name.trim()) nextErrors.name = "Vendor name is required"
|
||||||
@@ -155,14 +168,15 @@ export default function VendorsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FR-MD-08: masters are deactivated, never hard-deleted — "Delete" in the UI
|
// Hard delete. The server refuses (409) a vendor any purchase document or item still
|
||||||
// maps to PATCH status → Inactive, same mechanism as the detail page's toggle.
|
// references — those can only be deactivated, from the detail page's status toggle.
|
||||||
async function handleDeactivate(vendor: Vendor) {
|
async function handleDelete(vendor: Vendor) {
|
||||||
setActionPendingId(vendor.vendorId)
|
setActionPendingId(vendor.vendorId)
|
||||||
try {
|
try {
|
||||||
await vendorsApi.updateStatus(vendor.vendorId, "Inactive")
|
await vendorsApi.delete(vendor.vendorId)
|
||||||
toast.success("Vendor deleted", `${vendor.code} has been deactivated.`)
|
toast.success("Vendor deleted", `${vendor.code} — ${vendor.name}`)
|
||||||
load()
|
load()
|
||||||
|
setAllVendorCodes((codes) => codes.filter((c) => c !== vendor.code))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not delete vendor", errorMessage(err))
|
toast.error("Could not delete vendor", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -170,6 +184,17 @@ export default function VendorsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row click → detail page. React bubbles events out of portals along the component tree,
|
||||||
|
* so a click on the delete confirmation (portaled to <body>) still reaches the row; only
|
||||||
|
* navigate for events whose DOM target really is inside the row and not an action cell.
|
||||||
|
*/
|
||||||
|
function openRow(e: React.MouseEvent | React.KeyboardEvent, vendorId: number) {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!e.currentTarget.contains(target) || target.closest("[data-no-row-nav]")) return
|
||||||
|
router.push(`/dashboard/vendors/${vendorId}`)
|
||||||
|
}
|
||||||
|
|
||||||
const hasFilters = query.length > 0 || status !== "All"
|
const hasFilters = query.length > 0 || status !== "All"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -196,8 +221,8 @@ export default function VendorsPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.name}>
|
<Field data-invalid={!!errors.name}>
|
||||||
<FieldLabel htmlFor="v-name">Name</FieldLabel>
|
<FieldLabel htmlFor="v-name" required>Name</FieldLabel>
|
||||||
<Input id="v-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
|
<Input id="v-name" value={name} onChange={(e) => { setName(e.target.value); clearError("name") }} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
@@ -213,14 +238,14 @@ export default function VendorsPage() {
|
|||||||
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
|
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field data-invalid={!!errors.currency}>
|
<Field data-invalid={!!errors.currency}>
|
||||||
<FieldLabel htmlFor="v-currency">Currency</FieldLabel>
|
<FieldLabel htmlFor="v-currency" required>Currency</FieldLabel>
|
||||||
<Input id="v-currency" value={currency} onChange={(e) => setCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
|
<Input id="v-currency" value={currency} onChange={(e) => { setCurrency(e.target.value); clearError("currency") }} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
|
||||||
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
|
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
|
||||||
</Field>
|
</Field>
|
||||||
{showBranchPicker && (
|
{showBranchPicker && (
|
||||||
<Field data-invalid={!!errors.branch}>
|
<Field data-invalid={!!errors.branch}>
|
||||||
<FieldLabel htmlFor="v-branch">Branch</FieldLabel>
|
<FieldLabel htmlFor="v-branch" required>Branch</FieldLabel>
|
||||||
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
|
<Select<number | null> value={branchFilterId} onValueChange={(v) => { setBranchFilterId(v); clearError("branch") }}>
|
||||||
<SelectTrigger id="v-branch" className="w-full" aria-invalid={!!errors.branch}>
|
<SelectTrigger id="v-branch" className="w-full" aria-invalid={!!errors.branch}>
|
||||||
<SelectValue placeholder="Select branch" />
|
<SelectValue placeholder="Select branch" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -311,12 +336,9 @@ export default function VendorsPage() {
|
|||||||
key={v.vendorId}
|
key={v.vendorId}
|
||||||
role="link"
|
role="link"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
onClick={(e) => openRow(e, v.vendorId)}
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/vendors/${v.vendorId}`)
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") router.push(`/dashboard/vendors/${v.vendorId}`)
|
if (e.key === "Enter") openRow(e, v.vendorId)
|
||||||
}}
|
}}
|
||||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||||
>
|
>
|
||||||
@@ -356,14 +378,15 @@ export default function VendorsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
||||||
<Link
|
<Button
|
||||||
href={`/dashboard/vendors/${v.vendorId}`}
|
variant="ghost"
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
size="icon-sm"
|
||||||
aria-label={`Update ${v.code}`}
|
aria-label={`Update ${v.code}`}
|
||||||
title="Edit"
|
title="Edit"
|
||||||
|
onClick={() => setEditingId(v.vendorId)}
|
||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Link>
|
</Button>
|
||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<AlertDialogTrigger
|
||||||
@@ -373,7 +396,7 @@ export default function VendorsPage() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10"
|
className="text-destructive hover:bg-destructive/10"
|
||||||
aria-label={`Delete ${v.code}`}
|
aria-label={`Delete ${v.code}`}
|
||||||
disabled={v.status === "Inactive" || actionPendingId === v.vendorId}
|
disabled={actionPendingId === v.vendorId}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -382,9 +405,9 @@ export default function VendorsPage() {
|
|||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
title={`Delete ${v.code}?`}
|
title={`Delete ${v.code}?`}
|
||||||
description={`This deactivates ${v.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its detail page.`}
|
description={`This permanently deletes ${v.name} and cannot be undone. A vendor used on purchase documents or items can't be deleted — deactivate it from its detail page instead.`}
|
||||||
confirmLabel="Delete"
|
confirmLabel="Delete"
|
||||||
onConfirm={() => handleDeactivate(v)}
|
onConfirm={() => handleDelete(v)}
|
||||||
/>
|
/>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -418,10 +441,7 @@ export default function VendorsPage() {
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={v.vendorId}
|
key={v.vendorId}
|
||||||
className="align-top cursor-pointer hover:bg-muted/20"
|
className="align-top cursor-pointer hover:bg-muted/20"
|
||||||
onClick={(e) => {
|
onClick={(e) => openRow(e, v.vendorId)}
|
||||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
|
||||||
router.push(`/dashboard/vendors/${v.vendorId}`)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link href={`/dashboard/vendors/${v.vendorId}`} className="font-semibold text-foreground hover:underline">
|
<Link href={`/dashboard/vendors/${v.vendorId}`} className="font-semibold text-foreground hover:underline">
|
||||||
@@ -457,14 +477,15 @@ export default function VendorsPage() {
|
|||||||
>
|
>
|
||||||
<Eye className="size-4" />
|
<Eye className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Button
|
||||||
href={`/dashboard/vendors/${v.vendorId}`}
|
variant="ghost"
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
size="icon-sm"
|
||||||
aria-label={`Update ${v.code}`}
|
aria-label={`Update ${v.code}`}
|
||||||
title="Edit"
|
title="Edit"
|
||||||
|
onClick={() => setEditingId(v.vendorId)}
|
||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Link>
|
</Button>
|
||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<AlertDialogTrigger
|
||||||
@@ -474,7 +495,7 @@ export default function VendorsPage() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10"
|
className="text-destructive hover:bg-destructive/10"
|
||||||
aria-label={`Delete ${v.code}`}
|
aria-label={`Delete ${v.code}`}
|
||||||
disabled={v.status === "Inactive" || actionPendingId === v.vendorId}
|
disabled={actionPendingId === v.vendorId}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -483,9 +504,9 @@ export default function VendorsPage() {
|
|||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
title={`Delete ${v.code}?`}
|
title={`Delete ${v.code}?`}
|
||||||
description={`This deactivates ${v.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its detail page.`}
|
description={`This permanently deletes ${v.name} and cannot be undone. A vendor used on purchase documents or items can't be deleted — deactivate it from its detail page instead.`}
|
||||||
confirmLabel="Delete"
|
confirmLabel="Delete"
|
||||||
onConfirm={() => handleDeactivate(v)}
|
onConfirm={() => handleDelete(v)}
|
||||||
/>
|
/>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -509,6 +530,22 @@ export default function VendorsPage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingId !== null && (
|
||||||
|
<EditVendorDialog
|
||||||
|
key={editingId}
|
||||||
|
vendorId={editingId}
|
||||||
|
existingCodes={allVendorCodes}
|
||||||
|
onClose={() => setEditingId(null)}
|
||||||
|
onSaved={(saved) => {
|
||||||
|
// A renamed vendor gets a regenerated code — swap it so later creates de-dupe correctly.
|
||||||
|
const oldCode = vendors?.find((v) => v.vendorId === saved.vendorId)?.code
|
||||||
|
setAllVendorCodes((codes) => [...codes.filter((c) => c !== oldCode), saved.code])
|
||||||
|
setEditingId(null)
|
||||||
|
load()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { itemsApi } from "@/lib/api/items"
|
|||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
|
import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
|
||||||
import { LedgerEntry, OnHand } from "@/types/stock"
|
import { LedgerEntry, OnHand } from "@/types/stock"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -31,6 +32,8 @@ import BarChart from "@/components/ui/bar-chart"
|
|||||||
|
|
||||||
/** Charts stay legible with a lot of items only by capping to the top N by on-hand qty. */
|
/** Charts stay legible with a lot of items only by capping to the top N by on-hand qty. */
|
||||||
const CHART_ROW_LIMIT = 15
|
const CHART_ROW_LIMIT = 15
|
||||||
|
/** Fewest bar slots the charts' axis spans, so a couple of bars sit at the start (see padSlots). */
|
||||||
|
const CHART_MIN_SLOTS = 5
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||||
|
|
||||||
@@ -120,8 +123,18 @@ export default function WarehouseDetailPage() {
|
|||||||
const stockRows = useMemo(() => (stock ?? []).slice().sort((a, b) => b.onHand - a.onHand), [stock])
|
const stockRows = useMemo(() => (stock ?? []).slice().sort((a, b) => b.onHand - a.onHand), [stock])
|
||||||
const totalOnHand = stockRows.reduce((sum, r) => sum + r.onHand, 0)
|
const totalOnHand = stockRows.reduce((sum, r) => sum + r.onHand, 0)
|
||||||
|
|
||||||
|
// Same fixed-axis padding as the product page's chart: with fewer than CHART_MIN_SLOTS bars,
|
||||||
|
// Chart.js would center them across the full width. Trailing blank slots keep the bars at the
|
||||||
|
// start. Skipped on mobile, where the bars should stretch to fill the narrower card instead.
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const padSlots = (count: number) => (isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - count))
|
||||||
|
|
||||||
const chartRows = stockRows.slice(0, CHART_ROW_LIMIT)
|
const chartRows = stockRows.slice(0, CHART_ROW_LIMIT)
|
||||||
const chartLabels = chartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`)
|
const chartPad = padSlots(chartRows.length)
|
||||||
|
const chartLabels = [
|
||||||
|
...chartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`),
|
||||||
|
...Array<string>(chartPad).fill(""),
|
||||||
|
]
|
||||||
|
|
||||||
const movementByItem = useMemo(() => {
|
const movementByItem = useMemo(() => {
|
||||||
const map = new Map<number, { in: number; out: number }>()
|
const map = new Map<number, { in: number; out: number }>()
|
||||||
@@ -137,12 +150,24 @@ export default function WarehouseDetailPage() {
|
|||||||
}, [ledger])
|
}, [ledger])
|
||||||
|
|
||||||
const movementChartRows = movementByItem.slice(0, CHART_ROW_LIMIT)
|
const movementChartRows = movementByItem.slice(0, CHART_ROW_LIMIT)
|
||||||
const movementChartLabels = movementChartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`)
|
const movementChartPad = padSlots(movementChartRows.length)
|
||||||
|
const movementChartLabels = [
|
||||||
|
...movementChartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`),
|
||||||
|
...Array<string>(movementChartPad).fill(""),
|
||||||
|
]
|
||||||
|
|
||||||
const pagedBins = bins?.slice((binsPage - 1) * binsPageSize, binsPage * binsPageSize) ?? []
|
const pagedBins = bins?.slice((binsPage - 1) * binsPageSize, binsPage * binsPageSize) ?? []
|
||||||
const pagedStockRows = stockRows.slice((stockPage - 1) * stockPageSize, stockPage * stockPageSize)
|
const pagedStockRows = stockRows.slice((stockPage - 1) * stockPageSize, stockPage * stockPageSize)
|
||||||
const pagedMovementRows = movementByItem.slice((movementPage - 1) * movementPageSize, movementPage * movementPageSize)
|
const pagedMovementRows = movementByItem.slice((movementPage - 1) * movementPageSize, movementPage * movementPageSize)
|
||||||
|
|
||||||
|
/** Closes the new-bin dialog and clears whatever was typed, so the next open starts blank. */
|
||||||
|
function closeCreate() {
|
||||||
|
setOpen(false)
|
||||||
|
setCode("")
|
||||||
|
setBinType("")
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
if (!code.trim()) nextErrors.code = "Bin code is required"
|
if (!code.trim()) nextErrors.code = "Bin code is required"
|
||||||
@@ -153,10 +178,7 @@ export default function WarehouseDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const bin = await warehousesApi.createBin(warehouseId, { code, binType: binType || null })
|
const bin = await warehousesApi.createBin(warehouseId, { code, binType: binType || null })
|
||||||
toast.success("Bin created", bin.code)
|
toast.success("Bin created", bin.code)
|
||||||
setOpen(false)
|
closeCreate()
|
||||||
setCode("")
|
|
||||||
setBinType("")
|
|
||||||
setErrors({})
|
|
||||||
loadBins()
|
loadBins()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const fe = fieldErrors(err)
|
const fe = fieldErrors(err)
|
||||||
@@ -198,7 +220,7 @@ export default function WarehouseDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={(v) => (v ? setOpen(true) : closeCreate())}>
|
||||||
<DialogTrigger
|
<DialogTrigger
|
||||||
render={
|
render={
|
||||||
<Button size="lg">
|
<Button size="lg">
|
||||||
@@ -214,8 +236,8 @@ export default function WarehouseDetailPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.code}>
|
<Field data-invalid={!!errors.code}>
|
||||||
<FieldLabel htmlFor="bin-code">Code</FieldLabel>
|
<FieldLabel htmlFor="bin-code" required>Code</FieldLabel>
|
||||||
<Input id="bin-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="A-01-01" aria-invalid={!!errors.code} />
|
<Input id="bin-code" value={code} onChange={(e) => { setCode(e.target.value); setErrors({}) }} placeholder="A-01-01" aria-invalid={!!errors.code} />
|
||||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
@@ -224,7 +246,7 @@ export default function WarehouseDetailPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={closeCreate} disabled={submitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||||
@@ -332,9 +354,18 @@ export default function WarehouseDetailPage() {
|
|||||||
<BarChart
|
<BarChart
|
||||||
labels={chartLabels}
|
labels={chartLabels}
|
||||||
datasets={[
|
datasets={[
|
||||||
{ label: "On hand", data: chartRows.map((r) => r.onHand), backgroundColor: "rgba(239,68,68,0.6)" },
|
{
|
||||||
{ label: "Available", data: chartRows.map((r) => r.available), backgroundColor: "rgba(34,197,94,0.6)" },
|
label: "On hand",
|
||||||
|
data: [...chartRows.map((r) => r.onHand), ...Array<null>(chartPad).fill(null)],
|
||||||
|
backgroundColor: "rgba(239,68,68,0.6)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Available",
|
||||||
|
data: [...chartRows.map((r) => r.available), ...Array<null>(chartPad).fill(null)],
|
||||||
|
backgroundColor: "rgba(34,197,94,0.6)",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
|
barPercentage={0.6}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -451,9 +482,18 @@ export default function WarehouseDetailPage() {
|
|||||||
<BarChart
|
<BarChart
|
||||||
labels={movementChartLabels}
|
labels={movementChartLabels}
|
||||||
datasets={[
|
datasets={[
|
||||||
{ label: "In", data: movementChartRows.map((r) => r.in), backgroundColor: "rgba(34,197,94,0.6)" },
|
{
|
||||||
{ label: "Out", data: movementChartRows.map((r) => r.out), backgroundColor: "rgba(239,68,68,0.6)" },
|
label: "In",
|
||||||
|
data: [...movementChartRows.map((r) => r.in), ...Array<null>(movementChartPad).fill(null)],
|
||||||
|
backgroundColor: "rgba(34,197,94,0.6)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Out",
|
||||||
|
data: [...movementChartRows.map((r) => r.out), ...Array<null>(movementChartPad).fill(null)],
|
||||||
|
backgroundColor: "rgba(239,68,68,0.6)",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
|
barPercentage={0.6}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -126,10 +126,30 @@ export default function WarehousesPage() {
|
|||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [search])
|
}, [search])
|
||||||
|
|
||||||
|
/** State updater that drops one field's error once the user starts editing that field. */
|
||||||
|
function dropError(key: string) {
|
||||||
|
return (prev: Record<string, string>) => {
|
||||||
|
if (!prev[key]) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[key]
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function binCount(warehouseId: number) {
|
function binCount(warehouseId: number) {
|
||||||
return (bins ?? []).filter((b) => b.warehouseId === warehouseId).length
|
return (bins ?? []).filter((b) => b.warehouseId === warehouseId).length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Closes the create dialog and clears whatever was typed, so the next open starts blank. */
|
||||||
|
function closeCreate() {
|
||||||
|
setOpen(false)
|
||||||
|
setName("")
|
||||||
|
setAddress("")
|
||||||
|
setPhone("")
|
||||||
|
setBranchId(0)
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
if (!name.trim()) nextErrors.name = "Warehouse name is required"
|
if (!name.trim()) nextErrors.name = "Warehouse name is required"
|
||||||
@@ -141,12 +161,7 @@ export default function WarehousesPage() {
|
|||||||
try {
|
try {
|
||||||
const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone || null, branchId })
|
const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone || null, branchId })
|
||||||
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
|
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
|
||||||
setOpen(false)
|
closeCreate()
|
||||||
setName("")
|
|
||||||
setAddress("")
|
|
||||||
setPhone("")
|
|
||||||
setBranchId(0)
|
|
||||||
setErrors({})
|
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A 409 here means another creation raced ours for the same generated code — the
|
// A 409 here means another creation raced ours for the same generated code — the
|
||||||
@@ -219,7 +234,7 @@ export default function WarehousesPage() {
|
|||||||
<p className="text-base text-muted-foreground">Multi-warehouse master data with per-warehouse bin/location structure (FR-WH-01, FR-MD-07).</p>
|
<p className="text-base text-muted-foreground">Multi-warehouse master data with per-warehouse bin/location structure (FR-WH-01, FR-MD-07).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={(v) => (v ? setOpen(true) : closeCreate())}>
|
||||||
<DialogTrigger
|
<DialogTrigger
|
||||||
render={
|
render={
|
||||||
<Button size="lg">
|
<Button size="lg">
|
||||||
@@ -235,8 +250,8 @@ export default function WarehousesPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.name}>
|
<Field data-invalid={!!errors.name}>
|
||||||
<FieldLabel htmlFor="wh-name">Name</FieldLabel>
|
<FieldLabel htmlFor="wh-name" required>Name</FieldLabel>
|
||||||
<Input id="wh-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} />
|
<Input id="wh-name" value={name} onChange={(e) => { setName(e.target.value); setErrors(dropError("name")) }} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} />
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
@@ -253,8 +268,8 @@ export default function WarehousesPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
{warehouseBranchingEnabled && (
|
{warehouseBranchingEnabled && (
|
||||||
<Field data-invalid={!!errors.branchId}>
|
<Field data-invalid={!!errors.branchId}>
|
||||||
<FieldLabel htmlFor="wh-branch">Branch</FieldLabel>
|
<FieldLabel htmlFor="wh-branch" required>Branch</FieldLabel>
|
||||||
<Select value={String(branchId)} onValueChange={(v) => setBranchId(v ? Number(v) : 0)}>
|
<Select value={String(branchId)} onValueChange={(v) => { setBranchId(v ? Number(v) : 0); setErrors(dropError("branchId")) }}>
|
||||||
<SelectTrigger id="wh-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
|
<SelectTrigger id="wh-branch" aria-invalid={!!errors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -263,7 +278,7 @@ export default function WarehousesPage() {
|
|||||||
)}
|
)}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={closeCreate} disabled={submitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||||
@@ -528,13 +543,13 @@ export default function WarehousesPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!editErrors.name}>
|
<Field data-invalid={!!editErrors.name}>
|
||||||
<FieldLabel htmlFor="wh-edit-name">Name</FieldLabel>
|
<FieldLabel htmlFor="wh-edit-name" required>Name</FieldLabel>
|
||||||
<Input id="wh-edit-name" value={editName} onChange={(e) => setEditName(e.target.value)} aria-invalid={!!editErrors.name} />
|
<Input id="wh-edit-name" value={editName} onChange={(e) => { setEditName(e.target.value); setEditErrors(dropError("name")) }} aria-invalid={!!editErrors.name} />
|
||||||
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field data-invalid={!!editErrors.code}>
|
<Field data-invalid={!!editErrors.code}>
|
||||||
<FieldLabel htmlFor="wh-edit-code">Code</FieldLabel>
|
<FieldLabel htmlFor="wh-edit-code" required>Code</FieldLabel>
|
||||||
<Input id="wh-edit-code" value={editCode} onChange={(e) => setEditCode(e.target.value)} aria-invalid={!!editErrors.code} />
|
<Input id="wh-edit-code" value={editCode} onChange={(e) => { setEditCode(e.target.value); setEditErrors(dropError("code")) }} aria-invalid={!!editErrors.code} />
|
||||||
<FieldError errors={[editErrors.code ? { message: editErrors.code } : undefined]} />
|
<FieldError errors={[editErrors.code ? { message: editErrors.code } : undefined]} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
@@ -547,8 +562,8 @@ export default function WarehousesPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
{warehouseBranchingEnabled && (
|
{warehouseBranchingEnabled && (
|
||||||
<Field data-invalid={!!editErrors.branchId}>
|
<Field data-invalid={!!editErrors.branchId}>
|
||||||
<FieldLabel htmlFor="wh-edit-branch">Branch</FieldLabel>
|
<FieldLabel htmlFor="wh-edit-branch" required>Branch</FieldLabel>
|
||||||
<Select value={String(editBranchId)} onValueChange={(v) => setEditBranchId(v ? Number(v) : 0)}>
|
<Select value={String(editBranchId)} onValueChange={(v) => { setEditBranchId(v ? Number(v) : 0); setEditErrors(dropError("branchId")) }}>
|
||||||
<SelectTrigger id="wh-edit-branch" aria-invalid={!!editErrors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
|
<SelectTrigger id="wh-edit-branch" aria-invalid={!!editErrors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||||
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 6.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -16,8 +16,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Hexa ERP",
|
||||||
description: "Generated by create next app",
|
description: "Hexa ERP — inventory, procurement, sales and accounting.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { customersApi } from "@/lib/api/customers"
|
||||||
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
|
import { Customer, CustomerType } from "@/types/customers"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit popup for one customer, opened from the customers list and laid out like its
|
||||||
|
* "New customer" dialog. Mount it with `key={customerId}` so each open starts clean. The
|
||||||
|
* list rows carry no ETag, so the customer is re-read here for a fresh If-Match first.
|
||||||
|
*/
|
||||||
|
export function EditCustomerDialog({
|
||||||
|
customerId,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
customerId: number
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: (customer: Customer) => void
|
||||||
|
}) {
|
||||||
|
const [customer, setCustomer] = useState<Customer | null>(null)
|
||||||
|
const [etag, setEtag] = useState<string | null>(null)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [customerCode, setCustomerCode] = useState("")
|
||||||
|
const [customerType, setCustomerType] = useState<CustomerType>("B2C")
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [phone, setPhone] = useState("")
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [addressLine1, setAddressLine1] = useState("")
|
||||||
|
const [addressLine2, setAddressLine2] = useState("")
|
||||||
|
const [city, setCity] = useState("")
|
||||||
|
const [country, setCountry] = useState("")
|
||||||
|
const [taxRegistrationNo, setTaxRegistrationNo] = useState("")
|
||||||
|
const [creditLimit, setCreditLimit] = useState("")
|
||||||
|
const [creditDays, setCreditDays] = useState("")
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
function applyCustomer(data: Customer, tag: string | null) {
|
||||||
|
setCustomer(data)
|
||||||
|
setEtag(tag)
|
||||||
|
setCustomerCode(data.customerCode)
|
||||||
|
setCustomerType(data.customerType)
|
||||||
|
setName(data.name)
|
||||||
|
setPhone(data.phone ?? "")
|
||||||
|
setEmail(data.email ?? "")
|
||||||
|
setAddressLine1(data.addressLine1 ?? "")
|
||||||
|
setAddressLine2(data.addressLine2 ?? "")
|
||||||
|
setCity(data.city ?? "")
|
||||||
|
setCountry(data.country ?? "")
|
||||||
|
setTaxRegistrationNo(data.taxRegistrationNo ?? "")
|
||||||
|
setCreditLimit(String(data.creditLimit))
|
||||||
|
setCreditDays(String(data.creditDays))
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
return customersApi.get(customerId).then(({ data, etag: tag }) => applyCustomer(data, tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload().catch((err) => setLoadError(errorMessage(err)))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [customerId])
|
||||||
|
|
||||||
|
/** Drops a field's error once the user starts editing it. */
|
||||||
|
function clearError(key: string) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!prev[key]) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[key]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!customerCode.trim()) nextErrors.customerCode = "Customer code is required"
|
||||||
|
if (!name.trim()) nextErrors.name = "Customer name is required"
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
toast.error("Could not save customer", Object.values(nextErrors)[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!customer || !etag) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const result = await customersApi.update(
|
||||||
|
customerId,
|
||||||
|
{
|
||||||
|
customerCode,
|
||||||
|
customerType,
|
||||||
|
name,
|
||||||
|
// Not editable in this popup — send the stored value back so the PUT doesn't clear it.
|
||||||
|
displayName: customer.displayName,
|
||||||
|
phone: phone || null,
|
||||||
|
email: email || null,
|
||||||
|
addressLine1: addressLine1 || null,
|
||||||
|
addressLine2: addressLine2 || null,
|
||||||
|
city: city || null,
|
||||||
|
country: country || null,
|
||||||
|
taxRegistrationNo: taxRegistrationNo || null,
|
||||||
|
creditLimit: Number(creditLimit) || 0,
|
||||||
|
creditDays: Number(creditDays) || 0,
|
||||||
|
defaultWarehouseId: customer.defaultWarehouseId,
|
||||||
|
branchId: customer.branchId,
|
||||||
|
},
|
||||||
|
etag
|
||||||
|
)
|
||||||
|
toast.success("Customer saved", `${result.data.customerCode} — ${result.data.name}`)
|
||||||
|
onSaved(result.data)
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: string })?.code === "CONCURRENCY_CONFLICT") {
|
||||||
|
// Someone else saved first: show their version so the user can redo the edit on top of it.
|
||||||
|
toast.error("Customer was changed by someone else", "The latest details have been loaded — make your edit again.")
|
||||||
|
await reload().catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fe = fieldErrors(err)
|
||||||
|
if (fe?.customerCode) setErrors({ customerCode: fe.customerCode })
|
||||||
|
toast.error("Could not save customer", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>Edit customer</DialogTitle>
|
||||||
|
<DialogDescription>Update this customer's details.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loadError ? (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{loadError}</div>
|
||||||
|
) : !customer ? (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={!!errors.name}>
|
||||||
|
<FieldLabel htmlFor="ec-name" required>Name</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="ec-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value)
|
||||||
|
clearError("name")
|
||||||
|
}}
|
||||||
|
placeholder="Nimal Perera"
|
||||||
|
aria-invalid={!!errors.name}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={!!errors.customerCode}>
|
||||||
|
<FieldLabel htmlFor="ec-code" required>Code</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="ec-code"
|
||||||
|
value={customerCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomerCode(e.target.value)
|
||||||
|
clearError("customerCode")
|
||||||
|
}}
|
||||||
|
aria-invalid={!!errors.customerCode}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.customerCode ? { message: errors.customerCode } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ec-type">Customer type</FieldLabel>
|
||||||
|
<Select<CustomerType> value={customerType} onValueChange={(v) => v && setCustomerType(v)}>
|
||||||
|
<SelectTrigger id="ec-type" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="B2C">B2C — individual</SelectItem>
|
||||||
|
<SelectItem value="B2B">B2B — business</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ec-phone">Phone (optional)</FieldLabel>
|
||||||
|
<Input id="ec-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ec-email">Email (optional)</FieldLabel>
|
||||||
|
<Input id="ec-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ec-addr1">Address line 1 (optional)</FieldLabel>
|
||||||
|
<Input id="ec-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ec-addr2">Address line 2 (optional)</FieldLabel>
|
||||||
|
<Input id="ec-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ec-city">City (optional)</FieldLabel>
|
||||||
|
<Input id="ec-city" value={city} onChange={(e) => setCity(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ec-country">Country (optional)</FieldLabel>
|
||||||
|
<Input id="ec-country" value={country} onChange={(e) => setCountry(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ec-tax">Tax registration (optional)</FieldLabel>
|
||||||
|
<Input id="ec-tax" value={taxRegistrationNo} onChange={(e) => setTaxRegistrationNo(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ec-credit-limit">Credit limit (LKR)</FieldLabel>
|
||||||
|
<Input id="ec-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ec-credit-days">Credit days</FieldLabel>
|
||||||
|
<Input id="ec-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={onClose} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSave} disabled={saving || !customer}>
|
||||||
|
{saving ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ReactNode, useRef, useState, useSyncExternalStore } from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
import { Download, Loader2, Printer, Share2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Shared plumbing for the printable invoice previews (sales and purchase): paper sizes, the
|
||||||
|
* sheet's building blocks, PDF export, and the preview frame with its Paper size / Share /
|
||||||
|
* Download / Print bar. Each invoice type only supplies its own A4 and receipt layouts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Deep-red accent from the reference layout — used for the heading underlines and header rule.
|
||||||
|
export const ACCENT = "#8b1c24"
|
||||||
|
|
||||||
|
export type PaperSize = "A4" | "80mm" | "58mm"
|
||||||
|
export type ReceiptWidth = Exclude<PaperSize, "A4">
|
||||||
|
|
||||||
|
const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [
|
||||||
|
{ value: "A4", label: "A4", hint: "210 × 297 mm" },
|
||||||
|
{ value: "80mm", label: "80mm receipt", hint: "Thermal roll" },
|
||||||
|
{ value: "58mm", label: "58mm receipt", hint: "Small thermal roll" },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */
|
||||||
|
const PAGE_RULES: Record<PaperSize, string> = {
|
||||||
|
A4: "@page { size: A4; margin: 12mm; }",
|
||||||
|
"80mm": "@page { size: 80mm auto; margin: 3mm; }",
|
||||||
|
"58mm": "@page { size: 58mm auto; margin: 2mm; }",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Receipt sheet sizing per roll — 58mm gets tighter padding and a smaller font to fit ~32 chars a line. */
|
||||||
|
export const RECEIPT_SHEET_CLASS: Record<ReceiptWidth, string> = {
|
||||||
|
"80mm": "w-[80mm] px-[4mm] py-[5mm] text-[11px]",
|
||||||
|
"58mm": "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Screen chrome for the on-screen A4 page; in print the @page margin does the spacing. */
|
||||||
|
export const A4_SHEET_CLASS =
|
||||||
|
"invoice-sheet mx-auto min-h-[297mm] w-[210mm] shrink-0 bg-white p-[12mm] text-sm text-neutral-800 shadow-md ring-1 ring-foreground/10 print:min-h-0 print:w-auto print:shadow-none print:ring-0"
|
||||||
|
|
||||||
|
/** Screen chrome for a receipt roll; combine with RECEIPT_SHEET_CLASS[width]. */
|
||||||
|
export const RECEIPT_SHEET_BASE_CLASS =
|
||||||
|
"invoice-sheet mx-auto shrink-0 bg-white leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0"
|
||||||
|
|
||||||
|
export const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const
|
||||||
|
|
||||||
|
export function money(n: number) {
|
||||||
|
return `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function qty(n: number) {
|
||||||
|
return n.toLocaleString(undefined, { maximumFractionDigits: 3 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dd/MM/yyyy, as on the reference invoice. */
|
||||||
|
export function formatDate(value: string | Date) {
|
||||||
|
const d = new Date(value)
|
||||||
|
return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section heading with the short accent underline. */
|
||||||
|
export function SectionTitle({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<h2 className="text-base font-bold text-neutral-900">{children}</h2>
|
||||||
|
<div className="mt-1 h-0.5 w-16" style={{ backgroundColor: ACCENT }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashed divider used between receipt sections. */
|
||||||
|
export function ReceiptRule() {
|
||||||
|
return <div className="my-2 border-t border-dashed border-neutral-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReceiptRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex justify-between gap-2", bold && "font-bold text-black")}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="tabular-nums">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Terms + footer note lines from the invoice template, blank lines dropped. */
|
||||||
|
export function templateNotes(termsAndConditions?: string | null, footerNote?: string | null): string[] {
|
||||||
|
return [...(termsAndConditions ?? "").split("\n"), ...(footerNote ?? "").split("\n")].map((l) => l.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribeNoop = () => () => {}
|
||||||
|
|
||||||
|
/** True only on the client — the print copy is portalled into document.body. */
|
||||||
|
function useIsClient() {
|
||||||
|
return useSyncExternalStore(subscribeNoop, () => true, () => false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paper width in mm — the PDF page is this wide; A4 is also 297mm tall (longer invoices add pages). */
|
||||||
|
const PAPER_WIDTH_MM: Record<PaperSize, number> = { A4: 210, "80mm": 80, "58mm": 58 }
|
||||||
|
const A4_HEIGHT_MM = 297
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the on-screen sheet to a PDF that looks exactly like the preview: the sheet is captured
|
||||||
|
* as an image (html-to-image) and laid onto pages of the chosen paper size (jsPDF). A4 is split
|
||||||
|
* across as many 297mm pages as needed; a receipt is one page as long as the receipt itself.
|
||||||
|
* Both libraries are loaded on demand.
|
||||||
|
*/
|
||||||
|
async function buildInvoicePdf(sheet: HTMLElement, size: PaperSize): Promise<Blob> {
|
||||||
|
const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")])
|
||||||
|
// The sheet's drop shadow / ring are screen decoration only.
|
||||||
|
const dataUrl = await toPng(sheet, {
|
||||||
|
pixelRatio: 2,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
style: { boxShadow: "none", margin: "0" },
|
||||||
|
// A logo the browser can't read back (cross-origin, no CORS) becomes a blank instead of failing the whole PDF.
|
||||||
|
imagePlaceholder: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
|
||||||
|
})
|
||||||
|
const img = new Image()
|
||||||
|
img.src = dataUrl
|
||||||
|
await img.decode()
|
||||||
|
|
||||||
|
const widthMm = PAPER_WIDTH_MM[size]
|
||||||
|
const imgHeightMm = (img.height * widthMm) / img.width
|
||||||
|
if (size !== "A4") {
|
||||||
|
const pdf = new jsPDF({ unit: "mm", format: [widthMm, imgHeightMm] })
|
||||||
|
pdf.addImage(dataUrl, "PNG", 0, 0, widthMm, imgHeightMm)
|
||||||
|
return pdf.output("blob")
|
||||||
|
}
|
||||||
|
const pdf = new jsPDF({ unit: "mm", format: "a4" })
|
||||||
|
// Same image on every page, shifted up one page-height each time; the page box crops the rest.
|
||||||
|
for (let offset = 0, page = 0; offset < imgHeightMm - 0.5; offset += A4_HEIGHT_MM, page++) {
|
||||||
|
if (page > 0) pdf.addPage()
|
||||||
|
pdf.addImage(dataUrl, "PNG", 0, -offset, widthMm, imgHeightMm)
|
||||||
|
}
|
||||||
|
return pdf.output("blob")
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice preview + paper-size bar underneath + Share / Download / Print. Printing uses a copy of
|
||||||
|
* the sheet portalled straight into <body> (`.invoice-print-root`, see globals.css) so the printout
|
||||||
|
* is just the invoice, whether this is rendered in a page or inside a dialog.
|
||||||
|
*/
|
||||||
|
export function InvoicePreviewFrame({
|
||||||
|
renderSheet,
|
||||||
|
error,
|
||||||
|
docNo,
|
||||||
|
shareTitle,
|
||||||
|
sharePath,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
/** The invoice laid out for a paper size, or null while it is still loading. */
|
||||||
|
renderSheet: ((size: PaperSize) => ReactNode) | null
|
||||||
|
error: string | null
|
||||||
|
/** Used for the PDF file name. */
|
||||||
|
docNo: string
|
||||||
|
shareTitle: string
|
||||||
|
/** App path of a page showing this invoice — shared/copied where the PDF itself can't be. */
|
||||||
|
sharePath: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
|
||||||
|
const isClient = useIsClient()
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [busy, setBusy] = useState<"download" | "share" | null>(null)
|
||||||
|
|
||||||
|
const fileName = `${docNo}${paperSize === "A4" ? "" : `-${paperSize}`}.pdf`
|
||||||
|
|
||||||
|
async function makePdf(): Promise<Blob> {
|
||||||
|
const sheet = previewRef.current?.querySelector<HTMLElement>(".invoice-sheet")
|
||||||
|
if (!sheet) throw new Error("The invoice is still loading.")
|
||||||
|
return buildInvoicePdf(sheet, paperSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
setBusy("download")
|
||||||
|
try {
|
||||||
|
saveBlob(await makePdf(), fileName)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not create the PDF", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shares the PDF itself where the device supports sharing files (phones, most desktop Chrome/Edge);
|
||||||
|
// otherwise shares — or, failing that, copies — a link to this invoice's page.
|
||||||
|
async function handleShare() {
|
||||||
|
const link = `${window.location.origin}${sharePath}`
|
||||||
|
setBusy("share")
|
||||||
|
try {
|
||||||
|
const file = new File([await makePdf()], fileName, { type: "application/pdf" })
|
||||||
|
if (navigator.canShare?.({ files: [file] })) {
|
||||||
|
await navigator.share({ files: [file], title: shareTitle, text: shareTitle })
|
||||||
|
} else if (navigator.share) {
|
||||||
|
await navigator.share({ title: shareTitle, text: shareTitle, url: link })
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(link)
|
||||||
|
toast.success("Link copied", "Sharing files isn't supported here — the invoice link is on your clipboard.")
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Closing the share sheet isn't an error.
|
||||||
|
if (!(err instanceof DOMException && err.name === "AbortError")) toast.error("Could not share the invoice", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !renderSheet) {
|
||||||
|
return <div className="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex min-h-0 flex-col", className)}>
|
||||||
|
{/* Preview window — scrolls so the page keeps its true size. */}
|
||||||
|
<div ref={previewRef} className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
|
||||||
|
{renderSheet ? renderSheet(paperSize) : <Skeleton className="mx-auto h-[297mm] w-[210mm] max-w-full" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paper size selection, under the invoice. */}
|
||||||
|
<div className="flex flex-col gap-3 border-t bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
|
<label htmlFor="invoice-paper-size" className="text-sm font-semibold text-foreground">Paper size</label>
|
||||||
|
<Select<PaperSize>
|
||||||
|
value={paperSize}
|
||||||
|
onValueChange={(v) => v && setPaperSize(v)}
|
||||||
|
items={PAPER_SIZES.map((s) => ({ value: s.value, label: s.label }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="invoice-paper-size" className="h-10! w-56 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PAPER_SIZES.map((s) => (
|
||||||
|
<SelectItem key={s.value} value={s.value}>
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{s.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{s.hint}</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="lg" variant="outline" onClick={handleShare} disabled={!renderSheet || busy !== null}>
|
||||||
|
{busy === "share" ? <Loader2 className="size-4 animate-spin" /> : <Share2 className="size-4" />}
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" variant="outline" onClick={handleDownload} disabled={!renderSheet || busy !== null}>
|
||||||
|
{busy === "download" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" onClick={() => window.print()} disabled={!renderSheet}>
|
||||||
|
<Printer className="size-4" />
|
||||||
|
Print {paperSize === "A4" ? "A4" : `${paperSize} receipt`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isClient &&
|
||||||
|
renderSheet &&
|
||||||
|
createPortal(
|
||||||
|
<div className="invoice-print-root">
|
||||||
|
<style>{PAGE_RULES[paperSize]}</style>
|
||||||
|
{renderSheet(paperSize)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
import { localToday, PaymentLine, PaymentLinesEditor, round2, validatePaymentLines } from "@/components/sales/PaymentLinesEditor"
|
||||||
|
|
||||||
interface PurchaseInvoicePaymentDialogProps {
|
interface PurchaseInvoicePaymentDialogProps {
|
||||||
// PurchaseInvoiceSummary rather than the full PurchaseInvoice — every field this dialog
|
// PurchaseInvoiceSummary rather than the full PurchaseInvoice — every field this dialog
|
||||||
@@ -24,23 +24,34 @@ interface PurchaseInvoicePaymentDialogProps {
|
|||||||
invoice: PurchaseInvoiceSummary | null
|
invoice: PurchaseInvoiceSummary | null
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
/** `newStatus` is always "Posted" — paying only ever succeeds against a posted invoice, whether
|
/** Called once per recorded payment line. `newStatus` is always "Posted" — paying only ever
|
||||||
* it started that way or this dialog auto-posted it first. */
|
* succeeds against a posted invoice, whether it started that way or this dialog auto-posted it. */
|
||||||
onPaid: (payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) => void
|
onPaid: (payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) => void
|
||||||
|
/** Called once, after the dialog closes, when this submit cleared the whole balance. */
|
||||||
|
onFullyPaid?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record a vendor payment against a Purchase Invoice's balance — full or partial, into an
|
/** Record a vendor payment against a Purchase Invoice's balance — full or partial, split across
|
||||||
* existing GL cash/bank account. A Draft invoice is posted first (the backend only accepts
|
* one or more payment lines (e.g. part cash, part bank), each from an existing GL cash/bank
|
||||||
* payments against a Posted invoice) so this doubles as "confirm and pay in one step" from a
|
* account. Same shape as SalesInvoicePaymentDialog: each line is posted as its own payment (the
|
||||||
* list row; an already-Posted invoice skips straight to payment. Modeled on
|
* backend already allows several installments). A Draft invoice is posted first (the backend only
|
||||||
* SalesInvoicePaymentDialog's "pick an account, submit" shape. */
|
* accepts payments against a Posted invoice) so this doubles as "confirm and pay in one step" from
|
||||||
export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: PurchaseInvoicePaymentDialogProps) {
|
* a list row. If a line fails part-way, the lines already recorded are removed from the form and
|
||||||
|
* the rest stay for a retry. */
|
||||||
|
export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid, onFullyPaid }: PurchaseInvoicePaymentDialogProps) {
|
||||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||||
const [glBankAccountId, setGlBankAccountId] = useState("")
|
const [lines, setLines] = useState<PaymentLine[]>([])
|
||||||
const [amount, setAmount] = useState("")
|
|
||||||
const [reference, setReference] = useState("")
|
const [reference, setReference] = useState("")
|
||||||
|
const [paymentDate, setPaymentDate] = useState(localToday)
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
// What this dialog has already done to the invoice in this session — the `invoice` prop isn't
|
||||||
|
// guaranteed to be refreshed by the parent between a partial failure and the retry.
|
||||||
|
const [postedHere, setPostedHere] = useState(false)
|
||||||
|
const [paidHere, setPaidHere] = useState(0)
|
||||||
|
// Balance as it stood when the dialog opened. The parent pages apply each `onPaid` to the
|
||||||
|
// invoice they pass in, so subtracting `paidHere` from the live prop would count it twice.
|
||||||
|
const [openingBalance, setOpeningBalance] = useState(0)
|
||||||
|
|
||||||
// Reset the form fields for a fresh open (or a different invoice) during render — same
|
// Reset the form fields for a fresh open (or a different invoice) during render — same
|
||||||
// "adjust state while rendering" pattern as SalesInvoicePaymentDialog, not inside an effect.
|
// "adjust state while rendering" pattern as SalesInvoicePaymentDialog, not inside an effect.
|
||||||
@@ -48,10 +59,13 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
|
|||||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||||
if (openKey !== null && resetFor !== openKey) {
|
if (openKey !== null && resetFor !== openKey) {
|
||||||
setResetFor(openKey)
|
setResetFor(openKey)
|
||||||
setAmount(invoice!.totals.balanceAmount.toFixed(2))
|
setLines([{ key: 0, glBankAccountId: "", amount: invoice!.totals.balanceAmount.toFixed(2) }])
|
||||||
setGlBankAccountId("")
|
|
||||||
setReference("")
|
setReference("")
|
||||||
|
setPaymentDate(localToday())
|
||||||
setErrors({})
|
setErrors({})
|
||||||
|
setPostedHere(false)
|
||||||
|
setPaidHere(0)
|
||||||
|
setOpeningBalance(invoice!.totals.balanceAmount)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -61,98 +75,120 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
|
|||||||
|
|
||||||
if (!invoice) return null
|
if (!invoice) return null
|
||||||
|
|
||||||
|
const isDraft = invoice.status === "Draft" && !postedHere
|
||||||
|
const balance = round2(openingBalance - paidHere)
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!invoice) return
|
if (!invoice) return
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors = validatePaymentLines(lines, balance)
|
||||||
const amountNum = Number(amount)
|
if (!paymentDate) nextErrors.paymentDate = "Select the payment date"
|
||||||
if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay from"
|
else if (paymentDate > localToday()) nextErrors.paymentDate = "Payment date cannot be in the future"
|
||||||
if (!amount || Number.isNaN(amountNum) || amountNum <= 0) nextErrors.amount = "Enter a valid amount"
|
|
||||||
else if (amountNum > invoice.totals.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(invoice.totals.balanceAmount)})`
|
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
if (isDraft) {
|
||||||
if (invoice.status === "Draft") {
|
try {
|
||||||
try {
|
await purchaseInvoicesApi.post(invoice.purchaseInvoiceId)
|
||||||
await purchaseInvoicesApi.post(invoice.purchaseInvoiceId)
|
setPostedHere(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not confirm invoice", errorMessage(err))
|
toast.error("Could not confirm invoice", errorMessage(err))
|
||||||
return
|
setSubmitting(false)
|
||||||
}
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payment = await purchaseInvoicesApi.pay(invoice.purchaseInvoiceId, {
|
// One payment per line, in order. On a failure, stop: drop the recorded lines from the form and
|
||||||
amount: amountNum,
|
// keep the rest so the user can fix and retry without double-paying.
|
||||||
glBankAccountId: Number(glBankAccountId),
|
const recorded: PurchaseInvoicePayment[] = []
|
||||||
reference: reference || undefined,
|
let failure: unknown = null
|
||||||
})
|
for (const line of lines) {
|
||||||
const fullyPaid = amountNum >= invoice.totals.balanceAmount
|
try {
|
||||||
|
const payment = await purchaseInvoicesApi.pay(invoice.purchaseInvoiceId, {
|
||||||
|
amount: Number(line.amount),
|
||||||
|
glBankAccountId: Number(line.glBankAccountId),
|
||||||
|
reference: reference || undefined,
|
||||||
|
paymentDate,
|
||||||
|
})
|
||||||
|
recorded.push(payment)
|
||||||
|
onPaid(payment, "Posted")
|
||||||
|
} catch (err) {
|
||||||
|
failure = err
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSubmitting(false)
|
||||||
|
|
||||||
|
const recordedTotal = round2(recorded.reduce((sum, p) => sum + p.amount, 0))
|
||||||
|
if (failure === null) {
|
||||||
|
const journals = recorded.map((p) => p.glJournalNo ?? "—").join(", ")
|
||||||
|
const fullyPaid = recordedTotal >= balance
|
||||||
toast.success(
|
toast.success(
|
||||||
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
|
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
|
||||||
`${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
|
`${formatAmount(recordedTotal)} posted to the ledger (${journals}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
|
||||||
)
|
)
|
||||||
onPaid(payment, "Posted")
|
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} catch (err) {
|
if (fullyPaid) onFullyPaid?.()
|
||||||
toast.error(
|
return
|
||||||
invoice.status === "Draft" ? "Invoice was confirmed, but the payment could not be recorded" : "Could not record payment",
|
|
||||||
errorMessage(err)
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPaidHere((p) => round2(p + recordedTotal))
|
||||||
|
setLines((prev) => prev.slice(recorded.length))
|
||||||
|
toast.error(
|
||||||
|
recorded.length > 0
|
||||||
|
? `${recorded.length} of ${lines.length} payment lines recorded (${formatAmount(recordedTotal)}); the rest could not be`
|
||||||
|
: invoice.status === "Draft"
|
||||||
|
? "Invoice was confirmed, but the payment could not be recorded"
|
||||||
|
: "Could not record payment",
|
||||||
|
errorMessage(failure)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
|
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{invoice.status === "Draft" && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
||||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(invoice.totals.balanceAmount)}</span>
|
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.glBankAccountId}>
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<FieldLabel htmlFor="pinv-pay-account">Pay from</FieldLabel>
|
<Field>
|
||||||
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
|
<FieldLabel htmlFor="pinv-pay-invoice-no">Invoice no.</FieldLabel>
|
||||||
<SelectTrigger id="pinv-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
|
<Input
|
||||||
<SelectValue placeholder={accounts === null ? "Loading…" : "Select a cash/bank account"} />
|
id="pinv-pay-invoice-no"
|
||||||
</SelectTrigger>
|
value={invoice.docNo}
|
||||||
<SelectContent>
|
readOnly
|
||||||
{(accounts ?? []).map((a) => (
|
tabIndex={-1}
|
||||||
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
|
className="cursor-not-allowed bg-muted text-muted-foreground"
|
||||||
{a.accountName} ({a.accountType})
|
/>
|
||||||
</SelectItem>
|
</Field>
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.glBankAccountId ? { message: errors.glBankAccountId } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={!!errors.amount}>
|
<Field data-invalid={!!errors.paymentDate}>
|
||||||
<FieldLabel htmlFor="pinv-pay-amount">Amount</FieldLabel>
|
<FieldLabel htmlFor="pinv-pay-date" required>Payment date</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="pinv-pay-amount"
|
id="pinv-pay-date"
|
||||||
type="number"
|
type="date"
|
||||||
step="0.01"
|
max={localToday()}
|
||||||
min="0.01"
|
value={paymentDate}
|
||||||
max={invoice.totals.balanceAmount}
|
onChange={(e) => setPaymentDate(e.target.value)}
|
||||||
value={amount}
|
aria-invalid={!!errors.paymentDate}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
/>
|
||||||
aria-invalid={!!errors.amount}
|
<FieldError errors={[errors.paymentDate ? { message: errors.paymentDate } : undefined]} />
|
||||||
/>
|
</Field>
|
||||||
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
|
</div>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="pinv-pay-reference">Reference (optional)</FieldLabel>
|
<FieldLabel htmlFor="pinv-pay-reference">Reference (optional)</FieldLabel>
|
||||||
<Input id="pinv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
<Input id="pinv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -160,11 +196,7 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={submit} disabled={submitting}>
|
<Button onClick={submit} disabled={submitting}>
|
||||||
{submitting
|
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
|
||||||
? "Recording…"
|
|
||||||
: invoice.status === "Draft"
|
|
||||||
? "Confirm & record payment"
|
|
||||||
: "Record payment"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ReactNode, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
||||||
|
import { vendorsApi } from "@/lib/api/vendors"
|
||||||
|
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||||
|
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
A4_SHEET_CLASS,
|
||||||
|
ACCENT,
|
||||||
|
formatDate,
|
||||||
|
InvoicePreviewFrame,
|
||||||
|
money,
|
||||||
|
PaperSize,
|
||||||
|
printColorExact,
|
||||||
|
RECEIPT_SHEET_BASE_CLASS,
|
||||||
|
RECEIPT_SHEET_CLASS,
|
||||||
|
ReceiptRow,
|
||||||
|
ReceiptRule,
|
||||||
|
ReceiptWidth,
|
||||||
|
SectionTitle,
|
||||||
|
templateNotes,
|
||||||
|
} from "@/components/invoice-template/InvoicePrintKit"
|
||||||
|
import { CompanyProfile } from "@/types/company-profile"
|
||||||
|
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||||
|
import { Vendor } from "@/types/master-data"
|
||||||
|
import { PurchaseInvoice, PurchaseInvoicePayment } from "@/types/purchase-invoices"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Printable purchase invoice (vendor bill) in the same three paper sizes as the sales invoice —
|
||||||
|
* A4, 80mm and 58mm receipt — sharing its preview frame, PDF export and print plumbing. The
|
||||||
|
* lines are the GRNs the bill covers; "From" is the vendor and "Bill To" is our company.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Everything both layouts render, resolved once. */
|
||||||
|
interface PurchaseInvoiceDoc {
|
||||||
|
invoice: PurchaseInvoice
|
||||||
|
vendor: Vendor | null
|
||||||
|
payments: PurchaseInvoicePayment[]
|
||||||
|
company: CompanyProfile | null
|
||||||
|
template: InvoiceTemplateConfig | null
|
||||||
|
notes: string[]
|
||||||
|
showLogo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function A4Sheet({ invoice, vendor, payments, company, template, notes, showLogo }: PurchaseInvoiceDoc) {
|
||||||
|
const t = invoice.totals
|
||||||
|
return (
|
||||||
|
<div className={A4_SHEET_CLASS} style={printColorExact}>
|
||||||
|
{/* Title + meta */}
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-extrabold tracking-wide text-neutral-900">PURCHASE INVOICE</h1>
|
||||||
|
<dl className="mt-5 grid grid-cols-[auto_1fr] gap-x-10 gap-y-1.5">
|
||||||
|
<dt className="text-xs font-bold text-neutral-900">Invoice No.</dt>
|
||||||
|
<dd>{invoice.docNo}</dd>
|
||||||
|
<dt className="text-xs font-bold text-neutral-900">Vendor Invoice No.</dt>
|
||||||
|
<dd>{invoice.vendorInvoiceNo}</dd>
|
||||||
|
<dt className="text-xs font-bold text-neutral-900">Invoice Date</dt>
|
||||||
|
<dd>{formatDate(invoice.vendorInvoiceDate)}</dd>
|
||||||
|
{invoice.status !== "Posted" && (
|
||||||
|
<>
|
||||||
|
<dt className="text-xs font-bold text-neutral-900">Status</dt>
|
||||||
|
<dd className="font-semibold" style={{ color: ACCENT }}>{invoice.status.toUpperCase()}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{showLogo && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||||
|
<img src={company!.logoUrl!} alt={company!.legalName} className="h-16 w-auto max-w-45 object-contain" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header rule: heavy dark bar over a thin accent line */}
|
||||||
|
<div className="mt-5 mb-7">
|
||||||
|
<div className="h-1 bg-neutral-900" />
|
||||||
|
<div className="mt-0.5 h-0.5" style={{ backgroundColor: ACCENT }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* From (vendor) / Bill To (us) */}
|
||||||
|
<div className="grid grid-cols-2 gap-10">
|
||||||
|
<div>
|
||||||
|
<SectionTitle>From</SectionTitle>
|
||||||
|
<div className="leading-snug">
|
||||||
|
<p className="font-bold text-neutral-900">{invoice.vendorSnapshotName}</p>
|
||||||
|
{vendor?.code && <p>Vendor code: {vendor.code}</p>}
|
||||||
|
<div className="mt-2">
|
||||||
|
{invoice.vendorSnapshotTaxNo && <p>Tax No: {invoice.vendorSnapshotTaxNo}</p>}
|
||||||
|
{vendor?.terms && <p>Terms: {vendor.terms}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionTitle>Bill To</SectionTitle>
|
||||||
|
{company ? (
|
||||||
|
<div className="leading-snug">
|
||||||
|
<p className="font-bold text-neutral-900">{company.tradeName || company.legalName}</p>
|
||||||
|
<p>{[company.addressLine1, company.addressLine2].filter(Boolean).join(", ")}</p>
|
||||||
|
<p>{[company.city, company.country].filter(Boolean).join(", ")}</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
{company.phone && <p>{company.phone}</p>}
|
||||||
|
{company.email && <p>{company.email}</p>}
|
||||||
|
{template?.showTaxRegNo && company.taxRegistrationNo && <p>Tax Reg No: {company.taxRegistrationNo}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-neutral-500 print:hidden">Company details aren't set up yet (Settings → Company Profile).</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GRN lines */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<SectionTitle>Goods Received</SectionTitle>
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-y-2 border-neutral-900 text-xs font-bold text-neutral-900">
|
||||||
|
<th className="px-2 py-2.5 text-left">GRN</th>
|
||||||
|
<th className="px-2 py-2.5 text-left">Received</th>
|
||||||
|
<th className="px-2 py-2.5 text-right">Subtotal</th>
|
||||||
|
<th className="px-2 py-2.5 text-right">VAT</th>
|
||||||
|
<th className="px-2 py-2.5 text-right">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoice.lines.map((line) => (
|
||||||
|
<tr key={line.purchaseInvoiceLineId} className="border-b border-neutral-300">
|
||||||
|
<td className="px-2 py-2.5">{line.grnDocNo}</td>
|
||||||
|
<td className="px-2 py-2.5">{formatDate(line.grnPostedAt)}</td>
|
||||||
|
<td className="px-2 py-2.5 text-right tabular-nums">{money(line.subtotal)}</td>
|
||||||
|
<td className="px-2 py-2.5 text-right tabular-nums">{line.vatTotal > 0 ? money(line.vatTotal) : "—"}</td>
|
||||||
|
<td className="px-2 py-2.5 text-right font-medium tabular-nums">{money(line.lineTotal)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes / totals */}
|
||||||
|
<div className="mt-6 grid grid-cols-[1fr_290px] items-start gap-8 print:break-inside-avoid">
|
||||||
|
<div>
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionTitle>Notes / Terms</SectionTitle>
|
||||||
|
<ul className="text-xs">
|
||||||
|
{notes.map((note, i) => (
|
||||||
|
<li key={i} className="border-b border-neutral-300 py-1.5">{note}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-neutral-900">
|
||||||
|
<div className="space-y-1.5 px-3.5 py-3 text-xs">
|
||||||
|
<div className="flex justify-between font-bold text-neutral-900"><span>Subtotal</span><span className="tabular-nums">{money(t.subtotal)}</span></div>
|
||||||
|
{t.vatTotal > 0 && <div className="flex justify-between"><span>VAT</span><span className="tabular-nums">{money(t.vatTotal)}</span></div>}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-t border-neutral-300 bg-neutral-100 px-3.5 py-3 text-sm font-bold text-neutral-900">
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="tabular-nums">{money(t.grandTotal)}</span>
|
||||||
|
</div>
|
||||||
|
{t.paidAmount > 0 && (
|
||||||
|
<div className="space-y-1.5 border-t border-neutral-300 px-3.5 py-3 text-xs">
|
||||||
|
<div className="flex justify-between"><span>Paid</span><span className="tabular-nums">{money(t.paidAmount)}</span></div>
|
||||||
|
<div className="flex justify-between font-bold text-neutral-900"><span>Balance Due</span><span className="tabular-nums">{money(t.balanceAmount)}</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payments */}
|
||||||
|
<div className="mt-8 print:break-inside-avoid">
|
||||||
|
<SectionTitle>Payments</SectionTitle>
|
||||||
|
{payments.length > 0 ? (
|
||||||
|
<ul className="text-xs">
|
||||||
|
{payments.map((p) => (
|
||||||
|
<li key={p.purchaseInvoicePaymentId} className="flex justify-between gap-4 border-b border-neutral-300 py-1.5">
|
||||||
|
<span>
|
||||||
|
{formatDate(p.paymentDate)} · {p.bankAccountName}
|
||||||
|
{p.reference && <span className="text-neutral-500"> · Ref {p.reference}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tabular-nums">{money(p.amount)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-neutral-500">No payment recorded yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 80mm / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
||||||
|
function ReceiptSheet({ invoice, payments, company, template, notes, showLogo, width }: PurchaseInvoiceDoc & { width: ReceiptWidth }) {
|
||||||
|
const t = invoice.totals
|
||||||
|
return (
|
||||||
|
<div className={cn(RECEIPT_SHEET_BASE_CLASS, RECEIPT_SHEET_CLASS[width])} style={printColorExact}>
|
||||||
|
<div className="text-center">
|
||||||
|
{showLogo && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||||
|
<img src={company!.logoUrl!} alt={company!.legalName} className="mx-auto mb-1.5 h-10 w-auto max-w-[50mm] object-contain grayscale" />
|
||||||
|
)}
|
||||||
|
{company && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-bold uppercase">{company.tradeName || company.legalName}</p>
|
||||||
|
<p>{[company.addressLine1, company.addressLine2, company.city].filter(Boolean).join(", ")}</p>
|
||||||
|
{company.phone && <p>Tel: {company.phone}</p>}
|
||||||
|
{template?.showTaxRegNo && company.taxRegistrationNo && <p>Tax Reg: {company.taxRegistrationNo}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-sm font-extrabold tracking-[0.15em]">PURCHASE INVOICE</p>
|
||||||
|
{invoice.status !== "Posted" && <p className="font-bold">*** {invoice.status.toUpperCase()} ***</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReceiptRule />
|
||||||
|
<ReceiptRow label="Invoice No." value={invoice.docNo} />
|
||||||
|
<ReceiptRow label="Vendor Inv." value={invoice.vendorInvoiceNo} />
|
||||||
|
<ReceiptRow label="Date" value={formatDate(invoice.vendorInvoiceDate)} />
|
||||||
|
<ReceiptRow label="Vendor" value={invoice.vendorSnapshotName} />
|
||||||
|
|
||||||
|
<ReceiptRule />
|
||||||
|
<div className="flex justify-between font-bold">
|
||||||
|
<span>GRN</span>
|
||||||
|
<span>Amount</span>
|
||||||
|
</div>
|
||||||
|
<ReceiptRule />
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{invoice.lines.map((line) => (
|
||||||
|
<div key={line.purchaseInvoiceLineId}>
|
||||||
|
<p className="font-medium">{line.grnDocNo}</p>
|
||||||
|
<div className="flex justify-between gap-2 tabular-nums">
|
||||||
|
<span>{formatDate(line.grnPostedAt)}</span>
|
||||||
|
<span>{money(line.lineTotal)}</span>
|
||||||
|
</div>
|
||||||
|
{line.vatTotal > 0 && <p className="text-right tabular-nums">incl. VAT {money(line.vatTotal)}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReceiptRule />
|
||||||
|
<ReceiptRow label="Subtotal" value={money(t.subtotal)} />
|
||||||
|
{t.vatTotal > 0 && <ReceiptRow label="VAT" value={money(t.vatTotal)} />}
|
||||||
|
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
|
||||||
|
<ReceiptRow label="TOTAL" value={money(t.grandTotal)} bold />
|
||||||
|
</div>
|
||||||
|
{t.paidAmount > 0 && (
|
||||||
|
<>
|
||||||
|
<ReceiptRow label="Paid" value={money(t.paidAmount)} />
|
||||||
|
<ReceiptRow label="Balance" value={money(t.balanceAmount)} bold />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{payments.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ReceiptRule />
|
||||||
|
<p className="font-bold">Payment</p>
|
||||||
|
{payments.map((p) => (
|
||||||
|
<ReceiptRow key={p.purchaseInvoicePaymentId} label={`${formatDate(p.paymentDate)} ${p.bankAccountName}`} value={money(p.amount)} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ReceiptRule />
|
||||||
|
{notes.map((note, i) => <p key={i} className="text-center">{note}</p>)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads the invoice plus the optional decoration around it (vendor, payments, company, template).
|
||||||
|
* Only the invoice is required — any other failure just leaves that block out. */
|
||||||
|
function usePurchaseInvoiceDoc(purchaseInvoiceId: number, refreshKey = 0) {
|
||||||
|
const [doc, setDoc] = useState<PurchaseInvoiceDoc | null>(null)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
const error = Number.isFinite(purchaseInvoiceId) ? loadError : `Invalid purchase invoice id '${purchaseInvoiceId}'.`
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(purchaseInvoiceId)) return
|
||||||
|
let cancelled = false
|
||||||
|
purchaseInvoicesApi
|
||||||
|
.get(purchaseInvoiceId)
|
||||||
|
.then(async ({ data: invoice }) => {
|
||||||
|
const [vendorRes, payments, companyRes, templateRes] = await Promise.all([
|
||||||
|
vendorsApi.get(invoice.vendorId).catch(() => null),
|
||||||
|
invoice.status === "Posted" ? purchaseInvoicesApi.listPayments(purchaseInvoiceId).catch(() => []) : Promise.resolve([]),
|
||||||
|
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||||
|
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||||
|
])
|
||||||
|
if (cancelled) return
|
||||||
|
const company = companyRes.data
|
||||||
|
const template = templateRes.data
|
||||||
|
setDoc({
|
||||||
|
invoice,
|
||||||
|
vendor: vendorRes?.data ?? null,
|
||||||
|
payments,
|
||||||
|
company,
|
||||||
|
template,
|
||||||
|
notes: templateNotes(template?.termsAndConditions, template?.footerNote),
|
||||||
|
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setLoadError(errorMessage(err))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
// refreshKey: bumped by the caller after a payment so the sheet shows the new state.
|
||||||
|
}, [purchaseInvoiceId, refreshKey])
|
||||||
|
|
||||||
|
return { doc, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sheet({ size, doc }: { size: PaperSize; doc: PurchaseInvoiceDoc }) {
|
||||||
|
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Purchase invoice preview + paper-size bar underneath + Share / Download / Print. */
|
||||||
|
export function PurchaseInvoicePrintPreview({
|
||||||
|
purchaseInvoiceId,
|
||||||
|
className,
|
||||||
|
refreshKey,
|
||||||
|
}: {
|
||||||
|
purchaseInvoiceId: number
|
||||||
|
className?: string
|
||||||
|
refreshKey?: number
|
||||||
|
}) {
|
||||||
|
const { doc, error } = usePurchaseInvoiceDoc(purchaseInvoiceId, refreshKey)
|
||||||
|
return (
|
||||||
|
<InvoicePreviewFrame
|
||||||
|
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
|
||||||
|
error={error}
|
||||||
|
docNo={doc?.invoice.docNo ?? `purchase-invoice-${purchaseInvoiceId}`}
|
||||||
|
shareTitle={`Purchase invoice ${doc?.invoice.docNo ?? ""}`.trim()}
|
||||||
|
sharePath={`/dashboard/procurement/purchase-invoices/${purchaseInvoiceId}`}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Popup form of the print preview — same frame as SalesInvoicePrintDialog. Opens on A4 every time. */
|
||||||
|
export function PurchaseInvoicePrintDialog({
|
||||||
|
purchaseInvoiceId,
|
||||||
|
invoiceNo,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
actions,
|
||||||
|
refreshKey,
|
||||||
|
}: {
|
||||||
|
purchaseInvoiceId: number | null
|
||||||
|
invoiceNo?: string
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
/** Optional buttons shown in the header (e.g. Record payment). */
|
||||||
|
actions?: ReactNode
|
||||||
|
/** Bump to reload the invoice (after a payment, …). */
|
||||||
|
refreshKey?: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
|
||||||
|
<div className="flex flex-col gap-3 border-b px-5 py-4 pr-12 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-lg font-semibold">Purchase invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
|
||||||
|
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
{purchaseInvoiceId !== null && (
|
||||||
|
<PurchaseInvoicePrintPreview purchaseInvoiceId={purchaseInvoiceId} refreshKey={refreshKey} className="flex-1" />
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -49,7 +49,10 @@ export function BrandPanel() {
|
|||||||
async function handleAdd() {
|
async function handleAdd() {
|
||||||
const nextErrors = validateBrandName(name)
|
const nextErrors = validateBrandName(name)
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
toast.error("Could not create brand", Object.values(nextErrors)[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
@@ -91,7 +94,10 @@ export function BrandPanel() {
|
|||||||
if (!editing) return
|
if (!editing) return
|
||||||
const nextErrors = validateBrandName(editName)
|
const nextErrors = validateBrandName(editName)
|
||||||
setEditErrors(nextErrors)
|
setEditErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
toast.error("Could not update brand", Object.values(nextErrors)[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setEditSubmitting(true)
|
setEditSubmitting(true)
|
||||||
try {
|
try {
|
||||||
@@ -116,7 +122,10 @@ export function BrandPanel() {
|
|||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setName(e.target.value)
|
||||||
|
setErrors({})
|
||||||
|
}}
|
||||||
placeholder="New brand name, e.g. Bosch"
|
placeholder="New brand name, e.g. Bosch"
|
||||||
className="h-12 flex-1 text-base"
|
className="h-12 flex-1 text-base"
|
||||||
aria-label="New brand name"
|
aria-label="New brand name"
|
||||||
@@ -178,11 +187,14 @@ export function BrandPanel() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!editErrors.name}>
|
<Field data-invalid={!!editErrors.name}>
|
||||||
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel>
|
<FieldLabel htmlFor="brand-edit-name" required>Name</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="brand-edit-name"
|
id="brand-edit-name"
|
||||||
value={editName}
|
value={editName}
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setEditName(e.target.value)
|
||||||
|
setEditErrors({})
|
||||||
|
}}
|
||||||
aria-invalid={!!editErrors.name}
|
aria-invalid={!!editErrors.name}
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
type SortOrder = "asc" | "desc"
|
type SortOrder = "asc" | "desc"
|
||||||
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
||||||
@@ -145,7 +146,10 @@ export function CategoryPanel() {
|
|||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const nextErrors = validateCategoryName(name)
|
const nextErrors = validateCategoryName(name)
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
toast.error(editing ? "Could not update category" : "Could not create category", Object.values(nextErrors)[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
@@ -219,8 +223,8 @@ export function CategoryPanel() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.name}>
|
<Field data-invalid={!!errors.name}>
|
||||||
<FieldLabel htmlFor="cat-name">Name</FieldLabel>
|
<FieldLabel htmlFor="cat-name" required>Name</FieldLabel>
|
||||||
<Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} />
|
<Input id="cat-name" value={name} onChange={(e) => { setName(e.target.value); setErrors({}) }} placeholder="Fasteners" aria-invalid={!!errors.name} />
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
@@ -385,39 +389,45 @@ export function CategoryPanel() {
|
|||||||
{/* Subcategories are their own resource now, not a nested tree.
|
{/* Subcategories are their own resource now, not a nested tree.
|
||||||
Hidden while the feature is switched off in Product Configuration. */}
|
Hidden while the feature is switched off in Product Configuration. */}
|
||||||
{config?.subcategoriesEnabled && (
|
{config?.subcategoriesEnabled && (
|
||||||
<Link
|
<ActionTooltip label="Manage subcategories">
|
||||||
href={`/dashboard/products/categories/${c.categoryId}`}
|
<Link
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
href={`/dashboard/products/categories/${c.categoryId}`}
|
||||||
aria-label={`Manage subcategories of ${c.name}`}
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
>
|
aria-label={`Manage subcategories of ${c.name}`}
|
||||||
<Network className="size-4" />
|
>
|
||||||
</Link>
|
<Network className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</ActionTooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<ActionTooltip label="Edit">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon-sm"
|
variant="ghost"
|
||||||
aria-label={`Edit ${c.name}`}
|
size="icon-sm"
|
||||||
onClick={() => openEditDialog(c)}
|
aria-label={`Edit ${c.name}`}
|
||||||
>
|
onClick={() => openEditDialog(c)}
|
||||||
<Pencil className="size-4" />
|
>
|
||||||
</Button>
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</ActionTooltip>
|
||||||
|
|
||||||
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
|
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<ActionTooltip label={c.status === "Active" ? "Deactivate" : "Activate"}>
|
||||||
render={
|
<AlertDialogTrigger
|
||||||
<Button
|
render={
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon-sm"
|
variant="ghost"
|
||||||
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
size="icon-sm"
|
||||||
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
|
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||||
disabled={deletingId === c.categoryId}
|
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
|
||||||
/>
|
disabled={deletingId === c.categoryId}
|
||||||
}
|
/>
|
||||||
>
|
}
|
||||||
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
>
|
||||||
</AlertDialogTrigger>
|
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
</ActionTooltip>
|
||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
variant={c.status === "Active" ? "destructive" : "success"}
|
variant={c.status === "Active" ? "destructive" : "success"}
|
||||||
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
|
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
|
||||||
@@ -453,6 +463,16 @@ export function CategoryPanel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hover label for an icon-only row action; `children` becomes the tooltip trigger. */
|
||||||
|
function ActionTooltip({ label, children }: { label: string; children: React.ReactElement }) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={children} />
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SortableHeader({
|
function SortableHeader({
|
||||||
label,
|
label,
|
||||||
active,
|
active,
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export function UomPanel() {
|
|||||||
async function handleAdd() {
|
async function handleAdd() {
|
||||||
const nextErrors = validateUomName(name)
|
const nextErrors = validateUomName(name)
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
toast.error("Could not create UOM", Object.values(nextErrors)[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
@@ -76,7 +79,10 @@ export function UomPanel() {
|
|||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setName(e.target.value)
|
||||||
|
setErrors({})
|
||||||
|
}}
|
||||||
placeholder="New UOM name, e.g. Box-12"
|
placeholder="New UOM name, e.g. Box-12"
|
||||||
className="h-12 flex-1 text-base"
|
className="h-12 flex-1 text-base"
|
||||||
aria-label="New UOM name"
|
aria-label="New UOM name"
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react"
|
import { ReactNode, useEffect, useState } from "react"
|
||||||
import { createPortal } from "react-dom"
|
|
||||||
import { Download, Loader2, Printer, Share2 } from "lucide-react"
|
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
@@ -10,55 +8,29 @@ import { companyProfileApi } from "@/lib/api/company-profile"
|
|||||||
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import {
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
A4_SHEET_CLASS,
|
||||||
import { toast } from "@/components/ui/toast"
|
ACCENT,
|
||||||
|
formatDate,
|
||||||
|
InvoicePreviewFrame,
|
||||||
|
money,
|
||||||
|
PaperSize,
|
||||||
|
printColorExact,
|
||||||
|
qty,
|
||||||
|
RECEIPT_SHEET_BASE_CLASS,
|
||||||
|
RECEIPT_SHEET_CLASS,
|
||||||
|
ReceiptRow,
|
||||||
|
ReceiptRule,
|
||||||
|
ReceiptWidth,
|
||||||
|
SectionTitle,
|
||||||
|
templateNotes,
|
||||||
|
} from "@/components/invoice-template/InvoicePrintKit"
|
||||||
import { CompanyProfile } from "@/types/company-profile"
|
import { CompanyProfile } from "@/types/company-profile"
|
||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||||
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
|
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
|
||||||
|
|
||||||
// Deep-red accent from the reference layout — used for the heading underlines and header rule.
|
|
||||||
const ACCENT = "#8b1c24"
|
|
||||||
|
|
||||||
type PaperSize = "A4" | "80mm" | "58mm"
|
|
||||||
type ReceiptWidth = Exclude<PaperSize, "A4">
|
|
||||||
|
|
||||||
const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [
|
|
||||||
{ value: "A4", label: "A4", hint: "210 × 297 mm" },
|
|
||||||
{ value: "80mm", label: "80mm receipt", hint: "Thermal roll" },
|
|
||||||
{ value: "58mm", label: "58mm receipt", hint: "Small thermal roll" },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */
|
|
||||||
const PAGE_RULES: Record<PaperSize, string> = {
|
|
||||||
A4: "@page { size: A4; margin: 12mm; }",
|
|
||||||
"80mm": "@page { size: 80mm auto; margin: 3mm; }",
|
|
||||||
"58mm": "@page { size: 58mm auto; margin: 2mm; }",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Receipt sheet sizing per roll — 58mm gets tighter padding and a smaller font to fit ~32 chars a line. */
|
|
||||||
const RECEIPT_SHEET_CLASS: Record<ReceiptWidth, string> = {
|
|
||||||
"80mm": "w-[80mm] px-[4mm] py-[5mm] text-[11px]",
|
|
||||||
"58mm": "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]",
|
|
||||||
}
|
|
||||||
|
|
||||||
function money(n: number) {
|
|
||||||
return `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function qty(n: number) {
|
|
||||||
return n.toLocaleString(undefined, { maximumFractionDigits: 3 })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** dd/MM/yyyy, as on the reference invoice. */
|
|
||||||
function formatDate(value: string | Date) {
|
|
||||||
const d = new Date(value)
|
|
||||||
return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Everything both layouts render, resolved once. */
|
/** Everything both layouts render, resolved once. */
|
||||||
interface InvoiceDoc {
|
interface InvoiceDoc {
|
||||||
invoice: SalesInvoice
|
invoice: SalesInvoice
|
||||||
@@ -72,26 +44,11 @@ interface InvoiceDoc {
|
|||||||
showBank: boolean
|
showBank: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const
|
|
||||||
|
|
||||||
/** Section heading with the short accent underline. */
|
|
||||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-3">
|
|
||||||
<h2 className="text-base font-bold text-neutral-900">{children}</h2>
|
|
||||||
<div className="mt-1 h-0.5 w-16" style={{ backgroundColor: ACCENT }} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function A4Sheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, showBank }: InvoiceDoc) {
|
function A4Sheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, showBank }: InvoiceDoc) {
|
||||||
const t = invoice.totals
|
const t = invoice.totals
|
||||||
return (
|
return (
|
||||||
// On screen: a true 210 × 297 mm page. In print the @page margin does the spacing.
|
// On screen: a true 210 × 297 mm page. In print the @page margin does the spacing.
|
||||||
<div
|
<div className={A4_SHEET_CLASS} style={printColorExact}>
|
||||||
className="invoice-sheet mx-auto min-h-[297mm] w-[210mm] shrink-0 bg-white p-[12mm] text-sm text-neutral-800 shadow-md ring-1 ring-foreground/10 print:min-h-0 print:w-auto print:shadow-none print:ring-0"
|
|
||||||
style={printColorExact}
|
|
||||||
>
|
|
||||||
{/* Title + meta */}
|
{/* Title + meta */}
|
||||||
<div className="flex items-start justify-between gap-6">
|
<div className="flex items-start justify-between gap-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -262,31 +219,11 @@ function A4Sheet({ invoice, customer, payments, company, template, dueDate, note
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dashed divider used between receipt sections. */
|
|
||||||
function ReceiptRule() {
|
|
||||||
return <div className="my-2 border-t border-dashed border-neutral-500" />
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReceiptRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className={cn("flex justify-between gap-2", bold && "font-bold text-black")}>
|
|
||||||
<span>{label}</span>
|
|
||||||
<span className="tabular-nums">{value}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 80mm / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
/** 80mm / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
||||||
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, width }: InvoiceDoc & { width: ReceiptWidth }) {
|
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, width }: InvoiceDoc & { width: ReceiptWidth }) {
|
||||||
const t = invoice.totals
|
const t = invoice.totals
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn(RECEIPT_SHEET_BASE_CLASS, RECEIPT_SHEET_CLASS[width])} style={printColorExact}>
|
||||||
className={cn(
|
|
||||||
"invoice-sheet mx-auto shrink-0 bg-white leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0",
|
|
||||||
RECEIPT_SHEET_CLASS[width]
|
|
||||||
)}
|
|
||||||
style={printColorExact}
|
|
||||||
>
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
{showLogo && (
|
{showLogo && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||||
@@ -405,9 +342,7 @@ function useInvoiceDoc(invoiceId: number, refreshKey = 0) {
|
|||||||
company,
|
company,
|
||||||
template,
|
template,
|
||||||
dueDate,
|
dueDate,
|
||||||
notes: [...(template?.termsAndConditions ?? "").split("\n"), ...(template?.footerNote ?? "").split("\n")]
|
notes: templateNotes(template?.termsAndConditions, template?.footerNote),
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
|
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
|
||||||
showBank: !!template?.showBankDetails && !!company && !!(company.bankName || company.accountNumber),
|
showBank: !!template?.showBankDetails && !!company && !!(company.bankName || company.accountNumber),
|
||||||
})
|
})
|
||||||
@@ -424,186 +359,22 @@ function useInvoiceDoc(invoiceId: number, refreshKey = 0) {
|
|||||||
return { doc, error }
|
return { doc, error }
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscribeNoop = () => () => {}
|
|
||||||
|
|
||||||
/** True only on the client — the print copy is portalled into document.body. */
|
|
||||||
function useIsClient() {
|
|
||||||
return useSyncExternalStore(subscribeNoop, () => true, () => false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Paper width in mm — the PDF page is this wide; A4 is also 297mm tall (longer invoices add pages). */
|
|
||||||
const PAPER_WIDTH_MM: Record<PaperSize, number> = { A4: 210, "80mm": 80, "58mm": 58 }
|
|
||||||
const A4_HEIGHT_MM = 297
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the on-screen sheet to a PDF that looks exactly like the preview: the sheet is captured
|
|
||||||
* as an image (html-to-image) and laid onto pages of the chosen paper size (jsPDF). A4 is split
|
|
||||||
* across as many 297mm pages as needed; a receipt is one page as long as the receipt itself.
|
|
||||||
* Both libraries are loaded on demand.
|
|
||||||
*/
|
|
||||||
async function buildInvoicePdf(sheet: HTMLElement, size: PaperSize): Promise<Blob> {
|
|
||||||
const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")])
|
|
||||||
// The sheet's drop shadow / ring are screen decoration only.
|
|
||||||
const dataUrl = await toPng(sheet, {
|
|
||||||
pixelRatio: 2,
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
style: { boxShadow: "none", margin: "0" },
|
|
||||||
// A logo the browser can't read back (cross-origin, no CORS) becomes a blank instead of failing the whole PDF.
|
|
||||||
imagePlaceholder: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
|
|
||||||
})
|
|
||||||
const img = new Image()
|
|
||||||
img.src = dataUrl
|
|
||||||
await img.decode()
|
|
||||||
|
|
||||||
const widthMm = PAPER_WIDTH_MM[size]
|
|
||||||
const imgHeightMm = (img.height * widthMm) / img.width
|
|
||||||
if (size !== "A4") {
|
|
||||||
const pdf = new jsPDF({ unit: "mm", format: [widthMm, imgHeightMm] })
|
|
||||||
pdf.addImage(dataUrl, "PNG", 0, 0, widthMm, imgHeightMm)
|
|
||||||
return pdf.output("blob")
|
|
||||||
}
|
|
||||||
const pdf = new jsPDF({ unit: "mm", format: "a4" })
|
|
||||||
// Same image on every page, shifted up one page-height each time; the page box crops the rest.
|
|
||||||
for (let offset = 0, page = 0; offset < imgHeightMm - 0.5; offset += A4_HEIGHT_MM, page++) {
|
|
||||||
if (page > 0) pdf.addPage()
|
|
||||||
pdf.addImage(dataUrl, "PNG", 0, -offset, widthMm, imgHeightMm)
|
|
||||||
}
|
|
||||||
return pdf.output("blob")
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveBlob(blob: Blob, filename: string) {
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement("a")
|
|
||||||
a.href = url
|
|
||||||
a.download = filename
|
|
||||||
a.click()
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
|
function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
|
||||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
|
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Invoice preview + paper-size bar underneath + Share / Download / Print (see InvoicePreviewFrame). */
|
||||||
* Invoice preview + paper-size bar underneath + Print. Printing uses a copy of the sheet
|
|
||||||
* portalled straight into <body> (`.invoice-print-root`, see globals.css) so the printout is
|
|
||||||
* just the invoice, whether this is rendered in a page or inside a dialog.
|
|
||||||
*/
|
|
||||||
export function SalesInvoicePrintPreview({ invoiceId, className, refreshKey }: { invoiceId: number; className?: string; refreshKey?: number }) {
|
export function SalesInvoicePrintPreview({ invoiceId, className, refreshKey }: { invoiceId: number; className?: string; refreshKey?: number }) {
|
||||||
const { doc, error } = useInvoiceDoc(invoiceId, refreshKey)
|
const { doc, error } = useInvoiceDoc(invoiceId, refreshKey)
|
||||||
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
|
|
||||||
const isClient = useIsClient()
|
|
||||||
const previewRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [busy, setBusy] = useState<"download" | "share" | null>(null)
|
|
||||||
|
|
||||||
const fileName = `${doc?.invoice.invoiceNo ?? `invoice-${invoiceId}`}${paperSize === "A4" ? "" : `-${paperSize}`}.pdf`
|
|
||||||
|
|
||||||
async function makePdf(): Promise<Blob> {
|
|
||||||
const sheet = previewRef.current?.querySelector<HTMLElement>(".invoice-sheet")
|
|
||||||
if (!sheet) throw new Error("The invoice is still loading.")
|
|
||||||
return buildInvoicePdf(sheet, paperSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDownload() {
|
|
||||||
setBusy("download")
|
|
||||||
try {
|
|
||||||
saveBlob(await makePdf(), fileName)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error("Could not create the PDF", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setBusy(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shares the PDF itself where the device supports sharing files (phones, most desktop Chrome/Edge);
|
|
||||||
// otherwise shares — or, failing that, copies — a link to this invoice's print page.
|
|
||||||
async function handleShare() {
|
|
||||||
const title = `Invoice ${doc?.invoice.invoiceNo ?? ""}`.trim()
|
|
||||||
const link = `${window.location.origin}/dashboard/sales/invoices/${invoiceId}/print`
|
|
||||||
setBusy("share")
|
|
||||||
try {
|
|
||||||
const file = new File([await makePdf()], fileName, { type: "application/pdf" })
|
|
||||||
if (navigator.canShare?.({ files: [file] })) {
|
|
||||||
await navigator.share({ files: [file], title, text: title })
|
|
||||||
} else if (navigator.share) {
|
|
||||||
await navigator.share({ title, text: title, url: link })
|
|
||||||
} else {
|
|
||||||
await navigator.clipboard.writeText(link)
|
|
||||||
toast.success("Link copied", "Sharing files isn't supported here — the invoice link is on your clipboard.")
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Closing the share sheet isn't an error.
|
|
||||||
if (!(err instanceof DOMException && err.name === "AbortError")) toast.error("Could not share the invoice", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setBusy(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && !doc) {
|
|
||||||
return <div className="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex min-h-0 flex-col", className)}>
|
<InvoicePreviewFrame
|
||||||
{/* Preview window — scrolls so the page keeps its true size. */}
|
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
|
||||||
<div ref={previewRef} className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
|
error={error}
|
||||||
{doc ? (
|
docNo={doc?.invoice.invoiceNo ?? `invoice-${invoiceId}`}
|
||||||
<Sheet size={paperSize} doc={doc} />
|
shareTitle={`Invoice ${doc?.invoice.invoiceNo ?? ""}`.trim()}
|
||||||
) : (
|
sharePath={`/dashboard/sales/invoices/${invoiceId}/print`}
|
||||||
<Skeleton className="mx-auto h-[297mm] w-[210mm] max-w-full" />
|
className={className}
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Paper size selection, under the invoice. */}
|
|
||||||
<div className="flex flex-col gap-3 border-t bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
||||||
<label htmlFor="invoice-paper-size" className="text-sm font-semibold text-foreground">Paper size</label>
|
|
||||||
<Select<PaperSize>
|
|
||||||
value={paperSize}
|
|
||||||
onValueChange={(v) => v && setPaperSize(v)}
|
|
||||||
items={PAPER_SIZES.map((s) => ({ value: s.value, label: s.label }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="invoice-paper-size" className="h-10! w-56 text-sm">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{PAPER_SIZES.map((s) => (
|
|
||||||
<SelectItem key={s.value} value={s.value}>
|
|
||||||
<span className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium">{s.label}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{s.hint}</span>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button size="lg" variant="outline" onClick={handleShare} disabled={!doc || busy !== null}>
|
|
||||||
{busy === "share" ? <Loader2 className="size-4 animate-spin" /> : <Share2 className="size-4" />}
|
|
||||||
Share
|
|
||||||
</Button>
|
|
||||||
<Button size="lg" variant="outline" onClick={handleDownload} disabled={!doc || busy !== null}>
|
|
||||||
{busy === "download" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
|
|
||||||
<Printer className="size-4" />
|
|
||||||
Print {paperSize === "A4" ? "A4" : `${paperSize} receipt`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isClient &&
|
|
||||||
doc &&
|
|
||||||
createPortal(
|
|
||||||
<div className="invoice-print-root">
|
|
||||||
<style>{PAGE_RULES[paperSize]}</style>
|
|
||||||
<Sheet size={paperSize} doc={doc} />
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ type Props = {
|
|||||||
labels: string[]
|
labels: string[]
|
||||||
datasets: Array<{
|
datasets: Array<{
|
||||||
label?: string
|
label?: string
|
||||||
data: number[]
|
/** `null` leaves an empty slot — used to pad the axis so a few bars sit at the start. */
|
||||||
|
data: (number | null)[]
|
||||||
backgroundColor?: string | string[]
|
backgroundColor?: string | string[]
|
||||||
}>
|
}>
|
||||||
/** Container height in px — defaults to the original 260 so existing callers are unaffected. */
|
/** Container height in px — defaults to the original 260 so existing callers are unaffected. */
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
/** `required` appends a red asterisk to mark a mandatory field. */
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
required,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"label"> & { required?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
data-slot="label"
|
data-slot="label"
|
||||||
@@ -13,7 +19,14 @@ function Label({ className, ...props }: React.ComponentProps<"label">) {
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
{required && (
|
||||||
|
<span aria-hidden="true" className="-ml-1 text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { vendorsApi } from "@/lib/api/vendors"
|
||||||
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
|
import { generateVendorCode } from "@/lib/vendor-code"
|
||||||
|
import { Vendor } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit popup for one vendor, opened from the vendors list. Mount it with `key={vendorId}`
|
||||||
|
* so each open starts clean. The list rows carry no ETag, so the vendor is re-read here
|
||||||
|
* for a fresh If-Match before anything can be saved.
|
||||||
|
*/
|
||||||
|
export function EditVendorDialog({
|
||||||
|
vendorId,
|
||||||
|
existingCodes,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
vendorId: number
|
||||||
|
/** Every loaded vendor code, to de-dupe the regenerated code against (this vendor's own is skipped). */
|
||||||
|
existingCodes: string[]
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: (vendor: Vendor) => void
|
||||||
|
}) {
|
||||||
|
const [vendor, setVendor] = useState<Vendor | null>(null)
|
||||||
|
const [etag, setEtag] = useState<string | null>(null)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [code, setCode] = useState("")
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [terms, setTerms] = useState("")
|
||||||
|
const [taxReg, setTaxReg] = useState("")
|
||||||
|
const [currency, setCurrency] = useState("")
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
function applyVendor(data: Vendor, tag: string | null) {
|
||||||
|
setVendor(data)
|
||||||
|
setEtag(tag)
|
||||||
|
setCode(data.code)
|
||||||
|
setName(data.name)
|
||||||
|
setTerms(data.terms ?? "")
|
||||||
|
setTaxReg(data.taxReg ?? "")
|
||||||
|
setCurrency(data.currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
return vendorsApi.get(vendorId).then(({ data, etag: tag }) => applyVendor(data, tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload().catch((err) => setLoadError(errorMessage(err)))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [vendorId])
|
||||||
|
|
||||||
|
/** Drops fields' errors once the user starts editing them. */
|
||||||
|
function clearErrors(...keys: string[]) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!keys.some((k) => prev[k])) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const k of keys) delete next[k]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNameChange(value: string) {
|
||||||
|
setName(value)
|
||||||
|
const otherCodes = existingCodes.filter((c) => c !== vendor?.code)
|
||||||
|
setCode(value.trim() ? generateVendorCode(value, otherCodes) : "")
|
||||||
|
// The code is regenerated from the name, so its error is stale too.
|
||||||
|
clearErrors("name", "code")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!code.trim()) nextErrors.code = "Vendor code is required"
|
||||||
|
if (!name.trim()) nextErrors.name = "Vendor name is required"
|
||||||
|
if (!currency.trim()) nextErrors.currency = "Currency is required"
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
toast.error("Could not save vendor", Object.values(nextErrors)[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!vendor || !etag) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const result = await vendorsApi.update(
|
||||||
|
vendorId,
|
||||||
|
{ code, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: vendor.branchId },
|
||||||
|
etag
|
||||||
|
)
|
||||||
|
toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`)
|
||||||
|
onSaved(result.data)
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: string })?.code === "CONCURRENCY_CONFLICT") {
|
||||||
|
// Someone else saved first: show their version so the user can redo the edit on top of it.
|
||||||
|
toast.error("Vendor was changed by someone else", "The latest details have been loaded — make your edit again.")
|
||||||
|
await reload().catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fe = fieldErrors(err)
|
||||||
|
if (fe?.code) setErrors({ code: fe.code })
|
||||||
|
toast.error("Could not save vendor", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Laid out to match the "New vendor" dialog (app/dashboard/vendors/page.tsx).
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>Edit vendor</DialogTitle>
|
||||||
|
<DialogDescription>Update this supplier record. Its code is regenerated from the name.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loadError ? (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{loadError}</div>
|
||||||
|
) : !vendor ? (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={!!errors.name}>
|
||||||
|
<FieldLabel htmlFor="ev-name" required>Name</FieldLabel>
|
||||||
|
<Input id="ev-name" value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
|
||||||
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.code}>
|
||||||
|
<FieldLabel htmlFor="ev-code">Code (auto-generated)</FieldLabel>
|
||||||
|
<Input id="ev-code" value={code} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||||
|
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ev-terms">Payment terms (optional)</FieldLabel>
|
||||||
|
<Input id="ev-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="ev-taxreg">Tax registration (optional)</FieldLabel>
|
||||||
|
<Input id="ev-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.currency}>
|
||||||
|
<FieldLabel htmlFor="ev-currency" required>Currency</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="ev-currency"
|
||||||
|
value={currency}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCurrency(e.target.value)
|
||||||
|
clearErrors("currency")
|
||||||
|
}}
|
||||||
|
placeholder="LKR"
|
||||||
|
maxLength={3}
|
||||||
|
aria-invalid={!!errors.currency}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={onClose} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSave} disabled={saving || !vendor}>
|
||||||
|
{saving ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// One typed client method per vendor (supplier) endpoint (docs/11-BACKEND-PHASE1.md
|
// One typed client method per vendor (supplier) endpoint (docs/11-BACKEND-PHASE1.md
|
||||||
// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate (masters are
|
// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate, DELETE for a
|
||||||
// deactivated, not hard-deleted, FR-MD-08).
|
// hard delete (409 MASTER_IN_USE while any purchase document or item references it).
|
||||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||||
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
||||||
import { CreateVendorRequest, UpdateVendorRequest, Vendor } from "@/types/master-data"
|
import { CreateVendorRequest, UpdateVendorRequest, Vendor } from "@/types/master-data"
|
||||||
@@ -33,4 +33,8 @@ export const vendorsApi = {
|
|||||||
updateStatus(vendorId: number, status: EntityStatus): Promise<void> {
|
updateStatus(vendorId: number, status: EntityStatus): Promise<void> {
|
||||||
return apiRequest<void>(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } })
|
return apiRequest<void>(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
delete(vendorId: number): Promise<void> {
|
||||||
|
return apiRequest<void>(`/vendors/${vendorId}`, { method: "DELETE" })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export interface CreatePurchaseInvoicePaymentRequest {
|
|||||||
amount: number
|
amount: number
|
||||||
glBankAccountId: number
|
glBankAccountId: number
|
||||||
reference?: string | null
|
reference?: string | null
|
||||||
|
/** `YYYY-MM-DD`; when the vendor was paid (also the GL entry date). Omitted ⇒ today; never future. */
|
||||||
|
paymentDate?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePurchaseInvoiceRequest {
|
export interface CreatePurchaseInvoiceRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user