# Invoice line items

> A React invoice line items editor that works like a small spreadsheet, with VAT per rate computed the e-Factura way.

- Type: React component (Forms)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add invoice-line-items`
- Docs: https://wingo-ui.com/components/invoice-line-items

## Interaction

Type in a product cell to search the catalog by name, SKU or keyword; picking one fills its unit, price and VAT, and typing a new name adds it as free text with the price flagged until you enter one. Tab moves across the cells in reading order, Escape in a number cell puts back the value it had, and an empty cell shows its prompt only on the line you are in. Enter moves to the same cell of the next line; on the last line it walks right through the text cells and adds a line from the last one, and Cmd or Ctrl + Enter adds a line from anywhere in the table. Drag a line by its grip with the mouse, or focus the grip and press Space to lift, the arrows to move, Space to drop and Escape to cancel; Alt + Arrow Up / Down moves the line you are in, and Delete or Backspace on a focused grip removes it. The ⋯ menu duplicates, moves (⌥↑ / ⌥↓ work from any cell) and removes; a removed line leaves an undo row for five seconds, paused while you hover or focus it. The % / lei chip in a discount cell switches the discount type. Totals update on every keystroke: subtotal, discounts, the taxable base, VAT per rate and the grand total, which is announced once your edits settle. Narrower than 768px (or with layout cards) every line is a card: tap it to edit a copy in a bottom sheet (a dialog on desktop) and keep it with Save or Cmd / Ctrl + Enter, while closing the sheet discards the draft; long-press the grip to reorder, use ⋯ for Move up / Move down, and tap the Total row to open the breakdown.

## Installation

```bash
npx wingo-ui@latest add invoice-line-items
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/invoice-line-items` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/invoice-line-items.tsx`

npm dependencies: `npm i @dnd-kit/core @dnd-kit/sortable motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Combobox](https://wingo-ui.com/components/combobox) ([Markdown](https://wingo-ui.com/components/combobox.md))
- [Currency Input](https://wingo-ui.com/components/currency-input) ([Markdown](https://wingo-ui.com/components/currency-input.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Number Input](https://wingo-ui.com/components/number-input) ([Markdown](https://wingo-ui.com/components/number-input.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Select](https://wingo-ui.com/components/select) ([Markdown](https://wingo-ui.com/components/select.md))
- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [Commerce](https://wingo-ui.com/components/commerce) ([Markdown](https://wingo-ui.com/components/commerce.md))
- [Invoicing](https://wingo-ui.com/components/invoicing) ([Markdown](https://wingo-ui.com/components/invoicing.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import {
  InvoiceLineItems,
  InvoiceTotals,
  type InvoiceDocumentDiscount,
  type InvoiceLine,
  type InvoiceProduct,
} from "@/components/ui/invoice-line-items"

const PRODUCTS: InvoiceProduct[] = [
  { id: "crm-business", name: "Northwind CRM Business license", unit: "month", unitPrice: 250, vatRate: 0.21, sku: "CRM-BUS" },
  { id: "onboarding", name: "Setup and training", unit: "hour", unitPrice: 320, vatRate: 0.21 },
  { id: "guide", name: "Printed user guide", unit: "pc", unitPrice: 64.9, vatRate: 0.11 },
]

export function InvoiceEditor() {
  const [lines, setLines] = useState<InvoiceLine[]>([
    { id: "l1", productId: "crm-business", product: "Northwind CRM Business license", quantity: 25, unit: "month", unitPrice: 250, vatRate: 0.21, discount: 10 },
  ])
  const [discount, setDiscount] = useState<InvoiceDocumentDiscount | null>(null)
  return (
    <InvoiceLineItems
      value={lines}
      onValueChange={(next, totals) => {
        setLines(next)
        console.log("Amount due", totals.total)
      }}
      products={PRODUCTS}
      units={["pc", "hour", "day", "month", "service"]}
      documentDiscount={discount}
      onDocumentDiscountChange={setDiscount}
      name="invoice[lines]"
      currency="RON"
      locale="en-US"
      labels={{ grandTotal: "Amount due" }}
    />
  )
}

// the totals block alone (an invoice preview): pass lines or precomputed totals.
// DEFAULT_INVOICE_LINE_ITEMS_LABELS holds the English strings to spread under a translation
export function InvoicePreviewTotals({ lines }: { lines: InvoiceLine[] }) {
  return <InvoiceTotals lines={lines} currency="RON" locale="en-US" />
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value?` | `InvoiceLine[]` |  | The lines (controlled). InvoiceLine is the lib/invoicing line { id, quantity, unitPrice, vatRate?, discount?, discountType? } plus product, productId, description, unit and vatCategory (tells the 0% rows apart: E exempt, AE reverse charge, K intra-community). |
| `defaultValue?` | `InvoiceLine[]` |  | The starting lines while uncontrolled. Left out: one empty line (none when minLines is 0). |
| `onValueChange?` | `(lines: InvoiceLine[], totals: InvoiceTotalsData) => void` |  | Every edit, with the totals from computeInvoiceTotals (net, VAT per rate, total). |
| `products?` | `InvoiceProduct[]` | `"[]"` | The product combobox's options: { id, name, description?, unit?, unitPrice, vatRate?, sku?, keywords? }. Name, SKU and keywords are searched; picking one fills the unit, price and VAT and keeps the quantity. |
| `onSearchProducts?` | `(query: string) => Promise<InvoiceProduct[]>` |  | Async catalog search (the combobox's async mode, debounced, older answers dropped). |
| `allowCustomProducts?` | `boolean` | `true` | Free text in the product cell with an “Add …” row. A new product keeps its price empty and flagged until one is entered. |
| `currency?` | `string` | `"RON"` | ISO 4217 code for prices and totals. Changing it never converts the amounts. |
| `pricesIncludeVat?` | `boolean` | `false` | The price column is the price with VAT (B2C quotes, shops); the totals end with “of which VAT”. Math per lib/invoicing either way. |
| `vatRates?` | `VatRateOption[]` | `"RO_VAT_RATES"` | The VAT select's options: { value, rate, category, label, description }. Default: 21%, 11%, 0% exempt, 0% reverse charge, 0% intra-community. A line with a rate not in the list shows it as text. |
| `defaultVatRate?` | `number` | `"0.21"` | The VAT rate of new lines, as a fraction. |
| `units?` | `string[]` | `"[\"pcs\", \"hr\", \"day\", \"month\", \"kg\", \"m²\", \"service\"]"` | The unit list; any other unit can be typed. |
| `showDescription?` | `boolean` | `true` | A second line under the product for a description. |
| `showUnit?` | `boolean` | `true` | The unit column. |
| `showVat?` | `boolean` | `true` | The VAT column. |
| `showDiscount?` | `boolean` | `true` | The discount column, with a % / amount switch per line. |
| `defaultDiscountType?` | `"percent" \| "amount"` | `"percent"` | The discount type of new lines. |
| `documentDiscount?` | `{ type: "percent" \| "amount"; value: number } \| null` | `"null"` | A discount on the whole document, shared across the lines by value before VAT. |
| `onDocumentDiscountChange?` | `(discount: InvoiceDocumentDiscount \| null) => void` |  | Makes the totals' discount row editable. |
| `showTotals?` | `boolean` | `true` | The totals block under the lines. |
| `totalsPosition?` | `"below" \| "end"` | `"below"` | below: a full-width block; end: a narrow block at the end under the table (always full width on phones). |
| `layout?` | `"auto" \| "table" \| "cards"` | `"auto"` | auto: the spreadsheet table from 768px of the component's own width (not the screen's), cards with a sheet editor below it, so a narrow panel on a desktop gets the cards too. table and cards force one (the table scrolls inside its panel when it does not fit). |
| `reorderable?` | `boolean` | `true` | Drag handles (dnd-kit: mouse, a long press on touch, the keyboard), Alt + arrows and Move up / down in the menu. |
| `minLines?` | `number` | `1` | Lines that always stay: removing one of the last ones clears it instead. 0 shows an empty state when there are none. |
| `maxLines?` | `number` | `0` | The most lines; Add and Duplicate turn off at the limit. 0 means no limit. |
| `errors?` | `Record<string, Partial<Record<"product" \| "quantity" \| "unitPrice" \| "vatRate" \| "discount", string>>>` |  | Server validation per line id and field: the cell turns invalid with the message in a tooltip (desktop) or under the field (the sheet). |
| `readOnly?` | `boolean` | `false` | The invoice as the customer sees it: a plain table without inputs, the same totals. |
| `disabled?` | `boolean` | `false` | Every cell, handle and button is disabled. |
| `size?` | `"sm" \| "md"` | `"md"` | Cell controls 32 / 40px (44px on touch). |
| `variant?` | `"ghost" \| "soft"` | `"ghost"` | ghost: a spreadsheet feel with a hover tint; soft: filled fields. |
| `name?` | `string` | `""` | A hidden input with the lines as JSON, for native forms. |
| `locale?` | `string` | `""` | Numbers and money format with this locale; empty uses the UIProvider's, then the page's. |
| `undoTimeout?` | `number` | `5000` | Milliseconds the undo row stays after a line is removed. |
| `labels?` | `Partial<InvoiceLineItemsLabels>` | `"DEFAULT_INVOICE_LINE_ITEMS_LABELS"` | Every string: the order, index and actions headers, column names (product, description, quantity, unit, unitPrice, unitPriceWithVat, vat, discount, total), actions (addLine, duplicate, moveUp, moveDown, remove, undo), the add row's lineCount(count, formatted), the empty state (empty, emptyDescription), the cells (descriptionPlaceholder, untitled), the pickers (productPlaceholder, createProduct(query), noProducts, unitPlaceholder, createUnit(query), priceMissing, newProduct, discountType, percent, amount), totals (gross, lineDiscounts, documentDiscount, net, vatRow(rate, base), grandTotal, includedVat(rate), showBreakdown, hideBreakdown), the sheet (editLine, saveLine, cancel, lineTitle(n), lineTotal), accessible names (caption, cellLabel(column, n), lineActions(n), reorder(n)), announcements (removed(n, product), moved(from, to), added(n), totalAnnouncement(total)) and the drag messages (pickedUp, movedTo, dropped, dragCancelled). |
| `classNames?` | `Partial<Record<InvoiceLineItemsSlot, string>>` |  | Classes for panel, table, head, row, handle, index, product, description, quantity, unit, price, vat, discount, total, actions, add, cards, card, sheet, empty, live and totals (the panel around InvoiceTotals). |

## Parts

### InvoiceTotals (component)

The totals block on its own (an invoice preview or a sidebar): subtotal, document discount, VAT per rate and the total, from lines or precomputed totals.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `lines?` | `InvoiceLine[]` |  | The lines to compute the totals from. |
| `totals?` | `InvoiceTotalsData` |  | Precomputed totals from computeInvoiceTotals; left out, they are computed from lines. |
| `pricesIncludeVat?` | `boolean` | `false` | Unit prices already include VAT. |
| `documentDiscount?` | `{ type: "percent" \| "amount"; value: number } \| null` | `"null"` | A discount on the whole document, shown as its own row. |
| `onDocumentDiscountChange?` | `(discount: InvoiceDocumentDiscount \| null) => void` |  | Makes the discount row editable. |
| `currency?` | `string` | `"RON"` | ISO 4217 code for the amounts. |
| `locale?` | `string` |  | Intl locale for the amounts; falls back to the UIProvider locale, then the browser. |
| `collapsible?` | `boolean \| "auto"` | `false` | One Total row that opens the breakdown; auto does it while the invoice container is narrower than 768px. |
| `readOnly?` | `boolean` | `false` | Shows the totals without any editable row. |
| `labels?` | `Partial<InvoiceLineItemsLabels>` |  | Overrides for the totals' strings. |

### DEFAULT_INVOICE_LINE_ITEMS_LABELS (constant)

Every English string of the editor and the totals, to spread under a translation and override a few keys.

### InvoiceLine (type)

One line: id, quantity, unitPrice, vatRate, discount and discountType from lib/invoicing, plus product, productId, description, unit and vatCategory.

### InvoiceProduct (type)

A catalog entry for the product picker: { id, name, unitPrice, description?, unit?, vatRate?, sku?, keywords? }.

## Examples

- **Quote with prices incl. VAT**: pricesIncludeVat for B2C: the price column is “Price incl. VAT” and the VAT shows as an “incl. VAT” row under the total.
- **Read-only preview**: The invoice as the customer sees it: plain text, the same totals, a 5% document discount.
- **Server errors**: errors marks cells per line and field (a tooltip in the table, a line of text on the cards). A retired 9% rate stays visible as text.
- **Reverse charge in EUR**: An EU client: VAT 0% with category AE (reverse charge) from vatRates, amounts in euro.
- **Compact table**: layout table, size sm, soft cells, no description line, totals in a narrow block at the end.
- **Cards and the sheet editor**: layout cards: tap a line to edit a copy (a bottom sheet on phones, a dialog on desktop) and keep it with Save line.
- **Credit note**: Negative quantities for a storno: line values, VAT and the total keep their minus sign.
- **Async catalog search**: onSearchProducts asks a server as you type; allowCustomProducts off keeps the lines to catalog products.

## See also

- [Invoicing](https://wingo-ui.com/components/invoicing) ([Markdown](https://wingo-ui.com/components/invoicing.md))
- [Commerce](https://wingo-ui.com/components/commerce) ([Markdown](https://wingo-ui.com/components/commerce.md))
- [Currency Input](https://wingo-ui.com/components/currency-input) ([Markdown](https://wingo-ui.com/components/currency-input.md))
- [Combobox](https://wingo-ui.com/components/combobox) ([Markdown](https://wingo-ui.com/components/combobox.md))
- [Table](https://wingo-ui.com/components/table) ([Markdown](https://wingo-ui.com/components/table.md))
- [Approval Flow](https://wingo-ui.com/components/approval-flow) ([Markdown](https://wingo-ui.com/components/approval-flow.md))

---

Canonical: https://wingo-ui.com/components/invoice-line-items
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
