Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions app/components/DeliveryNoteModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { DELIVERY_COST_QUESTION, MAX_DELIVERY_LINES, SAYS_NOT_POUNDS, deliveryCostBasis, saysQuantity } from '#shared/utils/bar'
import { MAX_PAGE_SIZE } from '#shared/utils/pagination'
import { toOrder } from '#shared/utils/ordering'
import { plural } from '#shared/utils/text'
import type { StockItem } from '#shared/utils/bar'
import type { OrderListRow } from '#shared/utils/ordering'
import type { Page } from '#shared/utils/pagination'

// One delivery of many items on one sheet, saved together, each line its own movement (0254).
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [], recorded: [count: number] }>()

const toast = useToast()
const items = ref<StockItem[]>([])
const find = ref('')
const reading = ref(false)
const saving = ref(false)
const filling = ref(false)
const failure = ref<string | null>(null)
// Typed per item: how many came in, counted as the order list counts them, and what it cost.
const counts = ref<Record<string, number | undefined>>({})
const costs = ref<Record<string, number | undefined>>({})

watch(() => props.open, async (open) => {
if (!open) return
counts.value = {}
costs.value = {}
failure.value = null
find.value = ''
reading.value = true
try {
// Every page, since a delivery can bring any item; each page is its own fixed query.
const read: StockItem[] = []
for (let page = 1, pages = 1; page <= pages; page++) {
const answer = await $fetch<Page<StockItem>, string>(`/api/admin/bar/items?retired=false&sort=name&pageSize=${MAX_PAGE_SIZE}&page=${page}`)
read.push(...answer.items)
pages = answer.pages
}
items.value = read
}
catch (error) {
failure.value = refusalText(error)
}
finally {
reading.value = false
}
}, { immediate: true })

// Whole bottles or kegs where the item has one size, the way the order list counts them (0140).
const inContainers = (item: StockItem): boolean => item.unit === 'ML' && item.containerMl !== null
const countLabel = (item: StockItem): string => inContainers(item) ? `× ${item.containerMl} ml` : item.unit === 'ML' ? 'ml' : 'each'
const qtyOf = (item: StockItem): number => (counts.value[item.id] ?? 0) * (inContainers(item) ? item.containerMl! : 1)

const shown = computed(() => {
const term = find.value.trim().toLowerCase()
return term === '' ? items.value : items.value.filter(item => item.name.toLowerCase().includes(term))
})
const lines = computed(() => items.value.filter(item => counts.value[item.id] !== undefined && (Number.isNaN(counts.value[item.id]) || counts.value[item.id]! > 0)))

async function fillFromOrderList(): Promise<void> {
filling.value = true
failure.value = null
try {
const { shortfalls } = await $fetch<{ shortfalls: OrderListRow[] }, string>('/api/admin/bar/order-list')
let filled = 0
for (const row of shortfalls) {
const count = toOrder(row) ?? row.shortfall
if (count <= 0 || !items.value.some(item => item.id === row.id)) continue
counts.value[row.id] = count
filled++
}
toast.add({ title: filled === 0 ? 'Nothing on the order list to fill' : `${plural(filled, 'line')} filled from the order list`, description: 'Change any line to what actually came in.', icon: 'i-lucide-list-checks' })
}
catch (error) {
failure.value = refusalText(error)
}
finally {
filling.value = false
}
}

async function record(): Promise<void> {
const uncounted = lines.value.find(item => Number.isNaN(counts.value[item.id]))
const unpriced = lines.value.find(item => costs.value[item.id] !== undefined && Number.isNaN(costs.value[item.id]))
if (uncounted) {
failure.value = `${uncounted.name}: type a whole number of what came in.`
return
}
if (unpriced) {
failure.value = `${unpriced.name}: ${SAYS_NOT_POUNDS}. Leave the cost empty if it is not known.`
return
}
saving.value = true
failure.value = null
try {
const body = { lines: lines.value.map(item => ({ itemId: item.id, qty: qtyOf(item), costPence: costs.value[item.id] ?? null })) }
const answer = await $fetch<{ recorded: number }>('/api/admin/bar/deliveries', { method: 'POST', body })
toast.add({ title: `Delivery recorded: ${plural(answer.recorded, 'line')}`, description: 'On-hand has moved with every line.', icon: 'i-lucide-check', color: 'success' })
emit('recorded', answer.recorded)
}
catch (error) {
failure.value = refusalText(error)
}
finally {
saving.value = false
}
}
</script>

<template>
<UModal
:open="open"
title="Record a delivery note"
description="Type what came in against each item, or fill the sheet from the order list and correct it. Saving records every line at once, each as its own delivery."
:ui="{ content: 'sm:max-w-3xl' }"
@update:open="value => { if (!value) emit('close') }"
>
<template #body>
<UAlert
v-if="failure"
data-test="delivery-failure"
class="mb-4"
color="error"
variant="subtle"
:description="failure"
/>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<UButton
icon="i-lucide-list-checks"
variant="outline"
color="neutral"
:loading="filling"
data-test="delivery-fill"
@click="fillFromOrderList"
>
Fill from the order list
</UButton>
<UInput
v-model="find"
icon="i-lucide-search"
placeholder="Find an item"
aria-label="Find an item on the sheet"
class="w-56"
data-test="delivery-find"
/>
</div>
<p
v-if="reading"
class="text-sm text-muted"
>
Reading the stocked items
</p>
<div
v-else
class="max-h-[55vh] overflow-y-auto"
>
<table
class="w-full text-sm"
data-test="delivery-sheet"
>
<thead class="sticky top-0 bg-default text-left text-xs text-muted">
<tr>
<th class="py-2 pe-2 font-medium">
Item
</th>
<th class="py-2 pe-2 font-medium">
Came in
</th>
<th class="py-2 font-medium">
Cost
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="item in shown"
:key="item.id"
:data-test="`delivery-line-${item.id}`"
>
<td class="py-2 pe-2">
<span class="font-medium">{{ item.name }}</span>
<span class="block text-xs text-muted">{{ saysQuantity(item.onHand, item.unit) }} on hand</span>
</td>
<td class="py-2 pe-2">
<div class="flex items-center gap-1">
<WholeField
v-model="counts[item.id]"
class="w-20"
:aria-label="`How many ${item.name} came in`"
:data-test="`delivery-count-${item.id}`"
/>
<span class="text-xs text-muted">{{ countLabel(item) }}</span>
</div>
</td>
<td class="py-2">
<PoundsField
v-model="costs[item.id]"
class="w-28"
:aria-label="`${DELIVERY_COST_QUESTION[deliveryCostBasis(item)].label}, ${item.name}`"
:data-test="`delivery-cost-${item.id}`"
/>
<span class="block text-xs text-muted">{{ DELIVERY_COST_QUESTION[deliveryCostBasis(item)].label }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
<p class="text-xs text-muted">
A cost left empty is recorded as not known. A note holds at most {{ MAX_DELIVERY_LINES }} lines.
</p>
<UButton
:disabled="lines.length === 0 || lines.length > MAX_DELIVERY_LINES"
:loading="saving"
data-test="delivery-submit"
@click="record"
>
{{ lines.length === 0 ? 'Type what came in first' : `Record ${plural(lines.length, 'line')}` }}
</UButton>
</div>
</template>
</UModal>
</template>
21 changes: 21 additions & 0 deletions app/pages/bar/stock/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ const sizeChips = computed(() => (moving.value ? writeOffSizes(moving.value) : [
const directionOptions = [{ label: 'Add to stock', value: true }, { label: 'Take off stock', value: false }]

const costPence = ref<number>()
const noting = ref(false)

async function noted(): Promise<void> {
noting.value = false
await reload()
}

// Asked the way the item is bought, never per millilitre (F-114 criterion 6); the route keeps it
// that way and a report divides it only when read (0100).
Expand Down Expand Up @@ -533,6 +539,15 @@ const columns = computed(() => (data.value.items.some(item => item.category)
</template>

<template #actions>
<UButton
data-test="delivery-note"
icon="i-lucide-truck"
variant="outline"
color="neutral"
@click="noting = true"
>
Record a delivery note
</UButton>
<UButton
data-test="add-item"
icon="i-lucide-plus"
Expand Down Expand Up @@ -967,5 +982,11 @@ const columns = computed(() => (data.value.items.some(item => item.category)
@update:open="value => { if (!value) retiring = null }"
@confirm="retire"
/>

<DeliveryNoteModal
:open="noting"
@close="noting = false"
@recorded="noted"
/>
</ReadGate>
</template>
25 changes: 24 additions & 1 deletion content/docs/05.bar/3.stock.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Stock
description: The register of stocked items, what is on hand, and recording a delivery, wastage or an adjustment.
module: Bar
audience: committee
updatedOn: 2026-10-21
updatedOn: 2026-10-25
updatedBy: Matt Adcock
navigation:
icon: i-lucide-package
Expand Down Expand Up @@ -113,6 +113,29 @@ exactly what it will write (*Write off 175 ml of House red*) before anything is
delivery whose cost nobody enters records none rather than 0.
4. Press **Record a delivery of ...**. On hand has moved with it.

### A whole delivery note

When a supplier's delivery brings many items, record the note in one go rather than item by item.

1. Press **Record a delivery note** above the register. It lists every item that is not retired,
by name; type in **Find an item** to narrow the sheet; what you have typed on other lines stays.
2. Type what came in against each item that arrived, in **Came in**: whole containers where the
item has a container size, otherwise its own unit. Or press **Fill from the order list** to
fill each line with what the [order list](/docs/bar/order-list) says is short, then change any
line to what actually came in. Lines left empty or at 0 are not recorded.
3. Optionally type each line's cost, asked the same way as a single delivery's (the label under
the field says which). A cost left empty records none.
4. Press **Record N lines**. Every line is recorded together, each as its own delivery, or none
is if one is refused.

- **"... is retired"**: an item was retired while the sheet was open. Put it back, or leave it off
the note.
- **"Type a whole number of what came in"** or **"Type pounds and pence"**: a line has something
that is not a number. Correct it, or empty it.
- A wrong line is fixed like any delivery, with **Fix a wrong delivery** on that item; the rest of
the note stands.
- A note holds at most 100 lines. Record a bigger delivery as two notes.

### A write-off

1. Press **Write off** on the item.
Expand Down
5 changes: 4 additions & 1 deletion content/docs/05.bar/6.order-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Order list
description: What is short against its par level, grouped for a supplier and exported as CSV.
module: Bar
audience: committee
updatedOn: 2026-10-21
updatedOn: 2026-10-25
updatedBy: Matt Adcock
navigation:
icon: i-lucide-truck
Expand Down Expand Up @@ -58,6 +58,9 @@ on the stock register, so giving it a par level is one press away rather than a
Nothing is written. The list changes as movements are recorded, a stocktake is applied, or a par
level is changed on the stock register.

When the order arrives, **Record a delivery note** on the [stock register](/docs/bar/stock#a-whole-delivery-note)
can fill its lines from this list, so only what differs needs typing.

## Related pages

- [Stock](/docs/bar/stock)
Expand Down
4 changes: 3 additions & 1 deletion docs/backlog/F-bar.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,9 @@ Open questions:
Only an unsettled tab charge's void puts such stock back, with its money (F-109); a card or
cash sale, a comp or a settled charge puts none back by hand.
5. Every movement stamps its actor (or system), timestamp and source document (delivery, sale
line, stocktake), so any on-hand figure can be audited to its causes.
line, stocktake), so any on-hand figure can be audited to its causes. Amended 25 October 2026
(decision 0254): a delivery note writes one delivery movement per line, together in one
batch; each line is its own source document, as a single delivery is.
6. Deliveries record cost, giving GP reporting (F-119) its cost basis. Amended 26 September
2026 (issue 1320, decision 0100): a delivery of a measured item records what one container
cost, in whole pence, and what that container held (or what the whole delivery cost, where
Expand Down
42 changes: 42 additions & 0 deletions docs/decisions/0254-a-delivery-note-is-recorded-on-one-sheet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 0254: A delivery note is recorded on one sheet

- Status: Proposed
- Date: 2026-10-25
- Amends: F-114 criterion 5

## Context

A supplier's delivery arrives as one note listing twenty or thirty items. The stock register
records a delivery one item at a time: find the row, press **Delivery**, type the containers and
the cost, press the button, and start again. The admin UX audit of 24 October 2026 found that a
thirty-line note is thirty rounds of the same dialogue, while the order list the order came from
already knows most of the quantities.

## Decision

1. **The stock register offers "Record a delivery note"**, a sheet of every stocked item that is
not retired, each with a count and a cost. The count is whole containers where the item has
one container size and its own unit otherwise, and the cost asks the same question a single
delivery asks (0100).
2. **"Fill from the order list" fills the counts** from the suggested order list's shortfalls,
in the containers or items it would order (0140). It writes nothing: the person corrects each
line to what actually came in.
3. **Saving writes every line in one `db.batch`**, each an ordinary `DELIVERY` movement stamped
with the same actor, so a note is recorded whole or not at all. There is no note record: each
movement is its own source document, exactly as a single delivery is, and is reversed one by
one from the movement history.
4. **The route reads the whole catalogue with one fixed statement** to check the lines, rather
than an `IN` list built from them (0006). A note holds at most 100 lines, which bounds the
batch; each statement binds a fixed number of parameters. An item named twice, an unknown item
or a retired item refuses the whole note and names what to fix.

## Consequences

- A whole delivery is one screen and one press. The single **Delivery** button stays for the
one-item case.
- A wrong line is corrected as any delivery is, by reversing that movement; the rest of the note
stands.
- The sheet reads every page of active items, by name, with a find box; only the lines typed are
capped, so a delivery of more than 100 items is two notes.
- The audit trail is the movements themselves, as for every hand-entered movement (0010), so the
route is exempt from the audit coverage registry.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,5 +207,6 @@ afterwards, only superseded.
| 0251 | An assignment suggests the least-booked eligible people before anything is typed | why both assign dialogues list up to eight people who pass the gate now, why the fewest shifts ahead come first, and why an unnamed rule suggests nobody. Amends E-107 criterion 3. Proposed |
| 0252 | The rota board is a row per night, and plans a bar opening from its night | why the board groups its cards under each show night with that night's confirmed count, why each night offers Plan a bar opening with the evening set, and why both screens share one planning dialogue. Amends E-107 criterion 7 and E-130 criterion 2. Proposed |
| 0253 | The rota is one sidebar entry that counts its claims, and the overview shows its week | why rota claims are a counted queue on the Rota entry and the overview, why Bar openings is a tab declared with `tabOf` rather than removed from the navigation, and why the overview lists the next seven nights still needing somebody. Proposed |
| 0254 | A delivery note is recorded on one sheet | why a whole delivery is one sheet filled from the order list, why it saves as one batch of ordinary delivery movements with no note record, and why the route reads the whole catalogue. Proposed |
| 0255 | An account says what its person can staff | why the account page shows each rota role a person can or cannot take and what is missing, linked to the fix, their shifts from tonight and their training, and why each card shows only to a viewer who already reads that screen. Proposed |
| 0256 | Ctrl+K goes to any screen the sidebar offers | why the console palette is built from the sidebar's own declaration and grouping, why typed words go to the lists that already search by them, and why a booking reference is not looked up. Proposed |
Loading
Loading