restaurant/static/js/menu.js
Padreug 4827f5e10f feat(cms): q-tree menu builder
Replace the flat sidebar + dead-subcategory-modal with a real
arbitrary-depth tree builder using Quasar's q-tree.

templates/restaurant/menu.html:
  Three-pane layout (sidebar / tree / items).
  q-tree binds to the hydrated tree returned by
  GET /api/v1/restaurants/{id}/menu. Custom default-header slot
  renders the node name + an item-count badge + a child-count
  hint, with inline buttons:
    add (disabled at depth 3),
    edit, drive_file_move, delete (with cascade prompt).
  Top-level button above the tree adds root nodes.
  Items pane filters to the selected node, with a 'New item' that
  opens the item dialog with node_id pre-selected. The item
  dialog's node_id picker is a flat-indented q-select of every
  node in the restaurant (em-space indentation per depth level).

  A dedicated Move dialog uses the same flat-indented picker, but
  filters out the moved node + its descendants and any depth-3
  candidate (cycle / depth pre-checks; server enforces both too).

static/js/menu.js:
  Vue 3 + Quasar 2 UMD. Loads {tree, items} once, builds a
  flatNodes index for the option lists, and refetches after every
  mutation (≤50 nodes per restaurant — trivial; SSE/Nostr push is
  v2). Helpers:
    _findNode    — recursive lookup by id
    _flatten     — depth-first walk producing the option list
    selectedNode / filteredItems / allNodeOptions /
    moveTargetOptions / adminkey computeds.
  Delete prompts surface child-count + item-count and pass
  cascade=true when needed.

CMS now lets the operator build menus like
    Drinks
      ├─ Hot Beverages
      │   ├─ Coffee-based
      │   └─ Cacao-based
      └─ Cold (with its own items)
including items at any non-leaf level, satisfying the design
constraint.
2026-05-09 07:11:06 +02:00

409 lines
13 KiB
JavaScript

/*
* Menu builder — q-tree + items panel.
*
* The server's GET /api/v1/restaurants/{id}/menu returns:
* { restaurant, tree: [<root MenuNode>], items: [<enriched item>] }
* `tree` is a hydrated tree (each node has children + items already
* attached, plus depth + path). `items` is the flat enriched list
* (with modifier groups, modifier options, availability windows
* pre-joined) — used here to populate the items panel by node_id.
*
* Tree mutations (create / rename / move / delete) hit the
* /api/v1/menu_nodes/* endpoints. We refetch the whole menu after
* each mutation; for ≤50 nodes per restaurant this is trivial and
* keeps state simple. SSE/Nostr push refresh is a v2.
*/
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
restaurant: window.RESTAURANT_BOOTSTRAP || {},
tree: [], // hydrated root nodes
flatNodes: [], // flat list of every node (id + name + depth + path)
enrichedItems: [], // flat list of items with modifier groups attached
selectedNodeId: null,
expandedNodeIds: [],
maxDepth: 3, // 0..3 = 4 levels; mirrors models.MAX_MENU_DEPTH
nodeDialog: {
show: false,
editing: false,
parentId: null,
parentName: '',
data: this._blankNode()
},
moveDialog: {
show: false,
nodeId: null,
nodeName: '',
newParentId: null
},
itemDialog: {
show: false,
data: this._blankItem(),
imagesText: '',
dietaryText: '',
allergensText: '',
ingredientsText: ''
},
modifiersDialog: {
show: false,
itemId: null,
itemName: '',
groups: []
}
}
},
computed: {
selectedNode() {
return this._findNode(this.tree, this.selectedNodeId)
},
filteredItems() {
if (!this.selectedNodeId) return this.enrichedItems
return this.enrichedItems.filter(
(i) => i.node_id === this.selectedNodeId
)
},
/** All nodes as a flat indented q-select option list, used for
* the item dialog's node_id picker and the move dialog. */
allNodeOptions() {
return this.flatNodes.map((n) => ({
label: `${'\u2003'.repeat(n.depth)}${n.name}`,
value: n.id
}))
},
/** Move-dialog targets exclude the node being moved + its
* descendants (cycle prevention is enforced server-side too,
* but we don't show the user options that would be rejected). */
moveTargetOptions() {
if (!this.moveDialog.nodeId) return this.allNodeOptions
const moving = this.flatNodes.find((n) => n.id === this.moveDialog.nodeId)
if (!moving) return this.allNodeOptions
const prefix = moving.path
return this.flatNodes
.filter(
(n) =>
n.id !== moving.id &&
n.path !== prefix &&
!n.path.startsWith(prefix + '/') &&
n.depth < this.maxDepth // can't add a child to a depth-3 node
)
.map((n) => ({
label: `${'\u2003'.repeat(n.depth)}${n.name}`,
value: n.id
}))
},
adminkey() {
const w = this.g.user.wallets.find((w) => w.id === this.restaurant.wallet)
return (w && w.adminkey) || (this.g.user.wallets[0] && this.g.user.wallets[0].adminkey)
}
},
methods: {
_blankNode() {
return {
id: null,
name: '',
description: '',
image_url: '',
sort_order: 0
}
},
_blankItem() {
return {
restaurant_id: '',
node_id: null,
name: '',
description: '',
price: 0,
currency: 'sat',
sku: '',
images: [],
dietary: [],
allergens: [],
ingredients: [],
calories: null,
sort_order: 0,
is_available: true,
is_featured: false,
stock: null
}
},
formatPrice(value, currency) {
const n = Number(value || 0)
const fmt = new Intl.NumberFormat(this.g.locale || 'en-US')
return `${fmt.format(n)} ${currency || ''}`.trim()
},
parseCsv(s) {
return (s || '')
.split(',')
.map((x) => x.trim())
.filter(Boolean)
},
_findNode(nodes, id) {
if (!id) return null
for (const n of nodes) {
if (n.id === id) return n
const inChild = this._findNode(n.children || [], id)
if (inChild) return inChild
}
return null
},
_flatten(nodes, out) {
for (const n of nodes) {
out.push({id: n.id, name: n.name, depth: n.depth, path: n.path})
this._flatten(n.children || [], out)
}
return out
},
// -------- fetch --------
async fetchMenu() {
try {
const {data} = await RestaurantAPI.getMenu(this.restaurant.id)
this.tree = data.tree || []
this.enrichedItems = data.items || []
this.flatNodes = this._flatten(this.tree, [])
// Auto-expand all on first load so the operator sees structure.
if (!this.expandedNodeIds.length && this.flatNodes.length) {
this.expandedNodeIds = this.flatNodes.map((n) => n.id)
}
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
// -------- nodes --------
openNodeDialog(existing, parent) {
if (existing) {
this.nodeDialog.editing = true
this.nodeDialog.parentId = null
this.nodeDialog.parentName = ''
this.nodeDialog.data = {
id: existing.id,
name: existing.name,
description: existing.description || '',
image_url: existing.image_url || '',
sort_order: existing.sort_order || 0
}
} else {
this.nodeDialog.editing = false
this.nodeDialog.parentId = parent ? parent.id : null
this.nodeDialog.parentName = parent ? parent.name : ''
this.nodeDialog.data = this._blankNode()
}
this.nodeDialog.show = true
},
async saveNode() {
const payload = {
restaurant_id: this.restaurant.id,
parent_id: this.nodeDialog.parentId,
name: this.nodeDialog.data.name,
description: this.nodeDialog.data.description || null,
image_url: this.nodeDialog.data.image_url || null,
sort_order: this.nodeDialog.data.sort_order || 0
}
try {
if (this.nodeDialog.editing) {
await RestaurantAPI.updateMenuNode(
this.adminkey,
this.nodeDialog.data.id,
payload
)
} else {
await RestaurantAPI.createMenuNode(this.adminkey, payload)
}
this.nodeDialog.show = false
await this.fetchMenu()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async deleteNode(node) {
const hasChildren = (node.children || []).length > 0
const hasItems = (node.items || []).length > 0
let cascade = false
if (hasChildren || hasItems) {
const msg = hasChildren && hasItems
? `${node.name} has child nodes AND items. Delete the whole subtree (items will be detached, not destroyed)?`
: hasChildren
? `${node.name} has child nodes. Delete the whole subtree?`
: `${node.name} has items. Detach them and delete the node?`
if (!confirm(msg)) return
cascade = true
} else {
if (!confirm(`Delete ${node.name}?`)) return
}
try {
await RestaurantAPI.deleteMenuNode(this.adminkey, node.id, cascade)
if (this.selectedNodeId === node.id) this.selectedNodeId = null
await this.fetchMenu()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
// -------- move --------
openMoveDialog(node) {
this.moveDialog.nodeId = node.id
this.moveDialog.nodeName = node.name
this.moveDialog.newParentId = null
this.moveDialog.show = true
},
async confirmMove() {
try {
await RestaurantAPI.moveMenuNode(
this.adminkey,
this.moveDialog.nodeId,
this.moveDialog.newParentId || null
)
this.moveDialog.show = false
await this.fetchMenu()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
// -------- items --------
openItemDialog(existing) {
const item = existing
? {...existing}
: {...this._blankItem(), restaurant_id: this.restaurant.id}
if (!item.node_id && this.selectedNodeId) {
item.node_id = this.selectedNodeId
}
this.itemDialog.data = item
this.itemDialog.imagesText = (item.images || []).join(', ')
this.itemDialog.dietaryText = (item.dietary || []).join(', ')
this.itemDialog.allergensText = (item.allergens || []).join(', ')
this.itemDialog.ingredientsText = (item.ingredients || []).join(', ')
this.itemDialog.show = true
},
async saveItem() {
const payload = {
...this.itemDialog.data,
images: this.parseCsv(this.itemDialog.imagesText),
dietary: this.parseCsv(this.itemDialog.dietaryText),
allergens: this.parseCsv(this.itemDialog.allergensText),
ingredients: this.parseCsv(this.itemDialog.ingredientsText)
}
// Strip the synthetic UI-only id when creating; the server sets it.
try {
if (this.itemDialog.data.id) {
await RestaurantAPI.updateMenuItem(
this.adminkey,
this.itemDialog.data.id,
payload
)
} else {
await RestaurantAPI.createMenuItem(this.adminkey, payload)
}
this.itemDialog.show = false
await this.fetchMenu()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async deleteItem(item) {
if (!confirm(`Delete ${item.name}?`)) return
try {
await RestaurantAPI.deleteMenuItem(this.adminkey, item.id)
await this.fetchMenu()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
// -------- modifier groups --------
async openModifiersDialog(item) {
this.modifiersDialog.itemId = item.id
this.modifiersDialog.itemName = item.name
try {
const {data: groups} = await RestaurantAPI.listModifierGroups(item.id)
for (const g of groups) {
const {data: mods} = await RestaurantAPI.listModifiers(g.id)
g._modifiers = mods
}
this.modifiersDialog.groups = groups
this.modifiersDialog.show = true
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async _refreshModifiers() {
const {data: groups} = await RestaurantAPI.listModifierGroups(
this.modifiersDialog.itemId
)
for (const g of groups) {
const {data: mods} = await RestaurantAPI.listModifiers(g.id)
g._modifiers = mods
}
this.modifiersDialog.groups = groups
},
async addModifierGroup() {
const name = prompt('Group name (e.g. "Choose your protein")')
if (!name) return
const kind = confirm('Required group? (Cancel = optional addon)')
? 'required'
: 'optional'
const selection = confirm('Single choice? (Cancel = multi-select)')
? 'one'
: 'many'
try {
await RestaurantAPI.createModifierGroup(this.adminkey, {
menu_item_id: this.modifiersDialog.itemId,
name,
kind,
selection
})
await this._refreshModifiers()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async deleteModifierGroup(grp) {
if (!confirm(`Delete group ${grp.name}?`)) return
try {
await RestaurantAPI.deleteModifierGroup(this.adminkey, grp.id)
await this._refreshModifiers()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async addModifier(grp) {
const name = prompt('Modifier name (e.g. "Chicken")')
if (!name) return
const priceStr = prompt('Price delta (in the same currency as the item)')
if (priceStr === null) return
const price_delta = parseFloat(priceStr) || 0
try {
await RestaurantAPI.createModifier(this.adminkey, {
group_id: grp.id,
name,
price_delta
})
await this._refreshModifiers()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async deleteModifier(grp, mod) {
if (!confirm(`Delete ${mod.name}?`)) return
try {
await RestaurantAPI.deleteModifier(this.adminkey, mod.id)
await this._refreshModifiers()
} catch (err) {
LNbits.utils.notifyApiError(err)
}
}
},
async created() {
await this.fetchMenu()
}
})