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) => ( -
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -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
+© 2026 {title}. All rights reserved
- - Privacy Policy - -+ 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. +
-+ 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. +
+ +{item.text}
++ Link login tetap tersedia di halaman publik ini. Setelah masuk, Anda akan menemukan navigation untuk POS Checkout, produk, stok, penjualan, pembayaran, dan laporan lainnya. +
+| 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))} |
+ Halaman ini membutuhkan izin baca produk serta buat transaksi penjualan. Gunakan akun Admin atau tambahkan izin untuk role kasir. +
++ Satu layar untuk pilih produk, hitung pajak, simpan pembayaran, dan buka print struk otomatis. Cocok untuk admin maupun kasir harian. +
++ Halaman kasir ini siap dipakai begitu ada minimal 1 toko, 1 register, dan 1 produk. CRUD bawaan tetap dipakai untuk setup master data. +
+