diff --git a/backend/src/db/db.config.js b/backend/src/db/db.config.js index 3d72a20..288461b 100644 --- a/backend/src/db/db.config.js +++ b/backend/src/db/db.config.js @@ -1,8 +1,6 @@ - - module.exports = { production: { - dialect: 'postgres', + dialect: process.env.DB_DIALECT || 'postgres', username: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, @@ -12,22 +10,23 @@ module.exports = { seederStorage: 'sequelize', }, development: { - username: 'postgres', - dialect: 'postgres', - password: '', - database: 'db_pos_stok___struk', - host: process.env.DB_HOST || 'localhost', + dialect: process.env.DB_DIALECT || 'mysql', + username: process.env.DB_USER || 'root', + password: process.env.DB_PASS || '', + database: process.env.DB_NAME || 'posstokstruk', + host: process.env.DB_HOST || '127.0.0.1', + port: process.env.DB_PORT || 3306, + logging: console.log, + seederStorage: 'sequelize', + }, + dev_stage: { + dialect: process.env.DB_DIALECT || 'postgres', + username: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + host: process.env.DB_HOST, + port: process.env.DB_PORT, logging: console.log, seederStorage: 'sequelize', }, - dev_stage: { - dialect: 'postgres', - username: process.env.DB_USER, - password: process.env.DB_PASS, - database: process.env.DB_NAME, - host: process.env.DB_HOST, - port: process.env.DB_PORT, - logging: console.log, - seederStorage: 'sequelize', - } }; diff --git a/backend/src/db/models/index.js b/backend/src/db/models/index.js index 4a3852f..14b7aeb 100644 --- a/backend/src/db/models/index.js +++ b/backend/src/db/models/index.js @@ -10,6 +10,11 @@ const db = {}; let sequelize; console.log(env); + +if (config.dialect === 'mysql') { + Sequelize.Op.iLike = Sequelize.Op.like; +} + if (config.use_env_variable) { sequelize = new Sequelize(process.env[config.use_env_variable], config); } else { diff --git a/backend/src/routes/sales.js b/backend/src/routes/sales.js index eeecd2f..769b50f 100644 --- a/backend/src/routes/sales.js +++ b/backend/src/routes/sales.js @@ -2,10 +2,10 @@ const express = require('express'); const SalesService = require('../services/sales'); +const PosCheckoutService = require('../services/posCheckout'); const SalesDBApi = require('../db/api/sales'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); const router = express.Router(); @@ -281,6 +281,16 @@ router.post('/deleteByIds', wrapAsync(async (req, res) => { res.status(200).send(payload); })); +router.get('/pos-context', wrapAsync(async (req, res) => { + const payload = await PosCheckoutService.getContext(req.currentUser, req.query.storeId); + res.status(200).send(payload); +})); + +router.post('/checkout', wrapAsync(async (req, res) => { + const payload = await PosCheckoutService.checkout(req.body, req.currentUser); + res.status(200).send(payload); +})); + /** * @swagger * /api/sales: diff --git a/backend/src/services/posCheckout.js b/backend/src/services/posCheckout.js new file mode 100644 index 0000000..e995b0d --- /dev/null +++ b/backend/src/services/posCheckout.js @@ -0,0 +1,505 @@ +const { Op } = require('sequelize'); +const db = require('../db/models'); +const SalesDBApi = require('../db/api/sales'); + +const PAYMENT_METHODS = new Set([ + 'cash', + 'card', + 'bank_transfer', + 'qris', + 'ewallet', + 'voucher', + 'split', +]); + +const roundCurrency = (value) => Number((Number.parseFloat(String(value || 0)) || 0).toFixed(2)); + +const createBadRequest = (message) => { + const error = new Error(message); + error.code = 400; + return error; +}; + +const buildOrganizationWhere = (currentUser) => ( + currentUser?.organizationsId + ? { organizationsId: currentUser.organizationsId } + : {} +); + +const stockLiteral = db.sequelize.literal(` + SUM( + CASE + WHEN "movement_type" IN ('purchase_in', 'adjustment_in', 'transfer_in', 'return_in') + THEN COALESCE("quantity", 0) + ELSE -COALESCE("quantity", 0) + END + ) +`); + +module.exports = class PosCheckoutService { + static async getStockByProductIds(productIds, storeId, currentUser, transaction) { + if (!productIds.length) { + return {}; + } + + const rows = await db.stock_movements.findAll({ + attributes: ['productId', [stockLiteral, 'stockOnHand']], + where: { + ...buildOrganizationWhere(currentUser), + ...(storeId ? { storeId } : {}), + productId: { + [Op.in]: productIds, + }, + }, + group: ['productId'], + raw: true, + transaction, + }); + + return rows.reduce((accumulator, row) => { + accumulator[row.productId] = roundCurrency(row.stockOnHand); + return accumulator; + }, {}); + } + + static generateReceiptNumber(date = new Date()) { + const pad = (value) => String(value).padStart(2, '0'); + const stamp = [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + '-', + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()), + ].join(''); + + const suffix = Math.floor(Math.random() * 900) + 100; + + return `POS-${stamp}-${suffix}`; + } + + static async getContext(currentUser, requestedStoreId) { + const organizationWhere = buildOrganizationWhere(currentUser); + + const [stores, registers, customers, productsRaw] = await Promise.all([ + db.stores.findAll({ + attributes: ['id', 'name', 'code', 'city', 'receipt_header', 'receipt_footer', 'currency_code', 'is_active'], + where: organizationWhere, + order: [ + ['is_active', 'DESC'], + ['name', 'ASC'], + ], + }), + db.registers.findAll({ + attributes: ['id', 'name', 'code', 'printer_name', 'printer_type', 'auto_print_receipt', 'storeId'], + where: organizationWhere, + order: [['name', 'ASC']], + }), + db.customers.findAll({ + attributes: ['id', 'name', 'phone'], + where: organizationWhere, + order: [['name', 'ASC']], + limit: 100, + }), + db.products.findAll({ + attributes: ['id', 'name', 'sku', 'barcode', 'sell_price', 'tax_rate', 'track_stock', 'is_active'], + where: organizationWhere, + include: [ + { + model: db.product_categories, + as: 'category', + attributes: ['id', 'name'], + required: false, + }, + ], + order: [ + ['is_active', 'DESC'], + ['name', 'ASC'], + ], + }), + ]); + + const activeStoreId = requestedStoreId || stores[0]?.id || null; + const stockByProductId = await PosCheckoutService.getStockByProductIds( + productsRaw.map((product) => product.id), + activeStoreId, + currentUser, + ); + + const recentSalesRaw = await db.sales.findAll({ + attributes: ['id', 'receipt_number', 'sold_at', 'total_amount', 'payment_status', 'createdAt'], + where: { + ...organizationWhere, + ...(activeStoreId ? { storeId: activeStoreId } : {}), + status: 'completed', + }, + include: [ + { + model: db.customers, + as: 'customer', + attributes: ['id', 'name'], + required: false, + }, + { + model: db.users, + as: 'cashier', + attributes: ['id', 'firstName', 'lastName'], + required: false, + }, + ], + order: [['sold_at', 'DESC']], + limit: 8, + }); + + const recentSaleIds = recentSalesRaw.map((sale) => sale.id); + const itemCountRows = recentSaleIds.length + ? await db.sale_items.findAll({ + attributes: [ + 'saleId', + [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'itemCount'], + [db.sequelize.fn('SUM', db.sequelize.col('quantity')), 'quantityTotal'], + ], + where: { + saleId: { + [Op.in]: recentSaleIds, + }, + }, + group: ['saleId'], + raw: true, + }) + : []; + + const itemCountMap = itemCountRows.reduce((accumulator, row) => { + accumulator[row.saleId] = { + itemCount: Number(row.itemCount) || 0, + quantityTotal: Number(row.quantityTotal) || 0, + }; + return accumulator; + }, {}); + + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(); + endOfDay.setHours(23, 59, 59, 999); + + const [todaySalesCount, todayRevenue] = await Promise.all([ + db.sales.count({ + where: { + ...organizationWhere, + ...(activeStoreId ? { storeId: activeStoreId } : {}), + status: 'completed', + sold_at: { + [Op.between]: [startOfDay, endOfDay], + }, + }, + }), + db.sales.sum('total_amount', { + where: { + ...organizationWhere, + ...(activeStoreId ? { storeId: activeStoreId } : {}), + status: 'completed', + sold_at: { + [Op.between]: [startOfDay, endOfDay], + }, + }, + }), + ]); + + return { + activeStoreId, + stores, + registers, + customers, + products: productsRaw.map((product) => ({ + id: product.id, + name: product.name, + sku: product.sku, + barcode: product.barcode, + sell_price: roundCurrency(product.sell_price), + tax_rate: roundCurrency(product.tax_rate), + track_stock: Boolean(product.track_stock), + is_active: Boolean(product.is_active), + stockOnHand: product.track_stock ? roundCurrency(stockByProductId[product.id]) : null, + category: product.category, + })), + recentSales: recentSalesRaw.map((sale) => ({ + id: sale.id, + receipt_number: sale.receipt_number, + sold_at: sale.sold_at, + total_amount: roundCurrency(sale.total_amount), + payment_status: sale.payment_status, + customer: sale.customer, + cashier: sale.cashier, + itemCount: itemCountMap[sale.id]?.itemCount || 0, + quantityTotal: itemCountMap[sale.id]?.quantityTotal || 0, + })), + summary: { + todaySalesCount, + todayRevenue: roundCurrency(todayRevenue), + }, + }; + } + + static async checkout(payload, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const items = Array.isArray(payload?.items) ? payload.items : []; + const storeId = payload?.storeId; + const registerId = payload?.registerId; + const customerId = payload?.customerId || null; + const paymentMethod = payload?.paymentMethod || 'cash'; + const paidAmount = roundCurrency(payload?.paidAmount); + const printReceipt = payload?.printReceipt !== false; + const notes = payload?.notes || null; + + if (!storeId) { + throw createBadRequest('Pilih toko terlebih dahulu.'); + } + + if (!registerId) { + throw createBadRequest('Pilih register kasir terlebih dahulu.'); + } + + if (!items.length) { + throw createBadRequest('Keranjang masih kosong. Tambahkan minimal satu produk.'); + } + + if (!PAYMENT_METHODS.has(paymentMethod)) { + throw createBadRequest('Metode pembayaran tidak valid.'); + } + + const organizationWhere = buildOrganizationWhere(currentUser); + + const [store, register, customer] = await Promise.all([ + db.stores.findOne({ + where: { + id: storeId, + ...organizationWhere, + }, + transaction, + }), + db.registers.findOne({ + where: { + id: registerId, + storeId, + ...organizationWhere, + }, + transaction, + }), + customerId + ? db.customers.findOne({ + where: { + id: customerId, + ...organizationWhere, + }, + transaction, + }) + : Promise.resolve(null), + ]); + + if (!store) { + throw createBadRequest('Toko tidak ditemukan atau tidak bisa diakses.'); + } + + if (!register) { + throw createBadRequest('Register tidak ditemukan untuk toko yang dipilih.'); + } + + if (customerId && !customer) { + throw createBadRequest('Customer yang dipilih tidak ditemukan.'); + } + + const normalizedItems = Object.values( + items.reduce((accumulator, item) => { + const productId = item?.productId; + const quantity = Number.parseInt(String(item?.quantity || 0), 10); + + if (!productId || !Number.isFinite(quantity) || quantity <= 0) { + return accumulator; + } + + if (!accumulator[productId]) { + accumulator[productId] = { productId, quantity: 0 }; + } + + accumulator[productId].quantity += quantity; + return accumulator; + }, {}) + ); + + if (!normalizedItems.length) { + throw createBadRequest('Jumlah item tidak valid.'); + } + + const productIds = normalizedItems.map((item) => item.productId); + const products = await db.products.findAll({ + where: { + id: { + [Op.in]: productIds, + }, + ...organizationWhere, + }, + transaction, + }); + + if (products.length !== productIds.length) { + throw createBadRequest('Beberapa produk pada keranjang tidak ditemukan.'); + } + + const productMap = products.reduce((accumulator, product) => { + accumulator[product.id] = product; + return accumulator; + }, {}); + + const stockByProductId = await PosCheckoutService.getStockByProductIds( + productIds, + storeId, + currentUser, + transaction, + ); + + let subtotal = 0; + let taxAmount = 0; + const soldAt = new Date(); + const receiptNumber = PosCheckoutService.generateReceiptNumber(soldAt); + + const saleItemsPayload = normalizedItems.map((item) => { + const product = productMap[item.productId]; + const unitPrice = roundCurrency(product.sell_price); + const itemTaxRate = roundCurrency(product.tax_rate); + const lineSubtotal = roundCurrency(unitPrice * item.quantity); + const lineTax = roundCurrency((lineSubtotal * itemTaxRate) / 100); + const lineTotal = roundCurrency(lineSubtotal + lineTax); + + if (product.track_stock) { + const availableStock = Number(stockByProductId[product.id] || 0); + + if (item.quantity > availableStock) { + throw createBadRequest(`Stok ${product.name} tidak cukup. Tersedia ${availableStock}.`); + } + + stockByProductId[product.id] = availableStock - item.quantity; + } + + subtotal = roundCurrency(subtotal + lineSubtotal); + taxAmount = roundCurrency(taxAmount + lineTax); + + return { + productId: product.id, + product_name_snapshot: product.name, + sku_snapshot: product.sku, + unit_price: unitPrice, + quantity: item.quantity, + discount_amount: 0, + tax_amount: lineTax, + line_total: lineTotal, + }; + }); + + const totalAmount = roundCurrency(subtotal + taxAmount); + + if (paidAmount < totalAmount) { + throw createBadRequest('Nominal bayar kurang dari total belanja.'); + } + + const changeAmount = roundCurrency(paidAmount - totalAmount); + + const sale = await db.sales.create( + { + receipt_number: receiptNumber, + status: 'completed', + sold_at: soldAt, + subtotal_amount: subtotal, + discount_amount: 0, + tax_amount: taxAmount, + total_amount: totalAmount, + paid_amount: paidAmount, + change_amount: changeAmount, + payment_status: 'paid', + channel: 'in_store', + auto_printed: printReceipt, + printed_at: printReceipt ? soldAt : null, + notes, + storeId: store.id, + registerId: register.id, + cashierId: currentUser.id, + customerId: customer?.id || null, + organizationsId: currentUser.organizationsId || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await Promise.all( + saleItemsPayload.map((item) => db.sale_items.create( + { + ...item, + saleId: sale.id, + organizationsId: currentUser.organizationsId || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + )), + ); + + await db.payments.create( + { + paid_at: soldAt, + method: paymentMethod, + amount: paidAmount, + status: 'settled', + notes: `Pembayaran ${receiptNumber}`, + saleId: sale.id, + received_byId: currentUser.id, + organizationsId: currentUser.organizationsId || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + const stockMovementPayload = saleItemsPayload + .filter((item) => productMap[item.productId]?.track_stock) + .map((item) => { + const product = productMap[item.productId]; + + return db.stock_movements.create( + { + movement_type: 'sale_out', + quantity: item.quantity, + unit_cost: roundCurrency(product.cost_price), + reference_number: receiptNumber, + movement_at: soldAt, + notes: `POS checkout ${receiptNumber}`, + storeId: store.id, + productId: product.id, + performed_byId: currentUser.id, + organizationsId: currentUser.organizationsId || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + }); + + await Promise.all(stockMovementPayload); + + const payloadResult = await SalesDBApi.findBy( + { id: sale.id }, + { transaction }, + ); + + await transaction.commit(); + + return payloadResult; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/search.js b/backend/src/services/search.js index 18a18c5..62ab805 100644 --- a/backend/src/services/search.js +++ b/backend/src/services/search.js @@ -3,6 +3,7 @@ const ValidationError = require('./notifications/errors/validation'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; +const textCastType = db.sequelize.getDialect() === 'mysql' ? 'char' : 'varchar'; /** * @param {string} permission @@ -476,7 +477,7 @@ module.exports = class SearchService { })), ...attributesIntToSearch.map(attribute => ( Sequelize.where( - Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), 'varchar'), + Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), textCastType), { [Op.iLike]: `%${searchQuery}%` } ) )), diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 628b33c..28174b8 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index fb94721..636635d 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -8,6 +8,15 @@ const menuAside: MenuAsideItem[] = [ label: 'Dashboard', }, + { + href: '/pos/checkout', + label: 'POS checkout', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiCashRegister' in icon ? icon['mdiCashRegister' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + permissions: ['READ_SALES', 'CREATE_SALES'] + }, + { href: '/users/users-list', label: 'Users', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index d1d4049..163a329 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,165 @@ - -import React, { useEffect, useState } from 'react'; +import React from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; import Link from 'next/link'; import BaseButton from '../components/BaseButton'; import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const highlights = [ + { + title: 'Checkout cepat + struk otomatis', + text: 'Kasir cukup pilih produk, simpan pembayaran, lalu browser print dialog terbuka untuk cetak struk.', + }, + { + title: 'Kelola produk & stok', + text: 'Master produk, kategori, supplier, lokasi stok, dan mutasi inventori sudah siap untuk workflow toko sehari-hari.', + }, + { + title: 'Laporan & multi-user', + text: 'Admin dan kasir bisa bekerja pada panel yang sama, dengan role/permission bawaan untuk operasional yang rapi.', + }, +]; + +const journey = [ + 'Admin menyiapkan toko, register, produk, dan stok awal.', + 'Kasir membuka halaman POS Checkout untuk transaksi harian.', + 'Penjualan tersimpan, stok berkurang, struk siap dicetak, dan riwayat penjualan langsung ter-update.', +]; export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'POS Stok & Struk' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('POS Stok & Struk')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+
+
POS Stok & Struk
+
Aplikasi kasir modern untuk admin & kasir toko.
- - - +
+ + +
+
- - -
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+
+
+ First MVP slice siap dipakai +
+

+ Jual lebih cepat, stok lebih rapi, laporan lebih jelas. +

+

+ Built untuk kebutuhan toko yang ingin proses transaksi penjualan, print struk otomatis via browser, dan tetap punya panel admin yang nyaman untuk mengelola produk, stok, dan laporan. +

-
+
+ + +
+ +
+
+
Workflow
+
POS Checkout
+
Create → simpan → print → review struk di satu alur tipis yang usable.
+
+
+
Master data
+
Produk & stok
+
CRUD bawaan tetap dipakai untuk setup produk, stok, supplier, dan register.
+
+
+
Akses
+
Admin & kasir
+
Role/permission bawaan memudahkan pembagian tugas operasional toko.
+
+
+
+ + +
+
What users get first
+
POS Checkout + Receipt Center
+

+ Halaman checkout khusus kasir akan menjadi pintu masuk transaksi harian: pilih toko, pilih register, cari produk, simpan pembayaran, cetak struk, lalu cek riwayat receipt tanpa pindah-pindah layar. +

+ +
+ {journey.map((step, index) => ( +
+
Langkah {index + 1}
+
{step}
+
+ ))} +
+
+
+
+ + +
+
+
+
Kenapa cocok
+

POS admin panel yang terasa siap jalan, bukan cuma template kosong.

+
+
Kita fokus pada workflow nyata dulu, lalu iterasi ke laporan dan operasional lanjutan.
+
+ +
+ {highlights.map((item) => ( +
+

{item.title}

+

{item.text}

+
+ ))} +
+
+ +
+
+
+
+
Admin access
+

Masuk ke panel admin untuk lanjut setup dan transaksi.

+

+ Link login tetap tersedia di halaman publik ini. Setelah masuk, Anda akan menemukan navigation untuk POS Checkout, produk, stok, penjualan, pembayaran, dan laporan lainnya. +

+
+
+ + +
+
+
+
+ +
+
+
© 2026 POS Stok & Struk. Built for fast retail operations.
+
+ + Privacy Policy + + + Login Admin + +
+
+
+ + ); } Starter.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/pos/checkout.tsx b/frontend/src/pages/pos/checkout.tsx new file mode 100644 index 0000000..349d526 --- /dev/null +++ b/frontend/src/pages/pos/checkout.tsx @@ -0,0 +1,1102 @@ +import * as icon from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import BaseButton from '../../components/BaseButton'; +import BaseButtons from '../../components/BaseButtons'; +import BaseDivider from '../../components/BaseDivider'; +import BaseIcon from '../../components/BaseIcon'; +import CardBox from '../../components/CardBox'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import { hasPermission } from '../../helpers/userPermissions'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { useAppSelector } from '../../stores/hooks'; + +type PosProduct = { + id: string; + name: string; + sku?: string; + barcode?: string; + sell_price: number; + tax_rate: number; + track_stock: boolean; + is_active: boolean; + stockOnHand: number | null; + category?: { + id: string; + name: string; + } | null; +}; + +type PosStore = { + id: string; + name: string; + code?: string; +}; + +type PosRegister = { + id: string; + name: string; + code?: string; + printer_name?: string; + printer_type?: string; + auto_print_receipt?: boolean; + storeId?: string; +}; + +type PosCustomer = { + id: string; + name: string; + phone?: string; +}; + +type CartItem = { + productId: string; + name: string; + sku?: string; + unitPrice: number; + taxRate: number; + quantity: number; + trackStock: boolean; + stockOnHand: number | null; +}; + +type SaleSummary = { + id: string; + receipt_number: string; + sold_at: string; + total_amount: number; + payment_status: string; + itemCount: number; + quantityTotal: number; + customer?: { + name?: string; + } | null; + cashier?: { + firstName?: string; + lastName?: string; + } | null; +}; + +type SaleDetail = { + id: string; + receipt_number: string; + sold_at: string; + subtotal_amount: number; + tax_amount: number; + total_amount: number; + paid_amount: number; + change_amount: number; + notes?: string; + customer?: { + name?: string; + } | null; + cashier?: { + firstName?: string; + lastName?: string; + } | null; + store?: { + name?: string; + receipt_header?: string; + receipt_footer?: string; + currency_code?: string; + } | null; + register?: { + name?: string; + printer_type?: string; + } | null; + sale_items_sale?: Array<{ + id: string; + product_name_snapshot: string; + sku_snapshot?: string; + quantity: number; + unit_price: number; + line_total: number; + }>; + payments_sale?: Array<{ + id: string; + method: string; + amount: number; + status: string; + }>; +}; + +type PosContext = { + activeStoreId: string | null; + stores: PosStore[]; + registers: PosRegister[]; + customers: PosCustomer[]; + products: PosProduct[]; + recentSales: SaleSummary[]; + summary: { + todaySalesCount: number; + todayRevenue: number; + }; +}; + +const formatCurrency = (value: number) => + new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + maximumFractionDigits: 0, + }).format(Number(value || 0)); + +const formatDateTime = (value?: string) => { + if (!value) { + return '-'; + } + + return new Intl.DateTimeFormat('id-ID', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +}; + +const getCashierName = (cashier?: { firstName?: string; lastName?: string } | null) => + `${cashier?.firstName || ''} ${cashier?.lastName || ''}`.trim() || 'Kasir'; + +const paymentMethodLabels: Record = { + cash: 'Tunai', + card: 'Kartu', + bank_transfer: 'Transfer', + qris: 'QRIS', + ewallet: 'E-Wallet', + voucher: 'Voucher', + split: 'Split Bill', +}; + +const cashRegisterIcon = + 'mdiCashRegister' in icon ? icon['mdiCashRegister' as keyof typeof icon] : icon.mdiTable; +const productIcon = + 'mdiPackageVariantClosed' in icon + ? icon['mdiPackageVariantClosed' as keyof typeof icon] + : icon.mdiTable; +const receiptIcon = + 'mdiReceiptText' in icon ? icon['mdiReceiptText' as keyof typeof icon] : icon.mdiTable; +const searchIcon = + 'mdiMagnify' in icon ? icon['mdiMagnify' as keyof typeof icon] : icon.mdiTable; +const printerIcon = + 'mdiPrinter' in icon ? icon['mdiPrinter' as keyof typeof icon] : receiptIcon; +const alertIcon = + 'mdiAlertCircleOutline' in icon + ? icon['mdiAlertCircleOutline' as keyof typeof icon] + : icon.mdiTable; +const storeIcon = + 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable; +const customersIcon = + 'mdiAccountBox' in icon ? icon['mdiAccountBox' as keyof typeof icon] : icon.mdiTable; + +const PosCheckoutPage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + + const [context, setContext] = useState(null); + const [selectedStoreId, setSelectedStoreId] = useState(''); + const [selectedRegisterId, setSelectedRegisterId] = useState(''); + const [selectedCustomerId, setSelectedCustomerId] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [paymentMethod, setPaymentMethod] = useState('cash'); + const [paidAmount, setPaidAmount] = useState('0'); + const [notes, setNotes] = useState(''); + const [printReceipt, setPrintReceipt] = useState(true); + const [cart, setCart] = useState([]); + const [selectedReceipt, setSelectedReceipt] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [loadingReceiptId, setLoadingReceiptId] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + + const canUsePos = hasPermission(currentUser, ['READ_SALES', 'CREATE_SALES']) && hasPermission(currentUser, 'READ_PRODUCTS'); + + const loadContext = async (storeId?: string, options?: { keepSelection?: boolean }) => { + try { + setLoading(true); + const query = storeId ? `?storeId=${storeId}` : ''; + const response = await axios.get(`sales/pos-context${query}`); + const nextContext = response.data as PosContext; + + setContext(nextContext); + + const nextStoreId = storeId || nextContext.activeStoreId || nextContext.stores[0]?.id || ''; + setSelectedStoreId(nextStoreId); + + setSelectedRegisterId((currentRegisterId) => { + const matchingCurrent = nextContext.registers.find( + (register) => register.id === currentRegisterId && register.storeId === nextStoreId, + ); + + if (matchingCurrent) { + return currentRegisterId; + } + + return nextContext.registers.find((register) => register.storeId === nextStoreId)?.id || ''; + }); + + if (!options?.keepSelection) { + setSelectedCustomerId(''); + } + } catch (error: any) { + console.error('Failed to load POS context', error); + setErrorMessage(error?.response?.data || 'Gagal memuat data kasir.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (canUsePos) { + loadContext(); + } else { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canUsePos]); + + useEffect(() => { + if (!context) { + return; + } + + setCart((currentCart) => + currentCart.map((item) => { + const freshProduct = context.products.find((product) => product.id === item.productId); + + if (!freshProduct) { + return item; + } + + return { + ...item, + stockOnHand: freshProduct.stockOnHand, + unitPrice: Number(freshProduct.sell_price || item.unitPrice), + taxRate: Number(freshProduct.tax_rate || item.taxRate), + }; + }), + ); + }, [context]); + + const filteredRegisters = useMemo( + () => (context?.registers || []).filter((register) => !selectedStoreId || register.storeId === selectedStoreId), + [context?.registers, selectedStoreId], + ); + + useEffect(() => { + if (!filteredRegisters.length) { + setSelectedRegisterId(''); + return; + } + + const registerStillValid = filteredRegisters.some((register) => register.id === selectedRegisterId); + if (!registerStillValid) { + setSelectedRegisterId(filteredRegisters[0].id); + } + }, [filteredRegisters, selectedRegisterId]); + + const filteredProducts = useMemo(() => { + const normalizedSearch = searchTerm.trim().toLowerCase(); + + return (context?.products || []).filter((product) => { + if (!normalizedSearch) { + return true; + } + + return [product.name, product.sku, product.barcode, product.category?.name] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(normalizedSearch)); + }); + }, [context?.products, searchTerm]); + + const cartSummary = useMemo(() => { + const summary = cart.reduce( + (accumulator, item) => { + const lineSubtotal = item.unitPrice * item.quantity; + const lineTax = (lineSubtotal * item.taxRate) / 100; + const lineTotal = lineSubtotal + lineTax; + + accumulator.subtotal += lineSubtotal; + accumulator.tax += lineTax; + accumulator.total += lineTotal; + accumulator.quantity += item.quantity; + + return accumulator; + }, + { + subtotal: 0, + tax: 0, + total: 0, + quantity: 0, + }, + ); + + return { + subtotal: Number(summary.subtotal.toFixed(2)), + tax: Number(summary.tax.toFixed(2)), + total: Number(summary.total.toFixed(2)), + quantity: summary.quantity, + }; + }, [cart]); + + useEffect(() => { + setPaidAmount((currentValue) => { + if (!cart.length) { + return '0'; + } + + const numericValue = Number(currentValue || 0); + if (!numericValue || numericValue < cartSummary.total) { + return String(Number(cartSummary.total.toFixed(2))); + } + + return currentValue; + }); + }, [cart, cartSummary.total]); + + const addProductToCart = (product: PosProduct) => { + setSuccessMessage(''); + setErrorMessage(''); + + setCart((currentCart) => { + const existingItem = currentCart.find((item) => item.productId === product.id); + const nextQuantity = (existingItem?.quantity || 0) + 1; + + if (product.track_stock && product.stockOnHand !== null && nextQuantity > product.stockOnHand) { + setErrorMessage(`Stok ${product.name} tidak cukup untuk ditambahkan.`); + return currentCart; + } + + if (existingItem) { + return currentCart.map((item) => + item.productId === product.id + ? { + ...item, + quantity: nextQuantity, + } + : item, + ); + } + + return [ + ...currentCart, + { + productId: product.id, + name: product.name, + sku: product.sku, + quantity: 1, + unitPrice: Number(product.sell_price || 0), + taxRate: Number(product.tax_rate || 0), + trackStock: product.track_stock, + stockOnHand: product.stockOnHand, + }, + ]; + }); + }; + + const updateCartQuantity = (productId: string, quantity: number) => { + setCart((currentCart) => + currentCart + .map((item) => { + if (item.productId !== productId) { + return item; + } + + const maxQuantity = item.trackStock && item.stockOnHand !== null ? Number(item.stockOnHand) : Number.MAX_SAFE_INTEGER; + const safeQuantity = Math.min(Math.max(quantity, 1), maxQuantity); + + return { + ...item, + quantity: safeQuantity, + }; + }) + .filter((item) => item.quantity > 0), + ); + }; + + const removeCartItem = (productId: string) => { + setCart((currentCart) => currentCart.filter((item) => item.productId !== productId)); + }; + + const openPrintWindow = (sale: SaleDetail) => { + const receiptWindow = window.open('', '_blank', 'width=420,height=720'); + + if (!receiptWindow) { + setErrorMessage('Popup printer terblokir browser. Silakan izinkan popup lalu coba print ulang.'); + return; + } + + const receiptLines = (sale.sale_items_sale || []) + .map( + (item) => ` + + ${item.product_name_snapshot}
${item.quantity} x ${formatCurrency(Number(item.unit_price || 0))} + ${formatCurrency(Number(item.line_total || 0))} + + `, + ) + .join(''); + + const paymentLine = sale.payments_sale?.[0]; + + receiptWindow.document.write(` + + + ${sale.receipt_number} + + + +
+
${sale.store?.name || 'POS Checkout'}
+ ${sale.store?.receipt_header ? `
${sale.store.receipt_header}
` : ''} +
${sale.receipt_number}
+
${formatDateTime(sale.sold_at)}
+
+
+
Kasir: ${getCashierName(sale.cashier)}
+
Register: ${sale.register?.name || '-'}
+
+ ${sale.customer?.name ? `
Pelanggan: ${sale.customer.name}
` : ''} +
+ + + ${receiptLines} + +
+
+ + + + + + + + +
Subtotal${formatCurrency(Number(sale.subtotal_amount || 0))}
Pajak${formatCurrency(Number(sale.tax_amount || 0))}
Total${formatCurrency(Number(sale.total_amount || 0))}
Bayar (${paymentMethodLabels[paymentLine?.method || 'cash'] || paymentLine?.method || 'Tunai'})${formatCurrency(Number(sale.paid_amount || paymentLine?.amount || 0))}
Kembalian${formatCurrency(Number(sale.change_amount || 0))}
+ ${sale.notes ? `
Catatan: ${sale.notes}
` : ''} + ${sale.store?.receipt_footer ? `
${sale.store.receipt_footer}
` : ''} + + + + `); + receiptWindow.document.close(); + }; + + const handleCheckout = async () => { + if (!selectedStoreId || !selectedRegisterId) { + setErrorMessage('Pilih toko dan register sebelum menyimpan transaksi.'); + return; + } + + if (!cart.length) { + setErrorMessage('Keranjang masih kosong.'); + return; + } + + try { + setSubmitting(true); + setErrorMessage(''); + setSuccessMessage(''); + + const response = await axios.post('sales/checkout', { + storeId: selectedStoreId, + registerId: selectedRegisterId, + customerId: selectedCustomerId || null, + paymentMethod, + paidAmount: Number(paidAmount || 0), + notes, + printReceipt, + items: cart.map((item) => ({ + productId: item.productId, + quantity: item.quantity, + })), + }); + + const sale = response.data as SaleDetail; + setSelectedReceipt(sale); + setSuccessMessage(`Transaksi ${sale.receipt_number} berhasil disimpan.`); + setCart([]); + setNotes(''); + setSelectedCustomerId(''); + await loadContext(selectedStoreId, { keepSelection: true }); + + if (printReceipt) { + openPrintWindow(sale); + } + } catch (error: any) { + console.error('Checkout failed', error); + setErrorMessage(error?.response?.data || 'Transaksi gagal diproses.'); + } finally { + setSubmitting(false); + } + }; + + const loadReceiptDetail = async (saleId: string) => { + try { + setLoadingReceiptId(saleId); + const response = await axios.get(`sales/${saleId}`); + setSelectedReceipt(response.data as SaleDetail); + } catch (error: any) { + console.error('Failed to load receipt detail', error); + setErrorMessage(error?.response?.data || 'Gagal memuat detail struk.'); + } finally { + setLoadingReceiptId(''); + } + }; + + const setupIncomplete = Boolean(context) && (!context.stores.length || !context.registers.length || !context.products.length); + + return ( + <> + + {getPageTitle('POS Checkout')} + + + + + + + + + + + {!canUsePos && ( + +
+ +
+

Akses POS belum tersedia

+

+ Halaman ini membutuhkan izin baca produk serta buat transaksi penjualan. Gunakan akun Admin atau tambahkan izin untuk role kasir. +

+
+
+
+ )} + + {canUsePos && ( + <> +
+
+
+
+
+ MVP kasir • print dialog browser +
+

Transaksi cepat, stok langsung berkurang, struk siap dicetak.

+

+ Satu layar untuk pilih produk, hitung pajak, simpan pembayaran, dan buka print struk otomatis. Cocok untuk admin maupun kasir harian. +

+
+ +
+
+
Penjualan hari ini
+
{context?.summary.todaySalesCount || 0}
+
transaksi selesai
+
+
+
Omzet hari ini
+
{formatCurrency(context?.summary.todayRevenue || 0)}
+
berdasarkan toko aktif
+
+
+
+
+ + +
+ + + + +
+
Mode cetak
+
+ Checkout akan membuka browser print dialog sebagai struk pertama. +
+
+ + {filteredRegisters.find((register) => register.id === selectedRegisterId)?.printer_type || 'browser_print'} +
+
+
+
+
+ + {(errorMessage || successMessage) && ( +
+ {errorMessage && ( +
+ {errorMessage} +
+ )} + {successMessage && ( +
+ {successMessage} +
+ )} +
+ )} + + {loading && ( + +
Memuat katalog kasir...
+
+ )} + + {!loading && setupIncomplete && ( + +
+
+
+ Setup dibutuhkan +
+

Sebelum transaksi pertama, lengkapi data toko POS Anda.

+

+ Halaman kasir ini siap dipakai begitu ada minimal 1 toko, 1 register, dan 1 produk. CRUD bawaan tetap dipakai untuk setup master data. +

+
+
+ {!context?.stores.length && } + {!context?.registers.length && } + {!context?.products.length && } +
+
+
+ )} + + {!loading && !setupIncomplete && context && ( +
+
+ +
+
+
Katalog produk
+

Pilih item untuk checkout

+
+ +
+ + + + {filteredProducts.length === 0 ? ( +
+ Tidak ada produk yang cocok dengan pencarian. +
+ ) : ( +
+ {filteredProducts.map((product) => { + const cartItem = cart.find((item) => item.productId === product.id); + const stockLabel = product.track_stock + ? `${product.stockOnHand || 0} tersisa` + : 'Non-stok'; + const lowStock = product.track_stock && Number(product.stockOnHand || 0) <= 3; + + return ( +
+
+
+ +
+
+ {product.category?.name && ( + {product.category.name} + )} + {!product.is_active && ( + Nonaktif + )} + {lowStock && ( + Low stock + )} +
+
+ +
+

{product.name}

+
{product.sku || product.barcode || 'Tanpa SKU'}
+
+ +
+
+
{formatCurrency(product.sell_price)}
+
{stockLabel}
+
+ addProductToCart(product)} + /> +
+
+ ); + })} +
+ )} +
+ + +
+
+
Receipt center
+

Transaksi terbaru dari toko ini

+
+
Klik salah satu struk untuk melihat detail dan print ulang.
+
+ + + + {context.recentSales.length === 0 ? ( +
+ Belum ada transaksi hari ini. Checkout pertama akan langsung muncul di sini. +
+ ) : ( +
+ {context.recentSales.map((sale) => ( + + ))} +
+ )} +
+
+ +
+ +
+
+
Keranjang
+

Checkout aktif

+
+
+
Item
+
{cartSummary.quantity}
+
+
+ + + +
+ + + +
+ + + + {cart.length === 0 ? ( +
+ Produk yang dipilih akan muncul di sini. Gunakan pencarian di kiri untuk transaksi yang lebih cepat. +
+ ) : ( +
+ {cart.map((item) => ( +
+
+
+
{item.name}
+
{item.sku || 'Tanpa SKU'} • {formatCurrency(item.unitPrice)}
+
+ +
+
+
+ + updateCartQuantity(item.productId, Number(event.target.value || 1))} + /> + +
+
+
Subtotal baris
+
+ {formatCurrency(item.quantity * item.unitPrice * (1 + item.taxRate / 100))} +
+
+
+ {item.trackStock && item.stockOnHand !== null && ( +
Stok tersedia: {item.stockOnHand}
+ )} +
+ ))} +
+ )} + + + +
+
+ Subtotal + {formatCurrency(cartSummary.subtotal)} +
+
+ Pajak + {formatCurrency(cartSummary.tax)} +
+
+ Total + {formatCurrency(cartSummary.total)} +
+
+ +
+ + +