Created
May 21, 2024 11:27
-
-
Save solanoize/5ee436ceeb7f6a30431938fadb69b82a to your computer and use it in GitHub Desktop.
Thermal Printer React
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import axios from "axios"; | |
| import { useCallback, useEffect, useMemo, useState } from "react" | |
| import { Badge, Button, Card, Col, Container, Form, Row, Table } from "react-bootstrap"; | |
| import useFormatter from "../../hooks/useFormatter"; | |
| import { FaBluetooth, FaCartPlus, FaPrint, FaTrash } from "react-icons/fa"; | |
| import useThermal, { StandardInvoice, StandardItemInvoice } from "../../hooks/useThermal"; | |
| const PageProduct = () => { | |
| const formatter = useFormatter(); | |
| const thermal = useThermal(); | |
| const [products, setProducts] = useState([]); | |
| const [items, setItems] = useState([]); | |
| const [order, setOrder] = useState({ | |
| total: 0, | |
| dibayar: 0, | |
| kembali: 0, | |
| items: [] | |
| }) | |
| const [orderValidator, setOrderValidator] = useState({ | |
| total: "", | |
| dibayar: "", | |
| kembali: "" | |
| }) | |
| const onGetProducts = () => { | |
| axios.get(`${import.meta.env.VITE_BASE_URL}/products`) | |
| .then((response) => { | |
| setProducts(response.data); | |
| }).catch((error) => { | |
| alert(`Error ${error}`); | |
| }); | |
| } | |
| const onAddItem = (value) => { | |
| let item = items.find((val) => val.id === value.id); | |
| if (item) { | |
| item.qty += 1; | |
| item.subtotal = item.qty * item.price; | |
| let index = items.findIndex((val) => val.id === value.id); | |
| setItems((values) => { | |
| let itemsCopy = [...values] | |
| itemsCopy.splice(index, 1, item); | |
| return itemsCopy; | |
| }); | |
| } else { | |
| value.qty = 1; | |
| value.subtotal = value.qty * Number(value.price); | |
| setItems([...items, value]) | |
| } | |
| } | |
| const removeItem = (index) => { | |
| setItems((values) => { | |
| let itemsCopy = [...values]; | |
| itemsCopy.splice(index, 1); | |
| return itemsCopy; | |
| }) | |
| } | |
| const getTotal = useMemo(() => { | |
| const subtotals = items.map((value) => value.subtotal); | |
| let count = 0; | |
| for (let subtotal of subtotals) { | |
| count += subtotal; | |
| } | |
| return count; | |
| }, [items]); | |
| const onHandlerOrder = (e) => { | |
| const name = e.target.name; | |
| const type = e.target.type; | |
| let value = e.target.value; | |
| if (type === 'number') { | |
| value = Number(value.replace(/\D/g, '')) | |
| } | |
| setOrderValidator((values) => ({...values, [name]: e.target.validationMessage})) | |
| setOrder((values) => ({...values, [name]: value})) | |
| } | |
| const onCalculateTotal = useCallback(() => { | |
| /** | |
| * useCallback adaalah memoisasi sebagai penyimpan nilai dalam cache | |
| * sehingga tidak perlu dihitung ulang. | |
| * | |
| * useMemo hampir mirip, hanya saja fungsionalitasnya berbeda. | |
| * useMemo mengembalikan value (cocok untuk perhitungan yang butuh data external) | |
| * sedangkan useCallback mengembalikan fungsi agar tidak di kalkulate (render) ulang | |
| * jika dependensinya tidak berubah, cocok untuk ditempatkan pada useEffect | |
| * dengan dependensi nama fungsinya sendiri | |
| */ | |
| const subtotals = items.map((value) => value.subtotal); | |
| let count = 0; | |
| for (let subtotal of subtotals) { | |
| count += subtotal; | |
| } | |
| setOrder((values) => ({...values, total: count, dibayar: 0})); | |
| }, [items]); | |
| useEffect(() => { | |
| console.log("useEffect 1") | |
| onGetProducts() | |
| }, []); | |
| useEffect(() => { | |
| console.log("useEffect 2") | |
| onCalculateTotal() | |
| }, [onCalculateTotal]) | |
| useEffect(() => { | |
| console.log("useEffect 3") | |
| if (order.dibayar >= order.total) { | |
| setOrder((values) => ({ | |
| ...values, | |
| kembali: Math.abs(order.total - order.dibayar) | |
| })); | |
| } else { | |
| setOrder((values) => ({ | |
| ...values, | |
| kembali: 0 | |
| })); | |
| } | |
| }, [order.dibayar, order.total]) | |
| const printInvoice = () => { | |
| let stdItems = items.map((value) => { | |
| return new StandardItemInvoice( | |
| value.title, | |
| value.qty, | |
| formatter.toCurrency(value.price), | |
| formatter.toCurrency(value.subtotal) | |
| ) | |
| }) | |
| let standardInvoice = new StandardInvoice( | |
| "TRX001", | |
| new Date().toISOString(), | |
| formatter.toCurrency(order.total), | |
| formatter.toCurrency(order.kembali), | |
| formatter.toCurrency(order.dibayar), | |
| stdItems | |
| ); | |
| thermal.printInvoice(standardInvoice); | |
| } | |
| return ( | |
| <> | |
| <Container className="mt-4"> | |
| <Row className="mb-4"> | |
| <Col className="d-flex justify-content-between"> | |
| <h4>Simple POS</h4> | |
| {thermal.isConnect ? ( | |
| <Button onClick={thermal.disconnect} variant="primary"> | |
| <FaBluetooth /> Printer Connected | |
| </Button> | |
| ) : ( | |
| <Button onClick={thermal.connect} variant="success"> | |
| <FaBluetooth /> Connect Thermal | |
| </Button> | |
| )} | |
| </Col> | |
| </Row> | |
| <Row> | |
| <Col> | |
| <Row> | |
| {products.map((value) => ( | |
| <Col md={4} key={value.id} className="mb-4"> | |
| <Card > | |
| <Card.Img variant="top" src={`${value.image},pizza?random${value.id}`} /> | |
| <Card.Body> | |
| <Card.Title>{value.title}</Card.Title> | |
| <Card.Text> | |
| <small className="text-muted">{value.description}</small> | |
| </Card.Text> | |
| </Card.Body> | |
| <Card.Footer className="d-flex justify-content-between align-items-end"> | |
| <Badge>{formatter.toCurrency(value.price)}</Badge> | |
| <Button onClick={() => onAddItem(value)} size="sm"> | |
| <FaCartPlus /> Add | |
| </Button> | |
| </Card.Footer> | |
| </Card> | |
| </Col> | |
| ))} | |
| </Row> | |
| </Col> | |
| <Col md={4}> | |
| <Card> | |
| <Card.Body> | |
| <Card.Title>Items</Card.Title> | |
| </Card.Body> | |
| <Table hover striped responsive> | |
| <thead> | |
| <tr> | |
| <th>Title</th> | |
| <th>Price</th> | |
| <th>Qty</th> | |
| <th>Subtotal</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {items.map((value, index) => ( | |
| <tr key={value.id}> | |
| <td >{value.title}</td> | |
| <td>{formatter.toCurrency(value.price)}</td> | |
| <td>{value.qty}</td> | |
| <td>{formatter.toCurrency(value.subtotal)}</td> | |
| <td> | |
| <Button onClick={() => removeItem(index)} variant="danger" size="sm"> | |
| <FaTrash /> | |
| </Button> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| <tfoot> | |
| <tr> | |
| <td colSpan={3}>Total</td> | |
| <th colSpan={2}> | |
| {formatter.toCurrency(getTotal)} | |
| </th> | |
| </tr> | |
| <tr> | |
| <td colSpan={3}>Dibayar</td> | |
| <td colSpan={2}> | |
| <Form.Group> | |
| <Form.Control | |
| name="dibayar" | |
| type="number" | |
| value={order.dibayar ? order.dibayar.toString() : ""} | |
| min={getTotal} | |
| required | |
| onChange={onHandlerOrder} | |
| /> | |
| {orderValidator.dibayar && ( | |
| <small className="text-danger">{orderValidator.dibayar}</small> | |
| )} | |
| </Form.Group> | |
| </td> | |
| </tr> | |
| <tr> | |
| <td colSpan={3}>Kembali</td> | |
| <th colSpan={2}> | |
| {formatter.toCurrency(order.kembali)} | |
| </th> | |
| </tr> | |
| <tr> | |
| <td colSpan={5}> | |
| <Button onClick={printInvoice}> | |
| <FaPrint /> | |
| </Button> | |
| </td> | |
| </tr> | |
| </tfoot> | |
| </Table> | |
| </Card> | |
| </Col> | |
| </Row> | |
| </Container> | |
| </> | |
| ) | |
| } | |
| export default PageProduct; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { useState } from "react" | |
| export class StandardItemInvoice { | |
| constructor(title, qty, price, subtotal) { | |
| this.title = title; | |
| this.qty = qty; | |
| this.price = price; | |
| this.subtotal = subtotal; | |
| } | |
| } | |
| export class StandardInvoice { | |
| constructor(invoiceNumber, invoiceDate, invoiceTotal, invoiceReturn, invoicePayout, standardItemInvoice) { | |
| this.invoiceNumber = invoiceNumber; | |
| this.invoiceDate = invoiceDate; | |
| this.invoiceTotal = invoiceTotal; | |
| this.invoiceReturn = invoiceReturn; | |
| this.invoicePayout = invoicePayout; | |
| this.items = standardItemInvoice; | |
| } | |
| } | |
| const useThermal = () => { | |
| const SERVICE_UUID = "000018f0-0000-1000-8000-00805f9b34fb" | |
| const SERVICE_CHARACTERISTIC = "00002af1-0000-1000-8000-00805f9b34fb" | |
| const [isConnect, setIsConnect] = useState(false); | |
| const [characteristic, setCharacteristic] = useState(null); | |
| const [printer, setPrinter] = useState(null); | |
| const escape = '\x1B'; | |
| const newLine = `\x0A`; | |
| const alignCenter = (value) => '\x1b' + value.toString() + '\x01'; | |
| const alignLeft = (value) => '\x1b' + value.toString() + '\x00'; | |
| const alignRight = (value) => '\x1b' + value.toString() + '\x02'; | |
| const reset = () => '\x1B' + '\x61' + '\x31' + '\x1D' + '\x21' + '\x00' + '\n'.repeat(2) + '\r'; | |
| const enter = (count) => '\n'.repeat(count) + '\r'; | |
| const fontNormal = (value) => '\x1D' + '\x21' + '\x00' + value.toString(); | |
| const fontLarge = (value) => '\x1D' + '\x21' + '\x11' + value.toString(); | |
| const centerLine = (count) => '\x1B' + '\x61' + '\x31' + '-'.repeat(count); | |
| const printInvoice = async (standardObjectInvoice) => { | |
| // const text = []; | |
| // text.push(escape + "@"); | |
| // text.push(alignLeft(fontLarge(import.meta.env.VITE_APP_NAME))) | |
| // text.push(enter(1)); | |
| // text.push(centerLine(10)); | |
| // text.push(enter(2)); | |
| // text.push(alignLeft(fontNormal(" "))); | |
| // for (let item of standardObjectInvoice.items) { | |
| // text.push(alignLeft(fontNormal(item.title))); | |
| // text.push(enter(1)); | |
| // text.push(alignLeft(fontNormal(`${item.qty} x ${item.price} @ ${item.subtotal}`))); | |
| // text.push(enter(2)); | |
| // } | |
| // text.push(alignCenter(fontNormal(`Thank You`))); | |
| // text.push(alignLeft("")); | |
| // text.push(enter(3)); | |
| // text.push(reset()); | |
| var esc = '\x1B'; //ESC byte in hex notation | |
| var newLine = '\x0A'; //LF byte in hex notation | |
| var cmds = esc + "@"; //Initializes the printer (ESC @) | |
| cmds += esc + '!' + '\x38'; //Emphasized + Double-height + Double-width mode selected (ESC ! (8 + 16 + 32)) 56 dec => 38 hex | |
| cmds += import.meta.env.VITE_APP_NAME.toUpperCase(); //text to print | |
| cmds += newLine + newLine; | |
| cmds += esc + '!' + '\x00'; //Character font A selected (ESC ! 0) | |
| for (let item of standardObjectInvoice.items) { | |
| cmds += `${item.title} ${item.subtotal}'` | |
| cmds += newLine; | |
| } | |
| cmds += newLine + newLine; | |
| cmds += `TOTAL ${standardObjectInvoice.invoiceTotal}`; | |
| cmds += newLine; | |
| cmds += `DIBAYAR ${standardObjectInvoice.invoicePayout}`; | |
| cmds += newLine; | |
| cmds += `DIBAYAR ${standardObjectInvoice.invoiceReturn}`; | |
| // cmds += newLine; | |
| // cmds += 'TOTAL 9.22'; | |
| // cmds += newLine; | |
| // cmds += 'CASH TEND 10.00'; | |
| // cmds += newLine; | |
| // cmds += 'CASH DUE 0.78'; | |
| cmds += newLine + newLine; | |
| cmds += esc + '!' + '\x18'; //Emphasized + Double-height mode selected (ESC ! (16 + 8)) 24 dec => 18 hex | |
| cmds += '# ITEMS SOLD ' + standardObjectInvoice.items.length.toString(); | |
| cmds += esc + '!' + '\x00'; //Character font A selected (ESC ! 0) | |
| cmds += newLine + newLine; | |
| cmds += standardObjectInvoice.invoiceDate; | |
| cmds += newLine + newLine; | |
| printing(cmds) | |
| } | |
| const printing = async (text) => { | |
| let textEncoder = new TextEncoder("utf-8"); | |
| text = textEncoder.encode(text); | |
| await characteristic.writeValue(text); | |
| } | |
| const disconnect = () => { | |
| printer.gatt.disconnect(); | |
| setPrinter(null); | |
| setCharacteristic(null); | |
| setIsConnect(false) | |
| } | |
| const connect = () => { | |
| navigator.bluetooth.requestDevice({ | |
| filters: [{ | |
| services: [SERVICE_UUID] | |
| }] | |
| }).then((device) => { | |
| setPrinter(device); | |
| return device.gatt.connect(); | |
| }) | |
| .then((server) => server.getPrimaryService(SERVICE_UUID)) | |
| .then((service) => service.getCharacteristic(SERVICE_CHARACTERISTIC)) | |
| .then((chrt) => { | |
| setIsConnect(true); | |
| setCharacteristic(chrt) | |
| }) | |
| .catch(error => { | |
| setIsConnect(false); | |
| setCharacteristic(null) | |
| console.error(error); | |
| }); | |
| } | |
| return {connect, disconnect, isConnect, printInvoice} | |
| } | |
| export default useThermal; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment