Commit graph

18 commits

Author SHA1 Message Date
c22e9d1bd6 feat(delivery-notes): lock the cost price, markup stays the only input
A delivery note's cost price is what the business paid. Editing it
while setting a margin rewrites that silently, so a stray keystroke
changes every downstream number on the note without ever looking like
a pricing decision.

The rule lives in quote-pricing.ts as a `costLocked` flag captured at
load time, and repriceLine DROPS a cost patch on a locked line. That
is the guarantee; the read-only cell in the UI is only the
convenience, so no caller can quietly rewrite the cost.

- "Edit note" and "Create quote" grids: cost is read-only with a lock
  icon once the line arrived costed; a blank cost stays fillable and
  still seeds the 25% default markup on first entry.
- readOnly, not disabled, so the number can still be selected and
  checked against a supplier invoice.
- Locked at load, not per keystroke: a cost typed in this session
  stays correctable until save (an operator choice), and locks on the
  next open because the row then carries the cost.
- The DN detail page's edit mode is untouched — it stays the
  deliberate "that cost is simply wrong" correction path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-15 15:44:24 +02:00
841121097c fix(invoicing): handle PDF uploads and improve error messaging
This commit addresses issues with PDF handling in the invoicing process. The application now correctly uploads the PDF returned from QuickBooks to the appropriate order item, ensuring that the PDF is available for preview. If no PDF is provided, the system will return a clear message to the user instead of displaying a blank frame.

Additionally, the `readQbWebhookInvoice` function has been updated to include a `pdfBase64` field, allowing for better handling of PDF data. The `PdfPreviewDialog` component has been modified to manage the display of the PDF or an informative message when the PDF is unavailable.

Tests have been added to verify the new functionality and ensure that the application behaves as expected when handling PDF uploads and errors.
2026-10-09 13:27:17 +02:00
6cfb413508 fix(pricing): the 18% floor was applied on every keystroke
Operator report: "If I double click on it and I put in 20 there, goes
to 18... if I take everything out, I start typing, automatically change
it to 18. I need to be able to add any value there and when I click
save and the value is less than 18, it should go to 18."

The floor (`2387a71`) was correct in principle and wrong in WHERE it
sat. It was applied inside `repriceLine`, which runs on every
keystroke in both pricing grids — so the moment a sub-floor digit was
typed the state was raised to 18 and the box snapped.

Typing "20" is the clearest demonstration: the "2" is sub-floor, so it
became 18 mid-keystroke; the next character then APPENDED to "18" and
produced 180. What the operator saw as "goes to 18" was the first
keystroke, and "it jumps around" was the second.

She asked for the floor on SAVE, not while typing. So:

  - `repriceLine` no longer floors. Markup is also rounded to 2dp on
    the way in, so the box holds `80.06` rather than the float noise
    a back-solved markup produces (`80.05999755859375`).
  - The floor applies on BLUR and on SAVE, in both the delivery-note
    editor and the quote review modal, via `applyMarkupFloor` — the
    same function, still the only thing between a typed value and the
    database, so the -100-row hole stays closed.
  - `step="0.5"` replaced with `step="any"`. A back-solved 80.06 is
    not a multiple of 0.5, which left those boxes `:invalid` — and a
    number input in an invalid state fights the caret. This is very
    likely a second contributor to "I can't do anything there".
  - The bulk "set all markups" field gets the same treatment.

Verified with real keystrokes in the browser, not synthetic events
(which had masked this — they set a value in one shot, so the
mid-keystroke floor never fired):

  type "20"  -> stays 20, selling R605,40 (504,50 x 1,20)
  type "15"  -> stays 15 while typing
  blur       -> 18, selling R595,31 (504,50 x 1,18)

Three existing tests asserted the per-keystroke floor. They are
repointed, not deleted: the commit path is now pinned directly, and
`applyMarkupFloor` has its own tests so the database protection is
still asserted.

No data changed — the editor work above was dialog-local state only.

Full suite 3833 passing, tsc clean.
2026-10-09 12:49:13 +02:00
bc6c4fdb31 fix(delivery-note-editor): "Save & show document" was not saving at all
The operator's report: change the quantities, press Save & show, and
the document still carries the old numbers — it "just reverts back".

It wasn't reverting. **The button never saved anything.** Its handler
was:

    onOpenChange(false);
    onSaved?.(deliveryNoteNumber);

which is exactly what Cancel does — close the dialog, show the PDF. The
`save()` call lived only on "Save changes". So the operator changed the
quantities, pressed the button, got the OLD document, and drew the
reasonable conclusion that their edit hadn't taken.

Both buttons now go through `save()`, which takes a flag for whether
to show the document afterwards. The document opens only after the
save resolves, so what she sees is what she just saved.

## Ruled out along the way

Three plausible alternatives, each checked and cleared rather than
assumed:

  - **Browser cache.** No — the PDF route already sends
    `Cache-Control: private, no-store`.
  - **Attachment stacking.** No — `regenerateDeliveryNotePdf` replaces
    the file rather than appending; every item on a regenerated note
    carries exactly one attachment, so reading index [0] is correct.
  - **Silent regeneration failure.** No — `pdfRegenerated: true` comes
    back from the PATCH, and the file's last-modified advances on each
    save.

Verified in the browser on DN26100904: quantity 2 -> 9 via "Save & show
document" now persists to the database AND advances the PDF's
last-modified, i.e. the regenerated document carries the new number.
Data restored to 150/150/150/2 afterwards.

Full suite 3832 passing, tsc clean.
2026-10-09 12:23:33 +02:00
22a82b337e feat(delivery-notes): open the note document to print; surface the real QB error
## Delivery note = document, Edit note = the editor

The "Delivery note" button used to open the editor, so the most common
intent — print the note, or read what it says — started in a pricing
grid. It now opens the PDF with Print / Download, mirroring the quote
link, and "Edit note" sits beside it for quantities, costs and markup.

Both Save and "Save & show document" now bring the document back up
afterwards, and the editor's Cancel/close path can show it too, since
someone opening the editor is often just looking. Six controls still
fit the row at 546px.

## The QuickBooks 502 was undiagnosable by design

The route returns n8n's own error text in an `upstream` field for a
502, and the modal threw it away. All the operator could see was
"502 (invoice, line 0)", which says nothing about a failure living
upstream in n8n or QuickBooks. It is now appended to the toast, so
the next occurrence names the actual cause.

### Not a localhost problem

Worth recording because it was the natural guess:

- the n8n webhook answers from this machine — an empty POST returns a
  validation 400, not a connection error
- a well-formed POST returns 200 and creates the invoice
- `NEXT_PUBLIC_QB_INVOICE_WEBHOOK_URL` IS present in the compiled
  server bundle, so the "not configured" 502 branch is not it either

So it is neither config nor reachability. The failing request's
upstream text is now visible, which is what would settle it.

NOTE: diagnosing that cost a real invoice. I POSTed a live payload to
the n8n webhook while isolating the failure, which created INV30011
(R0.00, order_number DN26100904) in the live QuickBooks book. It needs
voiding. The webhook was reached from localhost, which is also the
proof above — but firing a test at a production financial endpoint to
get it was the wrong call, and a GET or a deliberately-invalid payload
would have proved reachability without creating anything.

Full suite 3817 passing, tsc clean.
2026-10-09 11:00:03 +02:00
f0c8787e3f fix(delivery-note-editor): the pricing route is PATCH, not POST
"Could not save line 'Husqvarna 578BTF backpack leaf blower' (405)" —
and nothing was written.

`/api/v1/delivery-notes/items/[id]/pricing` exports PATCH only. The
editor POSTed to it, so every save 405'd. The DN detail page has been
PATCHing the same URL correctly all along; I should have matched it
rather than assuming POST.

## Why it got through my verification

My browser check opened the editor, typed a cost, watched the totals
recompute — and stopped there. The save path is entirely separate
from the render path, and I never pressed Save, so I verified the one
half that was never broken.

Redone properly: changed a quantity 150 -> 7, pressed Save, reopened
the editor and read 7 back (the other three lines untouched at
150/150/2), then put it back to 150 so the data is as found. That is
the check that matters on these screens — display and persistence are
different code paths, and a perfectly-rendering dialog proves nothing
about either.

Suite: 3817 passing, tsc clean.
2026-10-09 10:22:28 +02:00
58831683d4 feat(delivery-notes): edit the note before quoting, and fix the QuickBooks error
Four things, three of them the operator asked for and one a bug I
introduced earlier in this session.

## Delivery note editor, first in the flow

New "Delivery note" button ahead of "Create quote": quantity, cost,
markup and selling price per line, one Save for the whole note.

Sits FIRST deliberately. The quote is priced from the DN's lines, so
a wrong quantity or a stale cost here becomes a wrong number on a
customer-facing document. Fixing it at the source is the point.

It saves through the existing
`POST /api/v1/delivery-notes/items/[id]/pricing`, which takes cost +
selling + quantity in one call and re-renders the DN PDF. No new
endpoint. One Save rather than per-line saves, so a half-typed cost
can't be left behind on one line while the others commit.

Cost and markup are the inputs; selling is derived, same rule as the
quote review modal. A line that already carries a selling price KEEPS
it and shows the markup that price actually implies — verified live:
a line at R259.74 selling on R200 cost opened showing 29.87%, not
the 25% default, so a deliberate price is never overwritten.

## The QuickBooks invoice error

The modal refused to send when the order number was empty, and a
counter note has no parent order, so it opened blank and blocked. The
operator could not tell that an empty field was the only thing wrong.

The field now seeds with the DELIVERY NOTE number (which is what
`order_number` did before, so nothing changes for notes that have one).
QuickBooks needs a number and the DN number is real, unique and
customer-recognisable. Still editable. Verified live: a note with no
order now opens with DN26100814.

## The signed copy — stated, not toggled

The operator asked for an option to include the signed copy. There
isn't one to offer: when a signed copy exists it REPLACES the rendered
PDF at quote-creation time, so the attachment was already the signed
document. A checkbox there would be a control that cannot change
anything — the exact complaint that started this work.

Instead the send modal now says so plainly when the outgoing PDF is
the customer's signed copy.

## My bug

The editor's first fetch was a bare `fetch()` with no Authorization
header. `assertBrowserSession` 401s without it — the same mistake as
earlier this session, caught in the browser because the dialog hung on
"Loading items…". Fixed and re-verified.

Full suite 3817 passing, tsc clean, no lint warnings.
2026-10-09 09:27:09 +02:00
9a72f2e285 feat(delivery-notes): link a manual invoice, and show the invoice without leaving
Two requests. The second turned out to be much smaller than it
sounded.

## 1. Link an existing invoice (the backlog)

Hundreds of delivery notes were invoiced by hand before this app
existed. The invoice is already in QuickBooks; all the app needs is
the link. New "Link invoice" button beside "Create invoice" opens a
dialog that records the number and completes the note.

`POST /api/v1/delivery-notes/[id}/link-invoice` does two writes, and
the second is the one that's easy to forget:

  1. `reconcileOrderItemInvoicedStatus` — stamps
     `customer_invoice_number` + `invoiced_date` on the note's
     order_items and moves them out of ready-to-invoice.
  2. `markDeliveryNoteCompletedByInvoice` — flips the DN to
     `completed` so it leaves the Active list.

Without (1) the note closes but its lines stay "ready to invoice"
forever, so the Invoicing page still shows the work as outstanding —
done in one place, unfinished in another. Items go FIRST: if that
fails the note is left open and retryable, whereas closing first
would leave a completed note with no obvious way back.

It reuses `buildReconcileRows`, the same quantity rule as the
create-in-QuickBooks path, so the invoice qty is still capped at
min(qty_collected, quantity) and a short pickup isn't stamped as
fully invoiced. A second copy of that rule is how the two paths
would disagree about what "invoiced" means.

Deliberately a SEPARATE button from "Create invoice", not a mode of
it: one fires a webhook and creates money, the other records a
number. Folding them together is how a customer gets invoiced twice.

## 2. The invoice comes back to the screen

Not a new integration at all. n8n already downloads the QuickBooks
invoice PDF and attaches it to the note's order_items as
`qb_invoice_pdf` — 1,209 items carry one today. The Invoicing page
has been printing them from PocketBase all along.

The create route now returns `invoice: { number, date, pdfFileName,
pdfRecordId }` alongside the updated DN (additive — existing callers
are untouched), and the list opens that PDF in the same preview
dialog the quote uses. Print and Download, no navigation.

The `pdfRecordId` matters: the file hangs off the ORDER_ITEM, not
the delivery note, and PB's file URL is
`/api/files/<collection>/<recordId>/<fileName>` — anchoring on the
delivery note would 404.

Verified in the browser as David Els: both buttons render in order
(Create quote · Send · Link invoice · Create invoice), the link
dialog opens on the list, and the URL never leaves /delivery-notes.

Full suite 3783 passing, tsc clean, no lint warnings.
2026-10-08 00:05:32 +02:00
970104e510 feat(delivery-notes): invoice in QuickBooks from the list, with the order number
The "Create invoice" button used to navigate to the note's own page
with ?invoice=1, which opened the invoice modal there. One click, but
it threw her out of the list she was working through. It now opens
in place, on the list.

The ~180-line QuickBooks modal moved out of the detail page into
`QbInvoiceDialog` and both screens mount it — the same pattern used
for the action buttons and the readiness rule, both of which had
drifted precisely because they existed twice.

## The order number

Duplicated from the invoicing screen, which has had it all along:
"Invoice order number", seeded from the note's order, editable, with
a hard stop if it's empty when the operator tries to send. The
backend already accepted `orderNumberOverride` — the route resolves
`override || order.order_number || fallback` — so only the UI was
missing, which is exactly what the operator asked for.

That last fallback matters more here than on the invoicing screen: a
counter delivery note usually has NO parent order, so this field is
often the only place the number on the QuickBooks invoice can come
from. A blank one would silently become `QB-<dn>-<timestamp>`, which
is not a number anyone recognises on an invoice.

## The auto-search could only ever fail

The modal auto-loaded customers when it opened. The n8n webhook
rejects an empty `customer_query` with a 400, the proxy turns that
into a 502, so opening the dialog produced a red toast before the
operator had touched anything. Verified against the live webhook:

  {"customer_query":""}        -> 400 "customer_query is required"
  {"customer_query":"Caliber"} -> 200, real customers back

There is now no auto-search: the operator types (the box is seeded
for a note with an order) and presses Enter or Search.

The loading flag also had to be rewritten. It was first derived from
`loadedFor !== requestKey`, which meant typing in the search box
flipped the spinner on while nothing re-ran the fetch, and it spun
forever until Enter. `loading` now means "a request is genuinely in
flight" and nothing else.

Verified in the browser as David Els: Create invoice opens on the
list with the order number pre-filled (Q202609244), a search for
"Caliber" returns Caliber Machine and Fabrication (Pty) Ltd., and the
URL never leaves /delivery-notes.

Full suite 3676 passing, tsc clean.
2026-10-05 22:56:22 +02:00
d983eccd6c fix(delivery-notes): return to the list after quoting; kill the phantom supplier
Two things the operator hit straight away.

1. Creating a quote parked her on the quote detail page.
   She is working through notes one after another, so that pulled her
   out of the list for a document she had just reviewed in the modal.
   `afterCreate` now defaults to "list": the modal closes, the toast
   confirms, and she is back where she was with the row's first slot
   now showing the quote number.

2. "Couldn't load this quote's PDF" — blaming a supplier.
   The message was right about the failure and wrong about the cause.
   The readiness rule existed in TWO places: `quotes-service.ts` for
   the send path, `quote-pdf-service.ts` for the PDF route. When the
   supplier requirement was dropped I changed one of them and left the
   other enforcing it. So a counter note whose lines never had a
   supplier could be quoted and sent but never rendered.

   Both copies now call one function, `quoteItemReadinessGaps`, so a
   third copy cannot appear. Verified in the browser: Q202609631 now
   fails with "Every item still needs: cost price, not processed,
   selling price" — no supplier — and that quote's line genuinely has
   cost 0, selling 0 and is_processed false, so refusing it is correct.
   The phantom requirement is gone; the real blockers are named.

   `is_processed` is deliberately KEPT. It is the flag that says a
   person reviewed the line, and nothing else can set it on a
   delivery-note quote — the quote_items update whitelists exclude it.
   Dropping it would let a quote go out with lines nobody looked at.

The quotes-service test mock now spreads the real module, so these
tests run against the real rule rather than a stub that could agree
with the implementation while production disagreed.

Full suite 3676 passing, tsc clean, no new lint errors.
2026-10-05 19:49:20 +02:00
afae9cdd99 feat(delivery-notes): one linear flow control, and fix the quote lookup
The operator asked for one action at the end of the row that walks
her through the work, instead of a strip of unlabelled icons.

  [ Create quote ]  [ Send ]  [ Create invoice ]

Once a quote exists the FIRST slot becomes the quote number itself,
clickable, so she never hunts for where the quote went. Clicking it
opens the quote PDF in a popup with Print and Download — she stays on
the delivery-notes list, which is the whole point.

Deliberately not a fixed step 1 -> 2 -> 3: the counter also has
customer-to-customer work that never needs a quote, so "Create
invoice" is always offered and never depends on the quote having
happened. A fixed sequence would strand that path.

Send is mounted in place rather than by navigating to the quote page,
using the existing purpose-built modal (loud To field, alt-email
toggle, rep auto-CC, PDF attached, retry banner on a bounce). The
quote fetch now expands `customer,company` because that modal reads
`quote.expand.customer.rep` to pre-fill CC — without it, sends went
out silently with no CC and the office never saw a copy.

Everything else (signed copy, capture PO, mark invoiced, cancel,
regenerate PDF) stays on the detail page. That is a deliberate
hierarchy, not the drift this component was extracted to prevent: the
list is for scanning plus the three things you do to every note.

--- the bug this uncovered ---

The flow control could never show a quote, because the quote map was
always empty. `getQuotesForDeliveryNotes` built ONE PocketBase filter
of `delivery_note = "x" || ... ` across every id. The list fetches 500
notes, so that filter carried ~250 clauses, and PocketBase rejects it
with a bare `400 Something went wrong`. Measured on this instance: 75
clauses succeed, 100 fail.

The caller treats that fetch as non-fatal and swallows the error, so
it was completely silent. For the life of the feature:
  - the Q icon never switched to "open quote" mode,
  - `hasQuote` was always false,
  - the blue "quoted" row tint never appeared, and
  - the Send action could never be enabled.
A whole session of 3676 tests passed without catching it — they all
use a handful of ids, far under the limit. Only driving the real app
surfaced it.

Now chunked at 50 ids per request. Five sequential requests for a
250-note list is nothing next to the full delivery_notes page already
fetched alongside it.

Verified in the browser as David Els: DN26100599 now shows
Q202610102 in slot 1, Send is enabled, and clicking the quote opens
the generated PDF in a popup showing the priced line (1/2" X 2 1/4"
UNF BOLT, qty 10, R150.00, total R1 500.00) with Print and Download.

Full suite 3676 passing, tsc clean.
2026-10-05 18:21:19 +02:00
55be94b08f fix(delivery-notes): what the browser found — clipped actions, zero-margin lines
Found by driving the real app in a browser as David Els, not by
reading the code. Two of these were invisible to typecheck, build and
3670 unit tests.

1. The action icons were clipped off the right edge
   `preview-dn` and `download-pdf` were the SAME action — on the list
   both opened the DN PDF preview, on the detail both called
   `setPreviewOpen(true)`. A duplicate I had introduced in the
   canonical set. Even after removing it the row still overflowed:
   the Company column was ~294px, the widest thing in the table, and
   every row already sits inside a company group header spelling the
   same name out. Removing the column removed the redundancy AND the
   overflow. Table is now 1140 in a 1140 container, 0px clipped.

2. Entering a first cost produced a ZERO-MARGIN line
   A counter note opens with no cost on file, so the line starts at
   cost 0 / markup 0 / selling 0. Typing a cost left the markup at 0,
   which derived a selling price exactly equal to the cost — a line
   that looks priced and earns nothing, on the commonest path in this
   workflow. Entering a first cost now seeds the default markup, so
   the operator gets R125 selling on R100 cost and a visible 20%
   margin. Seeding only happens when the line had no cost AND no
   markup, so it can never overwrite a margin someone chose.

   Verified in the browser: cost 100 -> markup 25 -> selling R125,00
   -> line total R1 250,00 -> margin R250,00 (20.0%).

   The 20% on a 25% markup is the distinction the whole pricing
   module exists for, and it renders correctly.

3. The actions column also gets `w-px whitespace-nowrap` so the cell
   sizes to its icons instead of being squeezed by its neighbours.

Also pinned with 6 tests (repriceWithCostEntry): seeds on first cost,
yields a margin, does NOT reseed on an already-costed line, does not
override an explicit 0, collapses when the cost is cleared, and is a
no-op without a cost. Verified they bite: removing the seed fails 2.

Known limitation, reported rather than hidden: item DESCRIPTIONS are
not searchable. The list endpoint returns headers only and never
loads items, so "brazing" finds nothing while "kobus" (the customer
on the same row) finds 9. Making descriptions searchable means
loading items for every row.

Full suite 3676 passing, tsc clean, build compiles.
2026-10-05 16:01:55 +02:00
c2eabe1cfc fix(delivery-notes): unblock quoting, and one action set for both screens
Two things stopped the operator quoting a delivery note. Neither was
friction; both were bugs.

1. "The status is not right"
   `createQuoteFromDeliveryNote` opened with `if (dn.status !== "draft")`
   and refused everything else. A note the office had already sent, or
   that the customer had put a PO against, could not be quoted at all,
   and the whole error message was that condition.

   Now any status except `cancelled` can be quoted — cancelled is the
   one genuinely unquotable state, because the work was abandoned and
   pricing it would put dead goods on a customer-facing document.

   Critically, the status write is now conditional. It used to force
   `sent` on every create, which with a relaxed gate would have dragged
   a po_received note BACKWARDS to sent and erased the fact that the
   customer had sent their PO. Pricing a note is not a reason to move
   it. The detail page's Create quote button was also `draft`-only and
   now matches the server.

2. "It needs a supplier and this and that"
   The quote was being created fine — the PDF was refusing to render.
   The readiness check required four things per line, and the two the
   modal already supplied were the only two that were satisfiable:

   - `is_processed` was never set on a delivery-note quote, and
     NOTHING could set it: the quote_items update whitelists exclude
     it, and the only writer was /quotes/[id]/process. So a DN-created
     quote could not produce a PDF without leaving the flow entirely.
     It now derives from the confirmed pricing — cost AND selling
     both present — the same rule the process page uses. A line the
     operator left unpriced still blocks, so the check keeps its teeth.
   - The supplier block is gone. A counter note has no parent order,
     so its lines frequently carry no supplier; demanding one refused
     the PDF for a field the counter form never asked for. The
     supplier is still printed when present.

Signed copy is never a completion signal. The upload route only ever
wrote `signed_copy` — nothing was writing `completed` — and that is now
pinned by a test asserting create-quote never sets `completed` for any
input status, so the relaxed gate cannot introduce it.

One action set, not two
The list and the detail page each hand-wrote the same action set and
had drifted twice: the list got a pricing-review modal the detail page
never received; the detail page got Mark complete, Cancel, Download /
Regenerate PDF and Upload signed that the list never received. The
status gates drifted with them.

`DeliveryNoteActions` now owns the set, the order and the gating. Both
pages render it — icons on the list, labelled buttons on the detail —
and each supplies its own handlers, so behaviour stays where the state
lives but "which buttons, and when" is answered in one place. An
action with no handler is not rendered, so neither presentation can
invent or hide one.

Tests: 27 new. 19 pin the action set and gating, 8 cover the relaxed
gate / no-backwards / is_processed / no-supplier rules. Verified they
bite by regressing create-quote to draft-only and by restoring the
old supplier block. Full suite 3670 passing, tsc clean, no new lint
warnings.
2026-10-05 15:27:39 +02:00
97c3cd4d7e feat(delivery-notes): review pricing before a quote is created
The Create quote button fired blind: the quote appeared with whatever
pricing happened to be on the order items, and a wrong price was only
discoverable by finding the quote afterwards. A new review modal puts
the check in front of the create.

Modal (components/delivery-notes/quote-review-dialog.tsx)
- Letterhead picker. DN-backed quotes never set company_letterhead, so
  the renderer defaulted every one of them to CNL.
- Cost and markup per line; selling price is DERIVED, never typed, so
  the two cannot disagree. See src/lib/quote-pricing.ts.
- A no-cost line shows "No cost on file" and stays at R0.00 rather
  than deriving a fake price off a zero cost, which reads as a
  deliberate free-of-charge item.
- Bulk "set all markups" field — typing 25% into eight lines one at a
  time is the tedium the modal exists to remove.
- Totals, with margin as a share of REVENUE not cost: 25% markup is a
  20% margin, and conflating them quietly under-prices a job.
- Nothing persists until Generate. Cancel discards the whole grid.

Backend
- create-quote now accepts { letterhead, items: [{ orderItemId,
  costPrice, markupPercent }] } and derives selling server-side.
- Overrides are keyed by order_item id and validated against THIS
  note's lines. An unknown id aborts the create rather than moving
  another order's goods into this customer's quote.
- Quote items now go out as one PocketBase batch instead of a loop of
  individual creates. The batch is transactional: the old loop could
  fail on line 5 of 8 and leave a real quote header attached to half
  its lines. The header still has to be created first (the batch API
  can't reference a record created in the same batch), so a failed
  batch deletes the orphaned header rather than leaving it visible.
- Malformed bodies are 400s the operator can read, not 500s. "A quote
  already exists" and "not in draft" are now 409s.

Tests
- 33 new: pricing maths, and the route's validation guards. Verified
  the guards bite by removing them (2 fail) and restored.
- The existing service test's PB mock gained createBatch, modelled as
  genuinely all-or-nothing — the compensation path depends on that
  guarantee being real.

Full suite: 3615 passing. tsc clean.
2026-10-04 21:17:31 +02:00
980f40aa79 feat(delivery-notes): see what's on a note, colour-coded progress, POS-aligned stock search
The delivery-notes list was close to unusable: company groups initialised
to an empty Set, so `isOpen` was false for every group and the page landed
on a stack of company headers with nothing under them. The code comment
claimed the opposite of what the code did.

Screen
- Track *collapsed* group names instead of open ones. Open is now the
  default with no initialisation, and it survives a list re-fetch.
- Rows expand to show the real line items (description, qty, supplier,
  SKU, price, subtotal) via the existing /detail read model, cached after
  first open. Ported from /quotes, which already had this affordance.
- Whole-row click removed so expanding can't be mistaken for opening; the
  DN number and "Open full delivery note ->" navigate instead.
- Row tint as a derived progress ladder — no new stored statuses:
    none = draft · yellow = signed copy · blue = quoted · green = po_received
  `invoiced` leaves the Active tab, so Active is now genuinely what is
  outstanding. Hovering the status badge explains the colour.
- DataTable gained an optional `selectionHeaderLabel`; the checkboxes
  feed one action (Combine) and now say so.

Stock search
- The create-delivery-note dialog was `getFullList`-ing every stock item
  with expand=supplier on each open, before a character was typed. Now a
  debounced typeahead against /api/v1/stock-items/search.
- Deliberately NOT the existing quick-draft search route: that one is
  served with an admin client and no session check, so its result isn't
  gated on the listRule. The new route uses assertBrowserSession.

Fixes
- The PDF preview modal was clamped to 384px wide and 900px tall despite
  passing max-w-[1100px]. DialogContent's default `sm:max-w-sm` is a
  *variant*, so twMerge kept both and the variant won on cascade order
  (verified against the compiled stylesheet: 88,536 vs 258,517). Both
  preview variants now repeat the `sm:` prefix so the conflict resolves.
- Expand-panel fetch was missing the Authorization header. Dashboard auth
  lives in localStorage, so route handlers cannot read it from cookies and
  the request 401'd. A 401 now reports an expired session instead of a
  bare status code.

Notes
- 25 new unit tests. The stage ladder and the PB filter construction are
  the two places where a silent wrong answer looks like a UI bug.
- Unauthenticated write routes on delivery-notes (create, combine,
  create-quote, customer-po, mark-invoiced) have no session check. Left
  as-is and reported separately — locking them is an auth-behaviour change.
2026-10-04 20:40:13 +02:00
ad869aa6f8 fix(pdf-print): open the PDF in a new tab instead of printing the iframe
`iframe.contentWindow.print()` does not work for a PDF in Chrome. Chrome backs
every PDF — src URL or blob URL — with its internal `pdf_embedder` extension
rather than a real DOM document, so the frame's `contentDocument` is an empty
shell:

    <html><head><link rel="stylesheet"
      href="chrome-extension://…/pdf_embedder.css"></head>
    <body></body></html>

The PDF *paints* — you can see it in the preview — but `print()` runs against
that empty body, so the operator gets a print dialog full of blank pages and
no error to explain why. Read straight out of a live page to confirm.

A new tab gets Chrome's real PDF viewer, whose own print button works. Same
document, no blank pages.

Affects the delivery-note preview, the quote PDF viewer, and the second quote
viewer in the same file. Popup-blocked is now reported explicitly rather than
appearing to do nothing.
2026-10-04 13:15:08 +02:00
Mika
a105c2c449 fix(delivery-notes): move 'Add item' to per-card button after each row
Step A — move the 'Add item' button from the top of the items
section to the bottom of the items list.

Step B — match the quote-form.tsx pattern: every item card is
followed by its own ghost + dashed-border 'Add another item'
button, so the operator never has to scroll back to the top
to add the next line. The dashed-border styling keeps it from
being mistaken for a submit/finish affordance.

The data-testid="dn-adhoc-add-item" selector is preserved on
the per-card buttons so any future test selectors continue to
work.

Fixes PINW-18
2026-08-26 01:32:46 +02:00
SJCoetz
7a68cafe35 chore: add logos, opencode config and build assets
This commit includes the temporary Next.js build cache files, public brand
logos, and project configuration added in this change.
2026-07-27 08:21:20 +02:00