Skip to content
Merged
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
1 change: 1 addition & 0 deletions POS/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"frappe-ui": "^0.1.240",
"pinia": "^3.0.4",
"qz-tray": "^2.2.5",
"reka-ui": "^2.5.0",
"socket.io-client": "^4.7.2",
"vue": "3.5.13",
"vue-router": "^4.5.0"
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>
44 changes: 39 additions & 5 deletions POS/src/components/sale/ReturnInvoiceDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1179,6 +1179,7 @@
</template>

<script setup>
import { useAuthorization } from "@/composables/useAuthorization";
import { useOfflineStatus } from "@/composables/useOfflineStatus";
import { useToast } from "@/composables/useToast";
import { getPaymentIcon } from "@/utils/payment";
Expand All @@ -1194,6 +1195,9 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";

const { showSuccess, showError, showWarning } = useToast();
const { isOffline } = useOfflineStatus();
const { requireAuthorization } = useAuthorization();
const authorizationToken = ref(null);
const awaitingAuthorization = ref(false);

// ============================================
// Constants (hoisted for performance)
Expand Down Expand Up @@ -1444,10 +1448,10 @@ const createReturnResource = createResource({
doctype: "Sales Invoice",
pos_profile: props.posProfile,
posa_pos_opening_shift: props.posOpeningShift,
customer: baseDoc.customer || originalInvoice.value.customer,
company: baseDoc.company || originalInvoice.value.company,
customer: baseDoc.customer || originalInvoice.value?.customer,
company: baseDoc.company || originalInvoice.value?.company,
is_return: 1,
return_against: baseDoc.return_against || originalInvoice.value.name,
return_against: baseDoc.return_against || originalInvoice.value?.name,
// Setting to 0 ensures GL entries point to original invoice,
// which reduces its outstanding amount and updates its status
update_outstanding_for_self: 0,
Expand Down Expand Up @@ -1483,7 +1487,8 @@ const createReturnResource = createResource({
mode_of_payment: payment.mode_of_payment,
amount: -Math.abs(payment.amount),
})),
remarks: returnReason.value || __("Return against {0}", [originalInvoice.value.name]),
remarks: returnReason.value || __("Return against {0}", [originalInvoice.value?.name]),
authorization_token: authorizationToken.value,
};

// Return in the correct format: invoice as JSON string
Expand Down Expand Up @@ -1980,7 +1985,7 @@ function handleKeyboardShortcuts(event) {
event.preventDefault();
handleCreateReturn();
}
if (event.key === "Escape") closeReturnModal();
if (event.key === "Escape" && !awaitingAuthorization.value) closeReturnModal();
}

function incrementReturnQuantity(item) {
Expand Down Expand Up @@ -2009,6 +2014,34 @@ async function handleCreateReturn() {
}

submitError.value = "";

const returnAgainst = preparedReturnDoc.value?.return_against || originalInvoice.value?.name;
const action = returnAgainst ? "Sales Invoice Return" : "Sales Return Without Invoice";

authorizationToken.value = null;
awaitingAuthorization.value = true;
let grant;
try {
grant = await requireAuthorization(action, {
pos_profile: props.posProfile,
return_against: returnAgainst,
customer: preparedReturnDoc.value?.customer || originalInvoice.value?.customer,
amount: returnTotal.value,
});
} finally {
awaitingAuthorization.value = false;
}

if (!grant) return;

if (!originalInvoice.value) {
openErrorDialog(
__("This return was closed before authorization completed. Please start again.")
);
return;
}

authorizationToken.value = grant.grant_token || null;
isSubmitting.value = true;

try {
Expand All @@ -2027,6 +2060,7 @@ async function handleCreateReturn() {
}
} finally {
isSubmitting.value = false;
authorizationToken.value = null;
}
}

Expand Down
Loading
Loading