Skip to content

Example: Sales Order

Summary

A complete worked example of a Sales Order — the most complex screen type in 2Wee. This walks through the full JSON for every part: the header fields, the editable grid, item lookups with autofill, calculated fields, and saving.

By the end, you will understand how all the pieces (HeaderLines, lookups, autofill, Option columns, calculated columns, and the save changeset) work together in a real document.

What the user sees

A Sales Order has two parts:

  1. Header — customer info, dates, references (card fields)
  2. Lines — the items being sold (editable grid)
┌─ Sell-to Customer ────────────────┐  ┌─ General ──────────────────────┐
│ Customer No...... [10000         ]│  │ No............. [SO-1001      ]│
│ Name............. [Cannon Group  ]│  │ Order Date..... [10-03-2026   ]│
│ Address.......... [123 Main St   ]│  │ Posting Date... [10-03-2026   ]│
│ Post Code........ [100           ]│  │ Your Reference. [PO-4455      ]│
│ City............. [Tórshavn      ]│  │ Payment Terms.. [30D          ]│
│ Phone............ [+45 70102030  ]│  │ Shipment Method [DHL          ]│
│ E-Mail........... [ap@cannon.tst ]│  └────────────────────────────────┘
└───────────────────────────────────┘
┌─ Lines ──────────────────────────────────────────────────────────────────┐
│ Type    No.       Description          UoM  Quantity  Price   Disc% Amt  │
│ ─────── ───────── ──────────────────── ──── ──────── ─────── ───── ──────│
│ Item    1200      Tripod                PCS      5   250.00  0.00   1250 │
│ Item    1000      Bicycle               PCS      2  1495.00  0.00   2990 │
│ Item    1928-S    Conference Lamp        PCS     10   45.00  0.00    450 │
│                                                                          │
└──────────────────────────────────────────────────────────────────────────┘

Step 1: The ScreenContract

Your server returns a HeaderLines layout with sections (header) and lines (grid):

json
{
  "layout": "HeaderLines",
  "title": "Sales Order - SO-1001",
  "lines_overlay_pct": 65,
  "sections": [
    {
      "id": "sell_to",
      "label": "Sell-to Customer",
      "column": 0,
      "row_group": 0,
      "fields": [
        {
          "id": "sell_to_no",
          "label": "Customer No.",
          "type": "Text",
          "value": "10000",
          "focus": true,
          "lookup": {
            "endpoint": "/lookup/sell_to_no",
            "display_field": "no",
            "validate": "/validate/sell_to_no"
          }
        },
        {
          "id": "sell_to_name",
          "label": "Name",
          "type": "Text",
          "value": "The Cannon Group",
          "quick_entry": false
        },
        {
          "id": "sell_to_address",
          "label": "Address",
          "type": "Text",
          "value": "123 Main St",
          "quick_entry": false
        },
        {
          "id": "sell_to_post_code",
          "label": "Post Code",
          "type": "Text",
          "value": "100",
          "quick_entry": false
        },
        {
          "id": "sell_to_city",
          "label": "City",
          "type": "Text",
          "value": "Tórshavn",
          "quick_entry": false
        },
        {
          "id": "sell_to_phone",
          "label": "Phone",
          "type": "Phone",
          "value": "+45 70 10 20 30",
          "quick_entry": false
        },
        {
          "id": "sell_to_email",
          "label": "E-Mail",
          "type": "Email",
          "value": "ap@cannongroup.example",
          "quick_entry": false
        }
      ]
    },
    {
      "id": "general",
      "label": "General",
      "column": 1,
      "row_group": 0,
      "fields": [
        {
          "id": "no",
          "label": "No.",
          "type": "Text",
          "value": "SO-1001",
          "editable": false
        },
        {
          "id": "order_date",
          "label": "Order Date",
          "type": "Date",
          "value": "10-03-2026"
        },
        {
          "id": "posting_date",
          "label": "Posting Date",
          "type": "Date",
          "value": "10-03-2026"
        },
        {
          "id": "your_reference",
          "label": "Your Reference",
          "type": "Text",
          "value": "PO-4455"
        },
        {
          "id": "payment_terms_code",
          "label": "Payment Terms",
          "type": "Text",
          "value": "30D",
          "lookup": {
            "endpoint": "/lookup/payment_terms",
            "display_field": "code",
            "validate": "/validate/payment_terms",
            "display": "modal"
          }
        },
        {
          "id": "shipment_method_code",
          "label": "Shipment Method",
          "type": "Text",
          "value": "DHL"
        }
      ]
    }
  ],
  "lines": {
    "columns": [
      {
        "id": "type",
        "label": "Type",
        "type": "Option",
        "width": 9,
        "editable": true,
        "quick_entry": false,
        "options": [
          "",
          "Item",
          "Resource",
          "Text"
        ]
      },
      {
        "id": "no",
        "label": "No.",
        "type": "Text",
        "width": 12,
        "editable": true,
        "lookup": {
          "endpoint": "/lookup/no",
          "display_field": "no",
          "validate": "/validate/no",
          "display": "modal",
          "context": [{ "field": "type" }]
        }
      },
      {
        "id": "description",
        "label": "Description",
        "type": "Text",
        "width": "fill",
        "editable": true,
        "quick_entry": false
      },
      {
        "id": "unit_of_measure",
        "label": "UoM",
        "type": "Text",
        "width": 6,
        "editable": true,
        "quick_entry": false,
        "lookup": {
          "endpoint": "/lookup/unit_of_measure",
          "display_field": "code",
          "display": "modal",
          "context": [{ "field": "type" }]
        }
      },
      {
        "id": "quantity",
        "label": "Quantity",
        "type": "Decimal",
        "width": 10,
        "align": "right",
        "editable": true
      },
      {
        "id": "unit_price",
        "label": "Unit Price",
        "type": "Decimal",
        "width": 12,
        "align": "right",
        "editable": true,
        "quick_entry": false
      },
      {
        "id": "line_discount_pct",
        "label": "Disc. %",
        "type": "Decimal",
        "width": 9,
        "align": "right",
        "editable": true,
        "quick_entry": false,
        "validation": {
          "min": 0.0,
          "max": 100.0,
          "decimals": 2
        }
      },
      {
        "id": "line_amount",
        "label": "Amount",
        "type": "Decimal",
        "width": 14,
        "align": "right",
        "editable": false,
        "formula": "quantity * unit_price * (1 - line_discount_pct / 100)",
        "validation": { "decimals": 2 }
      }
    ],
    "rows": [
      {
        "index": 0,
        "values": [
          "Item",
          "1200",
          "Tripod",
          "PCS",
          "5",
          "250.00",
          "0.00",
          "1,250.00"
        ]
      },
      {
        "index": 1,
        "values": [
          "Item",
          "1000",
          "Bicycle",
          "PCS",
          "2",
          "1,495.00",
          "0.00",
          "2,990.00"
        ]
      },
      {
        "index": 2,
        "values": [
          "Item",
          "1928-S",
          "Conference Lamp",
          "PCS",
          "10",
          "45.00",
          "0.00",
          "450.00"
        ]
      },
      {
        "index": 3,
        "values": [
          "",
          "",
          "",
          "",
          "",
          "",
          "",
          ""
        ]
      }
    ],
    "editable": true,
    "row_count": 4
  },
  "actions": {
    "save": "/screen/sales_order/SO-1001/save",
    "delete": "/screen/sales_order/SO-1001/delete",
    "create": "/screen/sales_order/new"
  },
  "screen_actions": [
    {
      "id": "send_email",
      "label": "Send as Email",
      "kind": "modal",
      "endpoint": "/action/sales_order/SO-1001/send_email",
      "fields": [
        {
          "id": "to",
          "label": "To",
          "type": "Email",
          "value": "ap@cannongroup.example",
          "required": true
        },
        {
          "id": "subject",
          "label": "Subject",
          "type": "Text",
          "value": "Sales Order SO-1001",
          "required": true
        },
        {
          "id": "message",
          "label": "Message",
          "type": "Text",
          "value": ""
        }
      ]
    }
  ],
  "locale": {
    "date_format": "DD-MM-YYYY",
    "decimal_separator": ".",
    "thousand_separator": ","
  },
  "work_date": "10-03-2026"
}

Key things to notice

  • lines_overlay_pct: 65 — the grid covers 65% of the screen when opened with Ctrl+L
  • line_amount has formula — calculated live by the client: quantity * unit_price * (1 - line_discount_pct / 100). The user cannot type in it (editable: false).
  • Row 3 is empty — this is the "new line" row where the user starts adding items
  • The type column is an Option — the user cycles through "", "Item", "Resource", "Text"
  • The no column has a lookup with validate — the item number is validated against the server
  • quick_entry: false on autofill columns (Description, UoM, Unit Price, Disc%) and Type — Enter skips them. The fast Enter path is: No. → Quantity → next row
  • focus: true on Customer No. — cursor starts there, not on No. (read-only)
  • quick_entry: false on header autofill fields (Name, Address, City, Phone, Email) — Enter skips from Customer No. to Order Date

Step 2: The user adds a new line

The user navigates to the empty row (row 3) and starts entering a new order line. Here is the complete workflow:

2a. Select the Type

The user moves to the Type cell and presses Space to cycle to "Item" (or opens the modal and selects it).

No server call. The client handles Option cycling locally.

2b. Look up an Item

The user moves to the No. column and presses Ctrl+Enter (or any alternate: Shift+Enter, F6, Ctrl+O).

The client reads the type column value from the current row ("Item") and sends:

GET /lookup/no?type=Item

Your server returns the Items lookup list:

json
{
  "layout": "List",
  "title": "Items",
  "lines": {
    "columns": [
      {
        "id": "no",
        "label": "No.",
        "width": 12
      },
      {
        "id": "description",
        "label": "Description",
        "width": "fill"
      },
      {
        "id": "unit_of_measure",
        "label": "UoM",
        "width": 6
      },
      {
        "id": "unit_price",
        "label": "Unit Price",
        "width": 12,
        "align": "right"
      },
      {
        "id": "line_discount_pct",
        "label": "Disc. %",
        "width": 8,
        "align": "right"
      }
    ],
    "rows": [
      {
        "index": 0,
        "values": [
          "1000",
          "Bicycle",
          "PCS",
          "1,495.00",
          "0.00"
        ]
      },
      {
        "index": 1,
        "values": [
          "1100",
          "Chain",
          "PCS",
          "45.00",
          "0.00"
        ]
      },
      {
        "index": 2,
        "values": [
          "1200",
          "Tripod",
          "PCS",
          "250.00",
          "0.00"
        ]
      },
      {
        "index": 3,
        "values": [
          "1928-S",
          "Conference Lamp",
          "PCS",
          "45.00",
          "0.00"
        ]
      }
    ],
    "selectable": true,
    "value_column": "no",
    "autofill": {
      "description": "description",
      "unit_of_measure": "unit_of_measure",
      "unit_price": "unit_price",
      "line_discount_pct": "line_discount_pct"
    }
  }
}

The autofill map says: when the user selects a row, copy these column values into the corresponding grid cells on the current row:

Lookup columnGrid column
descriptiondescription
unit_of_measureunit_of_measure
unit_priceunit_price
line_discount_pctline_discount_pct

2c. User selects "1100 - Chain"

The user presses Enter on the Chain row. The client:

  1. Writes "1100" to the no cell (from value_column)
  2. Writes "Chain" to the description cell (from autofill)
  3. Writes "PCS" to the unit_of_measure cell (from autofill)
  4. Writes "45.00" to the unit_price cell (from autofill)
  5. Writes "0.00" to the line_discount_pct cell (from autofill)

The row now looks like:

Item    1100      Chain                 PCS            45.00  0.00

All of this happens in one action — one selection fills five cells.

2d. Alternative: type the item number directly

Instead of opening the lookup, the user could type 1100 directly and press Tab to leave the cell.

The client sends a blur validation request:

GET /validate/no/1100?type=Item

Your server returns:

json
{
  "valid": true,
  "autofill": {
    "description": "Chain",
    "unit_of_measure": "PCS",
    "unit_price": "45.00",
    "line_discount_pct": "0.00"
  }
}

Same result — the description, UoM, price, and discount fill in automatically. The lookup and validation paths produce identical results.

If the user types an invalid number:

GET /validate/no/9999?type=Item
json
{
  "valid": false,
  "error": "'9999' is not a valid Item No."
}

The client locks the user on the no cell until they fix the value or press Esc to revert.

2e. Enter the quantity

The user tabs to the Quantity cell and types 4. No server call — the client accepts the input locally.

The row now shows:

Item    1100      Chain                 PCS      4     45.00  0.00

The line_amount cell (Amount) has a formula: "quantity * unit_price * (1 - discount_pct / 100)" — the client calculates it live as the user edits. After entering quantity 4, the Amount shows 180.00 (4 × 45.00). The server also recalculates on save as the authoritative source of truth.

2f. Polymorphic lookups — Resources

If the user sets Type to "Resource" instead of "Item", the same lookup request includes the context:

GET /lookup/no?type=Resource

Your server returns the Resources table instead:

json
{
  "layout": "List",
  "title": "Resources",
  "lines": {
    "columns": [
      {
        "id": "no",
        "label": "No.",
        "width": 12
      },
      {
        "id": "description",
        "label": "Description",
        "width": "fill"
      },
      {
        "id": "unit_of_measure",
        "label": "UoM",
        "width": 6
      },
      {
        "id": "unit_price",
        "label": "Unit Price",
        "width": 12,
        "align": "right"
      }
    ],
    "rows": [
      {
        "index": 0,
        "values": [
          "8100",
          "Consulting",
          "HOURS",
          "150.00"
        ]
      },
      {
        "index": 1,
        "values": [
          "8200",
          "Installation",
          "HOURS",
          "95.00"
        ]
      },
      {
        "index": 2,
        "values": [
          "8210",
          "Freight",
          "PCS",
          "0.00"
        ]
      }
    ],
    "selectable": true,
    "value_column": "no",
    "autofill": {
      "description": "description",
      "unit_of_measure": "unit_of_measure",
      "unit_price": "unit_price"
    }
  }
}

Note: Resources have no discount column, so the autofill map only includes three fields. The line_discount_pct cell stays at its current value.

One lookup endpoint (/lookup/no) serves different data based on the ?type= parameter. Your server just checks the query param and queries the right table.

Step 3: Saving

The user presses Ctrl+S. The client collects all dirty header fields and the full grid data:

json
{
  "screen_id": "sales_order",
  "record_id": "SO-1001",
  "changes": {
    "sell_to_name": "The Cannon Group"
  },
  "lines": [
    [
      "Item",
      "1200",
      "Tripod",
      "PCS",
      "5",
      "250.00",
      "0.00",
      "1,250.00"
    ],
    [
      "Item",
      "1000",
      "Bicycle",
      "PCS",
      "2",
      "1,495.00",
      "0.00",
      "2,990.00"
    ],
    [
      "Item",
      "1928-S",
      "Conference Lamp",
      "PCS",
      "10",
      "45.00",
      "0.00",
      "450.00"
    ],
    [
      "Item",
      "1100",
      "Chain",
      "PCS",
      "4",
      "45.00",
      "0.00",
      ""
    ],
    [
      "",
      "",
      "",
      "",
      "",
      "",
      "",
      ""
    ]
  ]
}

What your server does with this

  1. Parse header changes — update the sell_to_name field
  2. Parse grid lines — each inner array maps to columns by position:
    • Position 0 → type
    • Position 1 → no
    • Position 2 → description
    • Position 3 → unit_of_measure
    • Position 4 → quantity
    • Position 5 → unit_price
    • Position 6 → line_discount_pct
    • Position 7 → line_amount
  3. Skip empty rows — row 4 is all empty strings, ignore it
  4. Calculate line amounts — for each line: quantity × unit_price × (1 - discount/100)
    • Line 3 (new): 4 × 45.00 × (1 - 0/100) = 180.00
  5. Persist — save to database
  6. Return fresh ScreenContract — with all calculated values filled in

The save response

json
{
  "layout": "HeaderLines",
  "title": "Sales Order - SO-1001",
  "status": "Saved.",
  "lines_overlay_pct": 65,
  "sections": [],
  "lines": {
    "columns": [],
    "rows": [
      {
        "index": 0,
        "values": [
          "Item",
          "1200",
          "Tripod",
          "PCS",
          "5",
          "250.00",
          "0.00",
          "1,250.00"
        ]
      },
      {
        "index": 1,
        "values": [
          "Item",
          "1000",
          "Bicycle",
          "PCS",
          "2",
          "1,495.00",
          "0.00",
          "2,990.00"
        ]
      },
      {
        "index": 2,
        "values": [
          "Item",
          "1928-S",
          "Conference Lamp",
          "PCS",
          "10",
          "45.00",
          "0.00",
          "450.00"
        ]
      },
      {
        "index": 3,
        "values": [
          "Item",
          "1100",
          "Chain",
          "PCS",
          "4",
          "45.00",
          "0.00",
          "180.00"
        ]
      },
      {
        "index": 4,
        "values": [
          "",
          "",
          "",
          "",
          "",
          "",
          "",
          ""
        ]
      }
    ],
    "editable": true
  },
  "actions": {
    "save": "/screen/sales_order/SO-1001/save"
  }
}

The client replaces the current screen. The line_amount column now shows 180.00 for the new line — calculated by the server.

Step 4: Customer lookup with header autofill

The same autofill pattern works for header fields. When the user changes the Customer No.:

GET /lookup/sell_to_no
json
{
  "layout": "List",
  "title": "Customers",
  "lines": {
    "columns": [
      {
        "id": "no",
        "label": "No.",
        "width": 10
      },
      {
        "id": "name",
        "label": "Name",
        "width": "fill"
      },
      {
        "id": "city",
        "label": "City",
        "width": 15
      },
      {
        "id": "phone",
        "label": "Phone",
        "width": 18
      }
    ],
    "rows": [
      {
        "index": 0,
        "values": [
          "10000",
          "The Cannon Group",
          "Tórshavn",
          "+45 70 10 20 30"
        ]
      },
      {
        "index": 1,
        "values": [
          "20000",
          "Selangorian Ltd.",
          "London",
          "+44 20 1234 5678"
        ]
      }
    ],
    "selectable": true,
    "value_column": "no",
    "autofill": {
      "name": "sell_to_name",
      "city": "sell_to_city",
      "phone": "sell_to_phone"
    }
  }
}

Selecting customer 20000 fills in the name, city, and phone fields on the header — all at once.

The blur validation endpoint does the same for direct typing:

GET /validate/sell_to_no/20000
json
{
  "valid": true,
  "autofill": {
    "sell_to_name": "Selangorian Ltd.",
    "sell_to_address": "456 High Street",
    "sell_to_city": "London",
    "sell_to_post_code": "WC2R 1LA",
    "sell_to_phone": "+44 20 1234 5678",
    "sell_to_email": "orders@selangorian.example"
  }
}

The validate endpoint can return more autofill fields than the lookup list (the validate response is not limited by which columns the list shows).

Autofill means: you pick one thing, and the related things fill in automatically. Select a customer number, and the name, address, city, phone, and email fill in. Select an item number, and the description, unit of measure, and price fill in.

Calculated fields

The line_amount column has a formula — the client evaluates it live after every cell edit:

json
{
  "id": "line_amount",
  "editable": false,
  "formula": "quantity * unit_price * (1 - line_discount_pct / 100)"
}

When the user changes quantity, unit price, or discount, the Amount updates immediately — no server call needed. The formula references column IDs by name and supports +, -, *, /, parentheses, and numeric literals. Empty or missing values are treated as 0.

The server also recalculates on save (as the source of truth), but the user sees live results as they type.

Use validation.decimals to control the display precision (default: 2).

Step 5: Sending the order as email

The sales order has a screen_actions entry for "Send as Email". The user presses Ctrl+A (or F8) from anywhere on the order — the card header or the lines overlay — and selects "Send as Email".

The action form opens with the customer's email pre-filled from the card data. The user can edit the address, change the subject, add a message, then press Enter to submit.

The client POSTs

POST /action/sales_order/SO-1001/send_email
json
{
  "action_id": "send_email",
  "screen_title": "Sales Order - SO-1001",
  "record_id": "SO-1001",
  "fields": {
    "to": "ap@cannongroup.example",
    "subject": "Sales Order SO-1001",
    "message": "Please review and confirm."
  }
}

Your server processes it

  1. Look up the order by the URL path parameter
  2. Validate the email address
  3. Send the email (or queue it)
  4. Return the result

Success response

json
{
  "success": true,
  "message": "Email sent to ap@cannongroup.example."
}

The client shows this message in a result modal. The user presses Enter to dismiss and returns to the order.

Error response

If the email address is invalid:

json
{
  "success": false,
  "error": "A valid email address is required."
}

The client shows the error. The user can retry by pressing Ctrl+A again.

See Screen Actions for the full reference on action kinds, fields, and wire format.

Summary of all server endpoints for this example

EndpointMethodPurpose
/screen/sales_order/{no}GETLoad the order (HeaderLines ScreenContract)
/screen/sales_order/{no}/savePOSTSave changes + recalculate
/screen/sales_order/{no}/deletePOSTDelete the order
/screen/sales_order/newGETEmpty order form
/screen/sales_order/newPOSTCreate new order
/lookup/sell_to_noGETCustomer lookup list
/validate/sell_to_no/{value}GETValidate customer + autofill header
/lookup/no?type=ItemGETItem lookup list
/lookup/no?type=ResourceGETResource lookup list
/validate/no/{value}?type=ItemGETValidate item + autofill line
/validate/no/{value}?type=ResourceGETValidate resource + autofill line
/lookup/payment_termsGETPayment terms lookup (modal)
/validate/payment_terms/{value}GETValidate payment terms
/lookup/unit_of_measure?type=ItemGETUnit of measure lookup (modal)
/action/sales_order/{no}/send_emailPOSTSend order as email (screen action)