feat: implement portrait mode enhancements including category dropdown and optimized numpad actions

This commit is contained in:
Suherdy Yacob 2026-06-02 08:34:24 +07:00
parent b9658d87dd
commit 671f508b85
7 changed files with 224 additions and 10 deletions

View File

@ -51,12 +51,29 @@ A dedicated compact layout for portrait-orientation small screens. Designed for
| Feature | Standard | Portrait | | Feature | Standard | Portrait |
|---|---|---| |---|---|---|
| Layout | Two-column (order left, products right) | Single pane with bottom tabs | | 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 grid | `auto-fill minmax(115px)` | Fixed 2 columns |
| Product images | Full aspect ratio | Capped at 90px height | | 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 | | Actions (Pay/Send/Checker) | Inside left pane action pad | Always-visible bottom strip |
| Navbar | Full labels | Compact with smaller padding | | 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 #### 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. 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/ │ └── src/
│ ├── app/ │ ├── app/
│ │ ├── components/ │ │ ├── components/
│ │ │ ├── category_selector/
│ │ │ │ ├── category_selector_patch.js # Hierarchical dropdown list
│ │ │ │ └── category_selector_patch.xml
│ │ │ ├── navbar/ │ │ │ ├── navbar/
│ │ │ │ ├── navbar_patch.js # Display Mode toggle in burger menu │ │ │ │ ├── navbar_patch.js # Display Mode toggle in burger menu
│ │ │ │ └── navbar_patch.xml │ │ │ │ └── navbar_patch.xml

View File

@ -33,6 +33,9 @@ Features
# Navbar display-mode toggle # 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.js',
'pos_ui_optimization/static/src/app/components/navbar/navbar_patch.xml', '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 # 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_mode_patch.js',
'pos_ui_optimization/static/src/app/screens/product_screen/portrait_screen.xml', 'pos_ui_optimization/static/src/app/screens/product_screen/portrait_screen.xml',

View File

@ -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;
}
});

View File

@ -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>

View File

@ -1,6 +1,7 @@
/** @odoo-module **/ /** @odoo-module **/
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen"; 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 { patch } from "@web/core/utils/patch";
import { useState } from "@odoo/owl"; import { useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
@ -19,4 +20,16 @@ patch(ProductScreen.prototype, {
switchPortraitTab(tab) { switchPortraitTab(tab) {
this.portraitState.tab = tab; this.portraitState.tab = tab;
}, },
onDeleteLine() {
const orderline = this.pos.getOrder().getSelectedOrderline();
if (orderline) {
this.pos.getOrder().removeOrderline(orderline);
}
}
}); });
ProductScreen.components = {
...ProductScreen.components,
NoteButton,
};

View File

@ -16,10 +16,44 @@
</xpath> </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. 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"> <xpath expr="//div[hasclass('product-screen')]" position="inside">
<div t-if="portraitMode.isPortrait" class="portrait-footer"> <div t-if="portraitMode.isPortrait" class="portrait-footer">
@ -87,8 +121,6 @@
</button> </button>
</div> </div>
<!-- Bottom tab bar --> <!-- Bottom tab bar -->
<div class="portrait-tab-bar d-flex"> <div class="portrait-tab-bar d-flex">
<button <button

View File

@ -145,8 +145,7 @@
&.portrait-tab-cart { &.portrait-tab-cart {
.leftpane { .leftpane {
display: flex !important; display: flex !important;
// Show full left pane including numpad .pads { display: none !important; }
.subpads { display: flex; }
} }
.rightpane { display: none !important; } .rightpane { display: none !important; }
} }
@ -154,8 +153,42 @@
&.portrait-tab-numpad { &.portrait-tab-numpad {
.leftpane { .leftpane {
display: flex !important; 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; } .rightpane { display: none !important; }
} }
@ -333,4 +366,40 @@
font-size: 1.1rem !important; 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;
}
}
} }