Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8f84bb3
feat: add Price Group ownership constants and managed state
Muhayustrid Aug 31, 2026
2c48b25
feat: add Price Group Item and Price Group Outlet child tables
Muhayustrid Aug 31, 2026
195e155
feat: add Price Group doctype with outlet-scoped price sync
Muhayustrid Aug 31, 2026
fadd201
feat: upsert Price Group ownership custom fields on install and migrate
Muhayustrid Aug 31, 2026
d77c621
chore: mirror Price Group ownership fields in custom review JSON
Muhayustrid Aug 31, 2026
267b976
test: add bootstrap test runner for pos_next
Muhayustrid Aug 31, 2026
8b64af7
test: add Price Group lifecycle and concurrency tests
Muhayustrid Aug 31, 2026
ea08fb7
feat: add Price Group shortcut and Pricing card to POSNext workspace
Muhayustrid Aug 31, 2026
7a36590
feat: add POSNext workspace sidebar with Price Group entry
Muhayustrid Aug 31, 2026
eac9f39
feat: add IDR cash denomination quick buttons to payment dialog
Muhayustrid Aug 31, 2026
172144a
chore: ignore .omo agent runtime directory
Muhayustrid Aug 31, 2026
67ab4cd
feat: add POS Package doctypes with min/max choice groups
Muhayustrid Aug 31, 2026
260e060
feat: add server-authoritative POS Package quoting API
Muhayustrid Aug 31, 2026
925c35f
feat: wire POS Package lifecycle via hooks and install
Muhayustrid Aug 31, 2026
74812de
feat: add offline POS Package quote and cache support
Muhayustrid Aug 31, 2026
b76d164
feat: add POS Package selection flow to the POS UI
Muhayustrid Aug 31, 2026
4ffa20e
Merge pull request #3 from Muhayustrid/feat/pos-package
Muhayustrid Aug 31, 2026
a570bdd
fix: scope POS Package cache by profile and never downgrade a rejecte…
Muhayustrid Sep 1, 2026
359d0c9
Merge branch 'origin/feat/idr-cash-denominations' into develop (IDR c…
Muhayustrid Sep 1, 2026
3bb082d
Merge branch 'origin/feat/price-group-port' into develop (Price Group)
Muhayustrid Sep 1, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,6 @@ dev-dist/
.playwright-mcp/*
TODO.md
.claude/settings.json
.omo/
*.jpeg
missing.txt
1 change: 1 addition & 0 deletions POS/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ declare module 'vue' {
NumberField: typeof import('./src/components/settings/NumberField.vue')['default']
OffersDialog: typeof import('./src/components/sale/OffersDialog.vue')['default']
OfflineInvoicesDialog: typeof import('./src/components/sale/OfflineInvoicesDialog.vue')['default']
PackageSelectionDialog: typeof import('./src/components/sale/PackageSelectionDialog.vue')['default']
PartialPayments: typeof import('./src/components/partials/PartialPayments.vue')['default']
PaymentDialog: typeof import('./src/components/sale/PaymentDialog.vue')['default']
PhoneInput: typeof import('./src/components/common/PhoneInput.vue')['default']
Expand Down
109 changes: 99 additions & 10 deletions POS/src/components/sale/InvoiceCart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -940,19 +940,24 @@

<div v-else class="flex flex-col gap-0.5 sm:gap-1">
<div
v-for="(item, index) in sortedItems"
v-for="(item, index) in topLevelItems"
:key="
item.package_instance ||
item.item_code +
'-' +
(item.uom || '') +
(item.is_free_item ? '-free' : '')
'-' +
(item.uom || '') +
(item.is_free_item ? '-free' : '')
"
@click="
item.is_free_item || item.package_instance ? null : openEditDialog(item)
"
@click="item.is_free_item ? null : openEditDialog(item)"
:class="[
'border rounded-md p-1.5 sm:p-2 transition-all duration-200',
item.is_free_item
? 'bg-green-50 border-green-300 cursor-default'
: 'bg-white border-gray-200 hover:border-blue-300 hover:shadow-md active:scale-[0.99] cursor-pointer group',
item.package_instance
? 'bg-blue-50/50 border-blue-300 cursor-default'
: item.is_free_item
? 'bg-green-50 border-green-300 cursor-default'
: 'bg-white border-gray-200 hover:border-blue-300 hover:shadow-md active:scale-[0.99] cursor-pointer group',
]"
>
<div class="flex gap-1.5 sm:gap-2">
Expand Down Expand Up @@ -991,6 +996,12 @@
<!-- Header: Item Name, Badges & Delete -->
<div class="flex items-start justify-between gap-0.5 mb-0.5">
<div class="flex items-center gap-1.5 flex-1 min-w-0">
<span
v-if="item.package_instance"
class="inline-flex items-center px-1.5 py-0.5 bg-blue-600 text-white rounded-full text-[9px] font-bold flex-shrink-0"
>
{{ __("PACKAGE") }}
</span>
<h4
class="text-xs sm:text-sm font-extrabold text-gray-900 truncate leading-tight"
>
Expand Down Expand Up @@ -1047,7 +1058,29 @@
</div>
</div>
<button
v-if="!item.is_free_item"
v-if="item.package_instance"
type="button"
@click.stop="$emit('remove-package', item.package_instance)"
class="text-gray-400 hover:text-red-600 active:text-red-700 transition-colors flex-shrink-0 p-0.5 -m-0.5 touch-manipulation active:scale-90"
:aria-label="__('Remove {0}', [item.item_name])"
:title="__('Remove package')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<button
v-else-if="!item.is_free_item"
type="button"
@click.stop="$emit('remove-item', item.item_code, item.uom)"
class="text-gray-400 hover:text-red-600 active:text-red-700 transition-colors flex-shrink-0 p-0.5 -m-0.5 touch-manipulation active:scale-90"
Expand Down Expand Up @@ -1075,8 +1108,17 @@
<div class="flex items-center gap-1.5">
<!-- Quantity Counter -->
<!-- For free items, show static quantity badge -->
<!-- Package parents are priced as a unit — qty is fixed at 1 -->
<div
v-if="item.package_instance"
class="flex items-center bg-blue-100 border border-blue-300 rounded px-2 h-6 sm:h-7"
>
<span class="text-xs sm:text-sm font-bold text-blue-700">{{
item.quantity
}}</span>
</div>
<div
v-if="item.is_free_item"
v-else-if="item.is_free_item"
class="flex items-center bg-green-100 border border-green-300 rounded px-2 h-6 sm:h-7"
>
<span
Expand Down Expand Up @@ -1323,6 +1365,36 @@
</div>
</div>
</div>

<!-- Package contents: read-only, priced inside the package total -->
<ul
v-if="item.package_instance"
class="mt-1.5 ps-2 border-s-2 border-blue-200 flex flex-col gap-0.5"
>
<li
v-for="component in packageComponents(item.package_instance)"
:key="component.item_code + '-' + (component.uom || '')"
class="flex items-center gap-1.5 text-[10px] sm:text-xs text-gray-600"
>
<svg
class="w-3 h-3 text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
<span class="flex-1 truncate">{{
component.item_name || component.item_code
}}</span>
<span class="font-semibold text-gray-500 flex-shrink-0"
>x{{ formatQuantity(component.quantity) }}</span
>
</li>
</ul>
</div>
</div>
</div>
Expand Down Expand Up @@ -1487,6 +1559,7 @@ import { usePOSSettingsStore } from "@/stores/posSettings";
import { usePOSOffersStore } from "@/stores/posOffers";
import { useCustomerSearchStore } from "@/stores/customerSearch";
import { DEFAULT_CURRENCY, formatCurrency as formatCurrencyUtil } from "@/utils/currency";
import { PACKAGE_ITEM_ROLE } from "@/utils/packageQuote";
import { useFormatters } from "@/composables/useFormatters";
import { useCartSort } from "@/composables/useCartSort";
import { isOffline } from "@/utils/offline";
Expand Down Expand Up @@ -1575,6 +1648,7 @@ const props = defineProps({
const emit = defineEmits([
"update-quantity", // (itemCode, newQty, uom?) - Update item quantity
"remove-item", // (itemCode, uom?) - Remove item from cart
"remove-package", // (packageInstance) - Remove a package and its component rows
"select-customer", // (customer) - Select/change customer
"edit-customer", // (customer) - Open edit customer dialog
"create-customer", // (searchText) - Open create customer dialog
Expand Down Expand Up @@ -1610,6 +1684,21 @@ const {
getCartSortIconState,
} = useCartSort(() => props.items);

/**
* Cart rows to render at the top level.
* Package component rows are excluded — they render nested under their parent.
*/
const topLevelItems = computed(() =>
sortedItems.value.filter((item) => item.package_role !== PACKAGE_ITEM_ROLE)
);

/** Component rows belonging to a package instance, in cart order. */
function packageComponents(instance) {
return props.items.filter(
(item) => item.package_instance === instance && item.package_role === PACKAGE_ITEM_ROLE
);
}

/**
* ============================================================================
* REACTIVE STATE
Expand Down
21 changes: 20 additions & 1 deletion POS/src/components/sale/ItemsSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,12 @@

<!-- Item Details -->
<div class="min-w-0">
<span
v-if="isPackageItem(item.item_code)"
class="inline-block mb-0.5 px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-[8px] sm:text-[9px] font-bold uppercase tracking-wide"
>
{{ __("Package") }}
</span>
<h3
class="text-[10px] sm:text-xs font-semibold text-gray-900 truncate mb-0.5 leading-tight"
>
Expand Down Expand Up @@ -803,6 +809,12 @@
class="text-xs sm:text-sm font-medium text-gray-900 truncate"
:title="item.item_name"
>
<span
v-if="isPackageItem(item.item_code)"
class="me-1 px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-[9px] font-bold uppercase tracking-wide"
>
{{ __("Package") }}
</span>
{{ item.item_name }}
</div>
<div
Expand Down Expand Up @@ -1009,6 +1021,7 @@
import LazyImage from "@/components/common/LazyImage.vue";
import WarehouseAvailabilityDialog from "@/components/sale/WarehouseAvailabilityDialog.vue";
import { useItemSearchStore } from "@/stores/itemSearch";
import { usePOSPackagesStore } from "@/stores/posPackages";
import { usePOSSettingsStore } from "@/stores/posSettings";
import { useStock } from "@/composables/useStock";
import { useDialogState } from "@/composables/useDialogState";
Expand Down Expand Up @@ -1043,6 +1056,7 @@ const emit = defineEmits(["item-selected"]);
// Use composables
const { getStockStatus } = useStock();
const settingsStore = usePOSSettingsStore();
const packagesStore = usePOSPackagesStore();
const { showError, showWarning } = useToast();
const { isAnyDialogOpen } = useDialogState();

Expand Down Expand Up @@ -1352,6 +1366,10 @@ onUnmounted(() => {
// Create optimized click handlers for better touch response
const optimizedClickHandlers = new Map();

function isPackageItem(itemCode) {
return packagesStore.isPackageItem(itemCode);
}

function getOptimizedClickHandler(item) {
const key = item.item_code;
if (!optimizedClickHandlers.has(key)) {
Expand Down Expand Up @@ -1414,8 +1432,9 @@ function clearLongPress() {
function selectItem(item, autoAdd = false) {
if (!item) return false;

// Early out-of-stock guard — full qty validation happens in cartStore.addItem()
// Packages are non-stock parents; their component stock is checked on add.
if (
!isPackageItem(item.item_code) &&
!item.has_variants &&
settingsStore.shouldEnforceStockValidation() &&
shouldValidateItemStock(item)
Expand Down
Loading