feat: implement portrait mode enhancements including category dropdown and optimized numpad actions
This commit is contained in:
parent
b9658d87dd
commit
671f508b85
22
README.md
22
README.md
@ -51,12 +51,29 @@ A dedicated compact layout for portrait-orientation small screens. Designed for
|
||||
| Feature | Standard | Portrait |
|
||||
|---|---|---|
|
||||
| Layout | Two-column (order left, products right) | Single pane with bottom tabs |
|
||||
| Categories | Horizontal scrollable chips | Compact hierarchical select dropdown |
|
||||
| Product grid | `auto-fill minmax(115px)` | Fixed 2 columns |
|
||||
| Product images | Full aspect ratio | Capped at 90px height |
|
||||
| Numpad | Shown inline in left pane | Dedicated "Numpad" tab |
|
||||
| Numpad | Shown inline in left pane | Dedicated "Numpad" tab (shows only selected line) |
|
||||
| Actions (Pay/Send/Checker) | Inside left pane action pad | Always-visible bottom strip |
|
||||
| Navbar | Full labels | Compact with smaller padding |
|
||||
|
||||
#### Category Dropdown
|
||||
|
||||
In portrait mode, the category selector is transformed into a compact dropdown list. It lists all active POS categories formatted hierarchically with indentation, making it extremely easy to filter products on small touch screens.
|
||||
|
||||
#### Selected Line Actions (Note & Delete)
|
||||
|
||||
The Numpad tab contains actions for modifying the selected orderline:
|
||||
- **Note**: Triggers the standard customer note input dialog.
|
||||
- **Delete**: Instantly removes the selected line from the cart.
|
||||
|
||||
#### Numpad Single Line Focus
|
||||
|
||||
To prevent overlaps on 5.8-inch screens:
|
||||
- **Cart Tab**: Shows only the order lines (full height). The numpad is hidden.
|
||||
- **Numpad Tab**: Hides all non-selected lines and hides the order summary block, showing only the selected line and the numpad/actions. If no item is selected, a friendly guide card is displayed.
|
||||
|
||||
#### Table Checker Button
|
||||
|
||||
The **Checker** button is available in the bottom pay strip in portrait mode. It allows waiters or cashiers to quickly print a basic table checker receipt.
|
||||
@ -119,6 +136,9 @@ pos_ui_optimization/
|
||||
│ └── src/
|
||||
│ ├── app/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── category_selector/
|
||||
│ │ │ │ ├── category_selector_patch.js # Hierarchical dropdown list
|
||||
│ │ │ │ └── category_selector_patch.xml
|
||||
│ │ │ ├── navbar/
|
||||
│ │ │ │ ├── navbar_patch.js # Display Mode toggle in burger menu
|
||||
│ │ │ │ └── navbar_patch.xml
|
||||
|
||||
@ -33,6 +33,9 @@ Features
|
||||
# Navbar display-mode toggle
|
||||
'pos_ui_optimization/static/src/app/components/navbar/navbar_patch.js',
|
||||
'pos_ui_optimization/static/src/app/components/navbar/navbar_patch.xml',
|
||||
# Category selector patch
|
||||
'pos_ui_optimization/static/src/app/components/category_selector/category_selector_patch.js',
|
||||
'pos_ui_optimization/static/src/app/components/category_selector/category_selector_patch.xml',
|
||||
# ProductScreen portrait tab state + layout
|
||||
'pos_ui_optimization/static/src/app/screens/product_screen/portrait_mode_patch.js',
|
||||
'pos_ui_optimization/static/src/app/screens/product_screen/portrait_screen.xml',
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { CategorySelector } from "@point_of_sale/app/components/category_selector/category_selector";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
patch(CategorySelector.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.portraitMode = useService("portrait_mode");
|
||||
},
|
||||
|
||||
get allCategoriesTree() {
|
||||
const all = this.pos.models["pos.category"].getAll() || [];
|
||||
const roots = all.filter(c => !c.parent_id).sort((a, b) => (a.sequence || 0) - (b.sequence || 0));
|
||||
const result = [];
|
||||
|
||||
const traverse = (category, depth) => {
|
||||
if (!category.hasProductsToShow) return;
|
||||
result.push({
|
||||
id: category.id,
|
||||
name: "\u00A0\u00A0".repeat(depth * 2) + (depth > 0 ? "• " : "") + category.name,
|
||||
});
|
||||
if (category.child_ids && category.child_ids.length) {
|
||||
const children = [...category.child_ids].sort((a, b) => (a.sequence || 0) - (b.sequence || 0));
|
||||
for (const child of children) {
|
||||
traverse(child, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of roots) {
|
||||
traverse(root, 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-inherit="point_of_sale.CategorySelector" t-inherit-mode="extension" owl="1">
|
||||
<xpath expr="//div[hasclass('category-list')]" position="replace">
|
||||
<div t-if="portraitMode.isPortrait" class="portrait-category-dropdown p-2 bg-view border-bottom w-100">
|
||||
<select class="form-select form-select-lg w-100 bg-light border-0 rounded-3 text-dark fw-bold"
|
||||
t-on-change="(ev) => this.pos.setSelectedCategory(ev.target.value ? parseInt(ev.target.value) : 0)">
|
||||
<option value="0" t-att-selected="!this.pos.selectedCategory">All Categories</option>
|
||||
<t t-foreach="allCategoriesTree" t-as="cat" t-key="cat.id">
|
||||
<option t-att-value="cat.id" t-att-selected="this.pos.selectedCategory?.id === cat.id">
|
||||
<t t-esc="cat.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div t-else="" t-attf-class="product-list category-list d-grid gap-1 gap-lg-2 p-2">
|
||||
<t t-foreach="this.getCategoriesAndSub()" t-as="category" t-key="category.id">
|
||||
<button t-on-click="() => this.pos.setSelectedCategory(category.id)"
|
||||
t-attf-class="o_colorlist_item_color_{{category.color or 'none'}}"
|
||||
t-att-class="{
|
||||
'border-0': category.isChildren and !this.isAncestorOrSelected(category),
|
||||
'opacity-75 border-0': !category.isChildren and !category.isSelected,
|
||||
'justify-content-center': ui.isSmall
|
||||
}"
|
||||
class="category-button p-1 btn btn-light d-flex justify-content-around align-items-center rounded-3 gap-1">
|
||||
<div t-if="showCategoryImg(category)" class="overflow-hidden flex-shrink-0 ratio ratio-1x1" style="width:40%;">
|
||||
<img t-if="category.imgSrc and !ui.isSmall" t-att-src="category.imgSrc"
|
||||
class="category-img-thumb h-100 rounded-3 object-fit-cover pe-none"
|
||||
alt="Category"
|
||||
/>
|
||||
</div>
|
||||
<div class="line-clamp-3" t-att-class="{'w-100': !showCategoryImg(category)}" t-att-style="{'width: 60%;': showCategoryImg(category)}">
|
||||
<span t-if="category.name" class="text-center" t-esc="category.name" />
|
||||
</div>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@ -1,6 +1,7 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
|
||||
import { NoteButton } from "@point_of_sale/app/screens/product_screen/control_buttons/orderline_note_button/orderline_note_button";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { useState } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
@ -19,4 +20,16 @@ patch(ProductScreen.prototype, {
|
||||
switchPortraitTab(tab) {
|
||||
this.portraitState.tab = tab;
|
||||
},
|
||||
|
||||
onDeleteLine() {
|
||||
const orderline = this.pos.getOrder().getSelectedOrderline();
|
||||
if (orderline) {
|
||||
this.pos.getOrder().removeOrderline(orderline);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ProductScreen.components = {
|
||||
...ProductScreen.components,
|
||||
NoteButton,
|
||||
};
|
||||
|
||||
@ -16,10 +16,44 @@
|
||||
</xpath>
|
||||
|
||||
<!--
|
||||
2. Inject the portrait footer (pay strip + tab bar) at the bottom
|
||||
2. Toggle visibility class on the pads container in portrait mode
|
||||
when on the Numpad tab with no selected order line.
|
||||
-->
|
||||
<xpath expr="//div[hasclass('pads')]" position="attributes">
|
||||
<attribute name="t-att-class">{'d-none': portraitMode.isPortrait and portraitTab === 'numpad' and (!currentOrder or currentOrder.isEmpty() or !pos.getOrder()?.uiState.selected_orderline_uuid)}</attribute>
|
||||
</xpath>
|
||||
|
||||
<!--
|
||||
3. Inject a friendly no-selection placeholder before the pads container
|
||||
when on the Numpad tab but no item is selected.
|
||||
-->
|
||||
<xpath expr="//div[hasclass('pads')]" position="before">
|
||||
<div t-if="portraitMode.isPortrait and portraitTab === 'numpad' and (!currentOrder or currentOrder.isEmpty() or !pos.getOrder()?.uiState.selected_orderline_uuid)"
|
||||
class="portrait-no-selection d-flex flex-column align-items-center justify-content-center p-4 text-muted text-center flex-grow-1"
|
||||
style="min-height: 45vh;">
|
||||
<i class="fa fa-hand-pointer-o fa-3x mb-3 text-secondary"/>
|
||||
<h5 class="fw-bold text-dark">No Item Selected</h5>
|
||||
<p class="fs-6 text-muted px-4">Tap on any item in the <b>Cart</b> tab to select it, then return here to modify it.</p>
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
<!--
|
||||
4. Inject portrait-specific actions (Note and Delete) above the Numpad.
|
||||
-->
|
||||
<xpath expr="//Numpad" position="before">
|
||||
<div t-if="portraitMode.isPortrait and !currentOrder.isEmpty() and pos.getOrder()?.uiState.selected_orderline_uuid"
|
||||
class="portrait-numpad-actions d-flex gap-2 mb-2 w-100">
|
||||
<NoteButton label="'Note'" class="'btn btn-outline-primary flex-fill d-flex align-items-center justify-content-center gap-2 py-3 fs-5 fw-bold rounded-3'" />
|
||||
<button class="btn btn-outline-danger flex-fill d-flex align-items-center justify-content-center gap-2 py-3 fs-5 fw-bold rounded-3"
|
||||
t-on-click="onDeleteLine">
|
||||
<i class="fa fa-trash-o fa-lg"/>Delete
|
||||
</button>
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
<!--
|
||||
5. Inject the portrait footer (pay strip + tab bar) at the bottom
|
||||
of the product-screen div. Only rendered in portrait mode.
|
||||
ActionpadWidget from pos_custom_access is NOT duplicated here —
|
||||
we reuse the existing one inside .leftpane (already patched).
|
||||
-->
|
||||
<xpath expr="//div[hasclass('product-screen')]" position="inside">
|
||||
<div t-if="portraitMode.isPortrait" class="portrait-footer">
|
||||
@ -87,8 +121,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Bottom tab bar -->
|
||||
<div class="portrait-tab-bar d-flex">
|
||||
<button
|
||||
|
||||
@ -145,8 +145,7 @@
|
||||
&.portrait-tab-cart {
|
||||
.leftpane {
|
||||
display: flex !important;
|
||||
// Show full left pane including numpad
|
||||
.subpads { display: flex; }
|
||||
.pads { display: none !important; }
|
||||
}
|
||||
.rightpane { display: none !important; }
|
||||
}
|
||||
@ -154,8 +153,42 @@
|
||||
&.portrait-tab-numpad {
|
||||
.leftpane {
|
||||
display: flex !important;
|
||||
// Compact order list, expand numpad area
|
||||
.order-container { max-height: 35vh !important; }
|
||||
|
||||
// Hide other orderlines
|
||||
.orderline:not(.selected) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// Hide the summary box
|
||||
.order-summary {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// Compact order container for a single line
|
||||
.order-container {
|
||||
max-height: 80px !important;
|
||||
height: auto !important;
|
||||
flex: 0 0 auto !important;
|
||||
overflow: hidden !important;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
// Expand subpads to take the space
|
||||
.subpads {
|
||||
display: flex !important;
|
||||
flex-grow: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// Custom action buttons above numpad
|
||||
.portrait-numpad-actions {
|
||||
.btn {
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.rightpane { display: none !important; }
|
||||
}
|
||||
@ -333,4 +366,40 @@
|
||||
font-size: 1.1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CATEGORY DROPDOWN & PLACEHOLDER STYLING
|
||||
============================================================ */
|
||||
.portrait-category-dropdown {
|
||||
background-color: #fff;
|
||||
select {
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
font-size: 1rem !important;
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
border: 1px solid #ced4da !important;
|
||||
&:focus {
|
||||
border-color: #6610f2 !important;
|
||||
box-shadow: 0 0 0 0.25rem rgba(102, 16, 242, 0.25) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.portrait-no-selection {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
margin: 15px;
|
||||
border: 2px dashed #dee2e6;
|
||||
i {
|
||||
color: #adb5bd;
|
||||
}
|
||||
h5 {
|
||||
margin-top: 10px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
p {
|
||||
font-size: 0.88rem;
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user