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/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"dexie": "^4.2.0",
"feather-icons": "^4.29.2",
"frappe-ui": "^0.1.240",
"html-to-image": "^1.11.13",
"pinia": "^3.0.4",
"qz-tray": "^2.2.5",
"reka-ui": "^2.5.0",
Expand Down
37 changes: 36 additions & 1 deletion POS/src/components/sale/InvoiceHistoryDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
</Button>
<Button
variant="ghost"
theme="blue"
size="sm"
@click="copyAsImage(invoice)"
:loading="copyingInvoice === invoice.name"
:title="__('Copy as Image')"
:aria-label="__('Copy as Image')"
>
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</Button>
</div>
</div>
</div>
Expand Down Expand Up @@ -167,11 +180,12 @@ import { useFormatters } from "@/composables/useFormatters"
import { useToast } from "@/composables/useToast"
import { DEFAULT_CURRENCY, DEFAULT_LOCALE, formatCurrency as formatCurrencyUtil } from "@/utils/currency"
import { getInvoiceStatusColor } from "@/utils/invoice"
import { copyInvoiceImage } from "@/utils/invoiceImage"
import { Button, Dialog, Input, createResource } from "frappe-ui"
import { computed, ref, watch } from "vue"
import ReturnInvoiceDialog from "./ReturnInvoiceDialog.vue"

const { showError } = useToast();
const { showError, showSuccess, showInfo } = useToast();
const { formatDate, formatTime } = useFormatters();

const props = defineProps({
Expand Down Expand Up @@ -336,6 +350,27 @@ function canCreateReturn(invoice) {
return invoice.docstatus === 1 && !invoice.is_return && invoice.status !== 'Credit Note Issued'
}

// Name of the invoice whose image is being rendered
const copyingInvoice = ref(null);

async function copyAsImage(invoice) {
if (copyingInvoice.value) return;
copyingInvoice.value = invoice.name;
try {
const { method } = await copyInvoiceImage(invoice);
if (method === "clipboard") {
showSuccess(__("Invoice image copied"));
} else {
showInfo(__("Clipboard unavailable, invoice image downloaded"));
}
} catch (error) {
console.error("Error copying invoice image:", error);
showError(error?.message || __("Failed to create invoice image"));
} finally {
copyingInvoice.value = null;
}
}

function openReturnModal(invoice) {
selectedInvoiceForReturn.value = invoice;
showReturnDialog.value = true;
Expand Down
197 changes: 197 additions & 0 deletions POS/src/utils/invoiceImage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { call } from "@/utils/apiWrapper"
import { logger } from "@/utils/logger"
import {
buildReceiptDocumentHTML,
hydrateLocalOnlyInvoice,
isLocalOnlyInvoiceName,
resolvePrintSettings,
} from "@/utils/printInvoice"
import { toCanvas } from "html-to-image"

const log = logger.create("InvoiceImage")

const FRAME_WIDTH_PX = 420
const FRAME_LOAD_TIMEOUT_MS = 20000

/**
* Describe what to render for an invoice: the same /printview page the Print
* button opens, or the built-in receipt for offline/local-only invoices.
*/
async function resolveInvoiceSource(invoiceData) {
const invoice = await hydrateLocalOnlyInvoice(invoiceData)

if (isLocalOnlyInvoiceName(invoice.name)) {
if (!invoice.items?.length) {
throw new Error(
__(
"This offline receipt is no longer in browser storage. Sync the invoice, then try again from history.",
),
)
}
return {
srcdoc: buildReceiptDocumentHTML(invoice),
selector: ".receipt",
}
}

const doctype = invoice.doctype || "Sales Invoice"
// Some invoice lists omit pos_profile; without it the
// profile's print format would silently fall back to the default.
let posProfile = invoice.pos_profile
if (!posProfile) {
try {
posProfile = await call("frappe.client.get_value", {
doctype,
filters: { name: invoice.name },
fieldname: "pos_profile",
}).then((r) => (r?.message || r)?.pos_profile)
} catch (err) {
log.warn("Could not fetch invoice POS Profile:", err)
}
}

const settings = await resolvePrintSettings(
posProfile,
invoice.print_format,
null,
)

const params = new URLSearchParams({
doctype,
name: invoice.name,
format: settings.printFormat,
no_letterhead: settings.letterhead ? 0 : 1,
_lang: "en",
_t: Date.now(),
})
if (settings.letterhead) params.append("letterhead", settings.letterhead)

return { src: `/printview?${params}`, selector: ".print-format" }
}

function loadFrame({ src, srcdoc }) {
const frame = document.createElement("iframe")
frame.setAttribute("aria-hidden", "true")
frame.tabIndex = -1
Object.assign(frame.style, {
position: "fixed",
left: "-10000px",
top: "0",
width: `${FRAME_WIDTH_PX}px`,
height: "100px",
border: "0",
visibility: "hidden",
})

const loaded = new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(__("Timed out loading invoice"))),
FRAME_LOAD_TIMEOUT_MS,
)
frame.onload = () => {
clearTimeout(timer)
resolve(frame)
}
frame.onerror = () => {
clearTimeout(timer)
reject(new Error(__("Failed to load invoice")))
}
})

if (srcdoc !== undefined) frame.srcdoc = srcdoc
else frame.src = src
document.body.appendChild(frame)
return { frame, loaded }
}

async function waitForAssets(doc) {
const images = Array.from(doc.images).filter((img) => !img.complete)
await Promise.all([
doc.fonts?.ready,
...images.map(
(img) =>
new Promise((resolve) => {
img.onload = resolve
img.onerror = resolve
}),
),
])
}

/**
* Render an invoice to an image blob ("image/png" or "image/jpeg").
*/
export async function renderInvoiceImage(invoiceData, type = "image/png") {
const source = await resolveInvoiceSource(invoiceData)
const { frame, loaded } = loadFrame(source)
try {
await loaded
const doc = frame.contentDocument
if (!doc?.body) throw new Error(__("Failed to load invoice"))
await waitForAssets(doc)

const target = doc.querySelector(source.selector) || doc.body
// Frappe's screen styles give .print-format an A4 min-height, a drop
// shadow and margins. The image size is measured from this node, so
// strip them on the (throwaway) original rather than the clone.
Object.assign(target.style, {
minHeight: "0",
boxShadow: "none",
margin: "0",
})
// Size the frame to the content so nothing is clipped.
frame.style.height = `${doc.documentElement.scrollHeight}px`

const canvas = await toCanvas(target, {
pixelRatio: 2,
backgroundColor: "#ffffff",
})
// Encode here: html-to-image's toBlob ignores `type` and always emits PNG.
const blob = await new Promise((resolve) =>
canvas.toBlob(resolve, type, 0.92),
)
if (!blob) throw new Error(__("Failed to create invoice image"))
return blob
} finally {
frame.remove()
}
}

function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}

/**
* Copy the invoice image to the clipboard as PNG (the only image type browsers
* accept there). Falls back to downloading a JPEG when the clipboard is
* unavailable, e.g. over plain http or when permission is denied.
* @returns {Promise<{method: "clipboard" | "download"}>}
*/
export async function copyInvoiceImage(invoiceData) {
if (navigator.clipboard?.write && window.ClipboardItem) {
// Pass the blob promise straight to ClipboardItem so Safari keeps the
// user-gesture context across the async render.
const pngPromise = renderInvoiceImage(invoiceData, "image/png")
try {
await navigator.clipboard.write([
new ClipboardItem({ "image/png": pngPromise }),
])
return { method: "clipboard" }
} catch (err) {
// A render failure would fail the download too — surface it.
await pngPromise
log.warn("Clipboard write failed, downloading instead:", err)
}
}

const jpeg = await renderInvoiceImage(invoiceData, "image/jpeg")
downloadBlob(jpeg, `${invoiceData.name}.jpg`)
return { method: "download" }
}
6 changes: 3 additions & 3 deletions POS/src/utils/printInvoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { printHTML as qzPrintHTML } from "@/utils/qzTray";

const log = logger.create("PrintInvoice");

const DEFAULT_PRINT_FORMAT = "POS Next Receipt";
export const DEFAULT_PRINT_FORMAT = "POS Next Receipt";

// ============================================================================
// Shared helpers
Expand Down Expand Up @@ -347,7 +347,7 @@ export function buildReceiptHTML(invoiceData) {
</div>`;
}

function buildReceiptDocumentHTML(invoiceData, { includeControls = false } = {}) {
export function buildReceiptDocumentHTML(invoiceData, { includeControls = false } = {}) {
const controls = includeControls
? `
<div class="no-print" style="text-align: center; margin-top: 20px;">
Expand Down Expand Up @@ -378,7 +378,7 @@ function buildReceiptDocumentHTML(invoiceData, { includeControls = false } = {})
* Resolve print format & letterhead from a POS Profile.
* Returns defaults when the profile lookup fails so callers always get a value.
*/
async function resolvePrintSettings(posProfile, printFormat, letterhead) {
export async function resolvePrintSettings(posProfile, printFormat, letterhead) {
if (printFormat) return { printFormat, letterhead };

if (posProfile) {
Expand Down
7 changes: 7 additions & 0 deletions pos_next/translations/ar.csv
Original file line number Diff line number Diff line change
Expand Up @@ -1680,6 +1680,13 @@ Points applied: {0}. Please pay remaining {1} with {2},تم خصم النقاط:
"Expense {0} was recorded, but attachments failed.","تم تسجيل المصروف {0}، لكن فشل إرفاق الملفات.",""
"Mode of Payment","طريقة الدفع",""
"Submit","إرسال",""
"Copy as Image","نسخ كصورة",""
"Invoice image copied","تم نسخ صورة الفاتورة",""
"Clipboard unavailable, invoice image downloaded","الحافظة غير متاحة، تم تنزيل صورة الفاتورة",""
"Failed to create invoice image","فشل إنشاء صورة الفاتورة",""
"Timed out loading invoice","انتهت مهلة تحميل الفاتورة",""
"Failed to load invoice","فشل تحميل الفاتورة",""
"This offline receipt is no longer in browser storage. Sync the invoice, then try again from history.","هذا الإيصال المحفوظ دون اتصال لم يعد موجودًا في تخزين المتصفح. قم بمزامنة الفاتورة أولًا، ثم أعد المحاولة من السجل.",""
"Over limit by {0}","تجاوز الحد بمقدار {0}",""
"{0} left","المتبقي {0}",""
"Expense","مصروف",""
Expand Down
Loading