Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 POS/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ActionButton: typeof import('./src/components/common/ActionButton.vue')['default']
AuthorizationDialog: typeof import('./src/components/common/AuthorizationDialog.vue')['default']
AutocompleteSelect: typeof import('./src/components/common/AutocompleteSelect.vue')['default']
BatchSerialDialog: typeof import('./src/components/sale/BatchSerialDialog.vue')['default']
CheckboxField: typeof import('./src/components/settings/CheckboxField.vue')['default']
Expand Down
2 changes: 2 additions & 0 deletions POS/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
<div>
<router-view :key="translationVersion" />
<Toast />
<AuthorizationDialog />
</div>
</template>

<script setup>
import AuthorizationDialog from "@/components/common/AuthorizationDialog.vue";
import Toast from "@/components/common/Toast.vue";
import { translationVersion } from "@/utils/translation";
</script>
185 changes: 185 additions & 0 deletions POS/src/components/common/AuthorizationDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<template>
<div
v-if="state.open"
class="pointer-events-auto fixed inset-0 z-[var(--z-authorization)] flex items-center justify-center bg-black/50 p-4"
@click.self="onCancel"
@pointerdown.stop
>
<FocusScope trapped as-child>
<div class="w-full max-w-sm rounded-xl bg-white shadow-xl dark:bg-gray-800">
<div class="border-b border-gray-200 px-5 py-4 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ __("Authorization Required") }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ __("A manager must approve this action.") }}
</p>
</div>

<div class="space-y-4 px-5 py-4">
<div v-if="loading" class="py-6 text-center text-sm text-gray-500">
{{ __("Loading approvers…") }}
</div>

<div
v-else-if="!authorizers.length"
class="rounded-lg bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-900/30 dark:text-amber-200"
>
{{
__(
"No approver is available. Ask a System Manager to set an authorization PIN for a manager."
)
}}
</div>

<template v-else>
<div>
<label
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ __("Approver") }}
</label>
<select
v-model="approver"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
>
<option
v-for="person in authorizers"
:key="person.user"
:value="person.user"
>
{{ person.full_name || person.user }}
</option>
</select>
</div>

<div>
<label
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ __("PIN") }}
</label>
<input
ref="pinInput"
v-model="pin"
type="password"
inputmode="numeric"
autocomplete="off"
:maxlength="pinLength"
:placeholder="__('{0}-digit PIN', [pinLength])"
class="w-full rounded-lg border px-3 py-2 text-center text-2xl tracking-[0.5em] dark:bg-gray-700 dark:text-gray-100"
:class="
errorMessage
? 'border-red-500'
: 'border-gray-300 dark:border-gray-600'
"
@keyup.enter="onApprove"
/>
<p v-if="errorMessage" class="mt-1.5 text-sm text-red-600">
{{ errorMessage }}
</p>
</div>
</template>
</div>

<div
class="flex justify-end gap-2 border-t border-gray-200 px-5 py-3 dark:border-gray-700"
>
<button
type="button"
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
@click="onCancel"
>
{{ __("Cancel") }}
</button>
<button
type="button"
:disabled="!canApprove"
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
@click="onApprove"
>
{{ verifying ? __("Verifying…") : __("Approve") }}
</button>
</div>
</div>
</FocusScope>
</div>
</template>

<script setup>
import { useAuthorizationDialog } from "@/composables/useAuthorization";
import { computed, nextTick, ref, watch } from "vue";
import { FocusScope } from "reka-ui";

const {
state,
loadAuthorizers,
requestGrant,
pinLength: getPinLength,
approve,
cancel,
} = useAuthorizationDialog();

const authorizers = ref([]);
const approver = ref("");
const pin = ref("");
const errorMessage = ref("");
const loading = ref(false);
const verifying = ref(false);
const pinInput = ref(null);
const pinLength = ref(getPinLength());

const canApprove = computed(
() => Boolean(approver.value) && pin.value.length === pinLength.value && !verifying.value
);

watch(
() => state.open,
async (open) => {
if (!open) return;

authorizers.value = [];
approver.value = "";
pin.value = "";
pinLength.value = getPinLength();
errorMessage.value = "";
loading.value = true;

authorizers.value = await loadAuthorizers();
if (authorizers.value.length) {
approver.value = authorizers.value[0].user;
}
loading.value = false;

await nextTick();
pinInput.value?.focus();
}
);

async function onApprove() {
if (!canApprove.value) return;

verifying.value = true;
errorMessage.value = "";

try {
const result = await requestGrant(approver.value, pin.value);
if (result?.authorized) {
approve(result);
return;
}
errorMessage.value = result?.message || __("Authorization failed");
} catch (error) {
errorMessage.value = error?.message || __("Authorization failed");
} finally {
verifying.value = false;
pin.value = "";
await nextTick();
pinInput.value?.focus();
}
}

function onCancel() {
cancel();
}
</script>
76 changes: 69 additions & 7 deletions POS/src/components/common/SelectInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,45 @@
:ref="(el) => (optionRefs[index] = el)"
tabindex="0"
role="option"
:aria-selected="option.value === modelValue"
:aria-selected="isSelected(option.value)"
class="px-2 py-1.5 text-base cursor-pointer text-start transition-colors focus:outline-none"
:class="
option.value === modelValue
isSelected(option.value)
? 'bg-blue-50 text-blue-700'
: 'text-gray-800 hover:bg-gray-100 focus:bg-gray-100'
"
>
<div v-if="option.subtitle" class="flex flex-col">
<span class="text-sm font-medium">{{ option.label }}</span>
<span class="text-xs text-gray-500">{{ option.subtitle }}</span>
<div class="flex items-center gap-2">
<span
v-if="multiple"
class="flex-shrink-0 w-4 h-4 border rounded flex items-center justify-center"
:class="
isSelected(option.value)
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-300 bg-white'
"
>
<svg
v-if="isSelected(option.value)"
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
</span>
<div v-if="option.subtitle" class="flex flex-col min-w-0">
<span class="text-sm font-medium">{{ option.label }}</span>
<span class="text-xs text-gray-500">{{ option.subtitle }}</span>
</div>
<span v-else class="min-w-0 truncate">{{ option.label }}</span>
</div>
<span v-else>{{ option.label }}</span>
</div>
</div>
</div>
Expand All @@ -115,7 +141,7 @@ defineOptions({

const props = defineProps({
modelValue: {
type: [String, Number],
type: [String, Number, Array],
default: "",
},
options: {
Expand Down Expand Up @@ -150,6 +176,10 @@ const props = defineProps({
type: Number,
default: 50, // Limit displayed options for performance
},
multiple: {
type: Boolean,
default: false,
},
});

const emit = defineEmits(["update:modelValue", "change"]);
Expand All @@ -163,11 +193,31 @@ const optionRefs = ref([]);
const dropdownPosition = ref({ top: 0, left: 0, width: 0 });
const searchQuery = ref("");

const selectedValues = computed(() => {
if (props.multiple) {
return Array.isArray(props.modelValue) ? props.modelValue : [];
}
return props.modelValue === 0 || props.modelValue ? [props.modelValue] : [];
});

const selectedLabel = computed(() => {
if (props.multiple) {
const count = selectedValues.value.length;
if (!count) return "";
if (count === 1) {
const selected = props.options.find((opt) => opt.value === selectedValues.value[0]);
return selected?.label || String(selectedValues.value[0]);
}
return `${count} selected`;
}
const selected = props.options.find((opt) => opt.value === props.modelValue);
return selected?.label || "";
});

function isSelected(value) {
return selectedValues.value.includes(value);
}

const filteredOptions = computed(() => {
let result = props.options;

Expand Down Expand Up @@ -237,6 +287,18 @@ function openAndFocusFirst() {
}

function selectOption(option) {
if (props.multiple) {
const current = [...selectedValues.value];
const idx = current.indexOf(option.value);
if (idx >= 0) {
current.splice(idx, 1);
} else {
current.push(option.value);
}
emit("update:modelValue", current);
emit("change", current);
return;
}
emit("update:modelValue", option.value);
emit("change", option.value);
close();
Expand Down
5 changes: 3 additions & 2 deletions POS/src/components/sale/CouponDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
</template>

<script setup>
import { promoApi } from "@/utils/promoApi";
import { DEFAULT_CURRENCY, formatCurrency as formatCurrencyUtil } from "@/utils/currency";
import { Button, Dialog, Input, createResource } from "frappe-ui";
import { ref, watch } from "vue";
Expand Down Expand Up @@ -281,7 +282,7 @@ const errorMessage = ref("");

// Resource to load gift cards
const giftCardsResource = createResource({
url: "pos_next.api.offers.get_active_coupons",
url: promoApi.getActiveCoupons(),
makeParams() {
return {
customer: props.customer,
Expand All @@ -296,7 +297,7 @@ const giftCardsResource = createResource({

// Resource to validate coupon
const couponResource = createResource({
url: "pos_next.api.offers.validate_coupon",
url: promoApi.validateCoupon(),
makeParams() {
return {
coupon_code: couponCode.value,
Expand Down
Loading
Loading