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.
This commit is contained in:
parent
b7fa1aec4a
commit
4827f5e10f
2 changed files with 358 additions and 96 deletions
|
|
@ -1,16 +1,46 @@
|
|||
/*
|
||||
* 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 || {},
|
||||
categories: [],
|
||||
items: [],
|
||||
selectedCategoryId: null,
|
||||
categoryDialog: {
|
||||
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,
|
||||
data: {restaurant_id: '', name: '', description: ''}
|
||||
editing: false,
|
||||
parentId: null,
|
||||
parentName: '',
|
||||
data: this._blankNode()
|
||||
},
|
||||
|
||||
moveDialog: {
|
||||
show: false,
|
||||
nodeId: null,
|
||||
nodeName: '',
|
||||
newParentId: null
|
||||
},
|
||||
|
||||
itemDialog: {
|
||||
show: false,
|
||||
data: this._blankItem(),
|
||||
|
|
@ -19,6 +49,7 @@ window.app = Vue.createApp({
|
|||
allergensText: '',
|
||||
ingredientsText: ''
|
||||
},
|
||||
|
||||
modifiersDialog: {
|
||||
show: false,
|
||||
itemId: null,
|
||||
|
|
@ -27,24 +58,62 @@ window.app = Vue.createApp({
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedCategory() {
|
||||
return this.categories.find((c) => c.id === this.selectedCategoryId)
|
||||
selectedNode() {
|
||||
return this._findNode(this.tree, this.selectedNodeId)
|
||||
},
|
||||
filteredItems() {
|
||||
if (!this.selectedCategoryId) return this.items
|
||||
return this.items.filter((i) => i.node_id === this.selectedCategoryId)
|
||||
if (!this.selectedNodeId) return this.enrichedItems
|
||||
return this.enrichedItems.filter(
|
||||
(i) => i.node_id === this.selectedNodeId
|
||||
)
|
||||
},
|
||||
categoryOptions() {
|
||||
return this.categories.map((c) => ({label: c.name, value: c.id}))
|
||||
/** 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() {
|
||||
// The wallet that owns this restaurant.
|
||||
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: '',
|
||||
|
|
@ -76,41 +145,124 @@ window.app = Vue.createApp({
|
|||
.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
|
||||
},
|
||||
|
||||
// -------- categories --------
|
||||
// -------- fetch --------
|
||||
async fetchMenu() {
|
||||
try {
|
||||
const {data} = await RestaurantAPI.getMenu(this.restaurant.id)
|
||||
this.categories = data.categories
|
||||
this.items = data.items
|
||||
if (!this.selectedCategoryId && this.categories.length) {
|
||||
this.selectedCategoryId = this.categories[0].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)
|
||||
}
|
||||
},
|
||||
openCategoryDialog() {
|
||||
this.categoryDialog.data = {
|
||||
restaurant_id: this.restaurant.id,
|
||||
name: '',
|
||||
description: ''
|
||||
|
||||
// -------- 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.categoryDialog.show = true
|
||||
this.nodeDialog.show = true
|
||||
},
|
||||
async saveCategory() {
|
||||
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 {
|
||||
await RestaurantAPI.createCategory(this.adminkey, this.categoryDialog.data)
|
||||
this.categoryDialog.show = false
|
||||
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 deleteCategory(cat) {
|
||||
if (!confirm(`Delete category ${cat.name}?`)) return
|
||||
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.deleteCategory(this.adminkey, cat.id)
|
||||
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)
|
||||
|
|
@ -122,8 +274,8 @@ window.app = Vue.createApp({
|
|||
const item = existing
|
||||
? {...existing}
|
||||
: {...this._blankItem(), restaurant_id: this.restaurant.id}
|
||||
if (!item.node_id && this.selectedCategoryId) {
|
||||
item.node_id = this.selectedCategoryId
|
||||
if (!item.node_id && this.selectedNodeId) {
|
||||
item.node_id = this.selectedNodeId
|
||||
}
|
||||
this.itemDialog.data = item
|
||||
this.itemDialog.imagesText = (item.images || []).join(', ')
|
||||
|
|
@ -140,6 +292,7 @@ window.app = Vue.createApp({
|
|||
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(
|
||||
|
|
@ -172,7 +325,6 @@ window.app = Vue.createApp({
|
|||
this.modifiersDialog.itemName = item.name
|
||||
try {
|
||||
const {data: groups} = await RestaurantAPI.listModifierGroups(item.id)
|
||||
// Hydrate each group with its modifiers.
|
||||
for (const g of groups) {
|
||||
const {data: mods} = await RestaurantAPI.listModifiers(g.id)
|
||||
g._modifiers = mods
|
||||
|
|
@ -183,6 +335,16 @@ window.app = Vue.createApp({
|
|||
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
|
||||
|
|
@ -199,10 +361,7 @@ window.app = Vue.createApp({
|
|||
kind,
|
||||
selection
|
||||
})
|
||||
await this.openModifiersDialog({
|
||||
id: this.modifiersDialog.itemId,
|
||||
name: this.modifiersDialog.itemName
|
||||
})
|
||||
await this._refreshModifiers()
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
|
|
@ -211,10 +370,7 @@ window.app = Vue.createApp({
|
|||
if (!confirm(`Delete group ${grp.name}?`)) return
|
||||
try {
|
||||
await RestaurantAPI.deleteModifierGroup(this.adminkey, grp.id)
|
||||
await this.openModifiersDialog({
|
||||
id: this.modifiersDialog.itemId,
|
||||
name: this.modifiersDialog.itemName
|
||||
})
|
||||
await this._refreshModifiers()
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
|
|
@ -231,10 +387,7 @@ window.app = Vue.createApp({
|
|||
name,
|
||||
price_delta
|
||||
})
|
||||
await this.openModifiersDialog({
|
||||
id: this.modifiersDialog.itemId,
|
||||
name: this.modifiersDialog.itemName
|
||||
})
|
||||
await this._refreshModifiers()
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
|
|
@ -243,15 +396,13 @@ window.app = Vue.createApp({
|
|||
if (!confirm(`Delete ${mod.name}?`)) return
|
||||
try {
|
||||
await RestaurantAPI.deleteModifier(this.adminkey, mod.id)
|
||||
await this.openModifiersDialog({
|
||||
id: this.modifiersDialog.itemId,
|
||||
name: this.modifiersDialog.itemName
|
||||
})
|
||||
await this._refreshModifiers()
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async created() {
|
||||
await this.fetchMenu()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue