cct
This commit is contained in:
parent
a7dbcbf2d0
commit
91068a8627
@ -1,8 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
production: {
|
production: {
|
||||||
dialect: 'postgres',
|
dialect: process.env.DB_DIALECT || 'postgres',
|
||||||
username: process.env.DB_USER,
|
username: process.env.DB_USER,
|
||||||
password: process.env.DB_PASS,
|
password: process.env.DB_PASS,
|
||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
@ -12,22 +10,23 @@ module.exports = {
|
|||||||
seederStorage: 'sequelize',
|
seederStorage: 'sequelize',
|
||||||
},
|
},
|
||||||
development: {
|
development: {
|
||||||
username: 'postgres',
|
dialect: process.env.DB_DIALECT || 'mysql',
|
||||||
dialect: 'postgres',
|
username: process.env.DB_USER || 'root',
|
||||||
password: '',
|
password: process.env.DB_PASS || '',
|
||||||
database: 'db_pos_stok___struk',
|
database: process.env.DB_NAME || 'posstokstruk',
|
||||||
host: process.env.DB_HOST || 'localhost',
|
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,
|
logging: console.log,
|
||||||
seederStorage: 'sequelize',
|
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',
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,11 @@ const db = {};
|
|||||||
|
|
||||||
let sequelize;
|
let sequelize;
|
||||||
console.log(env);
|
console.log(env);
|
||||||
|
|
||||||
|
if (config.dialect === 'mysql') {
|
||||||
|
Sequelize.Op.iLike = Sequelize.Op.like;
|
||||||
|
}
|
||||||
|
|
||||||
if (config.use_env_variable) {
|
if (config.use_env_variable) {
|
||||||
sequelize = new Sequelize(process.env[config.use_env_variable], config);
|
sequelize = new Sequelize(process.env[config.use_env_variable], config);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -2,10 +2,10 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|
||||||
const SalesService = require('../services/sales');
|
const SalesService = require('../services/sales');
|
||||||
|
const PosCheckoutService = require('../services/posCheckout');
|
||||||
const SalesDBApi = require('../db/api/sales');
|
const SalesDBApi = require('../db/api/sales');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@ -281,6 +281,16 @@ router.post('/deleteByIds', wrapAsync(async (req, res) => {
|
|||||||
res.status(200).send(payload);
|
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
|
* @swagger
|
||||||
* /api/sales:
|
* /api/sales:
|
||||||
|
|||||||
505
backend/src/services/posCheckout.js
Normal file
505
backend/src/services/posCheckout.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -3,6 +3,7 @@ const ValidationError = require('./notifications/errors/validation');
|
|||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
|
const textCastType = db.sequelize.getDialect() === 'mysql' ? 'char' : 'varchar';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} permission
|
* @param {string} permission
|
||||||
@ -476,7 +477,7 @@ module.exports = class SearchService {
|
|||||||
})),
|
})),
|
||||||
...attributesIntToSearch.map(attribute => (
|
...attributesIntToSearch.map(attribute => (
|
||||||
Sequelize.where(
|
Sequelize.where(
|
||||||
Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), 'varchar'),
|
Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), textCastType),
|
||||||
{ [Op.iLike]: `%${searchQuery}%` }
|
{ [Op.iLike]: `%${searchQuery}%` }
|
||||||
)
|
)
|
||||||
)),
|
)),
|
||||||
|
|||||||
@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js'
|
|||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -8,6 +8,15 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
label: 'Dashboard',
|
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',
|
href: '/users/users-list',
|
||||||
label: 'Users',
|
label: 'Users',
|
||||||
|
|||||||
@ -1,166 +1,165 @@
|
|||||||
|
import React from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
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() {
|
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) => (
|
|
||||||
<div
|
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${
|
|
||||||
image
|
|
||||||
? `url(${image?.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={image?.photographer_url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Photo by {image?.photographer} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video?.user?.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('POS Stok & Struk')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(180deg,_#F8FAFC_0%,_#EEF2FF_48%,_#FFFFFF_100%)] text-slate-900">
|
||||||
<div
|
<section className="mx-auto max-w-7xl px-6 pb-10 pt-8 lg:px-10 lg:pt-10">
|
||||||
className={`flex ${
|
<div className="mb-10 flex items-center justify-between gap-4 rounded-full border border-white/80 bg-white/70 px-5 py-3 shadow-sm backdrop-blur">
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<div>
|
||||||
} min-h-screen w-full`}
|
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-sky-600">POS Stok & Struk</div>
|
||||||
>
|
<div className="text-sm text-slate-500">Aplikasi kasir modern untuk admin & kasir toko.</div>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
</div>
|
||||||
? imageBlock(illustrationImage)
|
<div className="flex items-center gap-3">
|
||||||
: null}
|
<BaseButton href="/login" label="Login" color="whiteDark" />
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
<BaseButton href="/login" label="Buka admin interface" color="info" />
|
||||||
? videoBlock(illustrationVideo)
|
</div>
|
||||||
: null}
|
</div>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
|
||||||
<CardBoxComponentTitle title="Welcome to your POS Stok & Struk app!"/>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="grid gap-8 lg:grid-cols-[1.15fr_0.85fr] lg:items-center">
|
||||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
<div>
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
<div className="inline-flex rounded-full border border-sky-200 bg-sky-50 px-4 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
First MVP slice siap dipakai
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-5 max-w-3xl text-5xl font-black tracking-tight text-slate-950 sm:text-6xl">
|
||||||
|
Jual lebih cepat, stok lebih rapi, laporan lebih jelas.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-2xl text-lg leading-8 text-slate-600">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
|
<BaseButton href="/login" label="Masuk ke admin" color="info" />
|
||||||
|
<BaseButton href="/privacy-policy" label="Lihat kebijakan privasi" color="whiteDark" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="rounded-3xl border border-white/80 bg-white/80 p-5 shadow-sm backdrop-blur">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Workflow</div>
|
||||||
|
<div className="mt-2 text-xl font-bold text-slate-900">POS Checkout</div>
|
||||||
|
<div className="mt-2 text-sm leading-6 text-slate-600">Create → simpan → print → review struk di satu alur tipis yang usable.</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-3xl border border-white/80 bg-white/80 p-5 shadow-sm backdrop-blur">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Master data</div>
|
||||||
|
<div className="mt-2 text-xl font-bold text-slate-900">Produk & stok</div>
|
||||||
|
<div className="mt-2 text-sm leading-6 text-slate-600">CRUD bawaan tetap dipakai untuk setup produk, stok, supplier, dan register.</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-3xl border border-white/80 bg-white/80 p-5 shadow-sm backdrop-blur">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Akses</div>
|
||||||
|
<div className="mt-2 text-xl font-bold text-slate-900">Admin & kasir</div>
|
||||||
|
<div className="mt-2 text-sm leading-6 text-slate-600">Role/permission bawaan memudahkan pembagian tugas operasional toko.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BaseButtons>
|
<CardBox className="border border-white/80 bg-white/85 shadow-[0_20px_70px_rgba(15,23,42,0.12)] backdrop-blur">
|
||||||
<BaseButton
|
<div className="rounded-[28px] bg-gradient-to-br from-slate-950 via-blue-950 to-cyan-900 p-6 text-white">
|
||||||
href='/login'
|
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-cyan-200">What users get first</div>
|
||||||
label='Login'
|
<div className="mt-3 text-3xl font-black leading-tight">POS Checkout + Receipt Center</div>
|
||||||
color='info'
|
<p className="mt-3 text-sm leading-7 text-slate-200">
|
||||||
className='w-full'
|
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.
|
||||||
/>
|
</p>
|
||||||
|
|
||||||
</BaseButtons>
|
<div className="mt-6 grid gap-3">
|
||||||
</CardBox>
|
{journey.map((step, index) => (
|
||||||
</div>
|
<div key={step} className="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur-sm">
|
||||||
</div>
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-200">Langkah {index + 1}</div>
|
||||||
</SectionFullScreen>
|
<div className="mt-2 text-sm leading-6 text-slate-100">{step}</div>
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
</div>
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
))}
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
</div>
|
||||||
Privacy Policy
|
</div>
|
||||||
</Link>
|
</CardBox>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
<section className="mx-auto max-w-7xl px-6 py-8 lg:px-10 lg:py-12">
|
||||||
|
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-600">Kenapa cocok</div>
|
||||||
|
<h2 className="mt-2 text-3xl font-black tracking-tight text-slate-950">POS admin panel yang terasa siap jalan, bukan cuma template kosong.</h2>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500">Kita fokus pada workflow nyata dulu, lalu iterasi ke laporan dan operasional lanjutan.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
|
{highlights.map((item) => (
|
||||||
|
<div key={item.title} className="rounded-3xl border border-slate-200/70 bg-white/85 p-6 shadow-sm backdrop-blur">
|
||||||
|
<h3 className="text-xl font-bold text-slate-900">{item.title}</h3>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-600">{item.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-7xl px-6 pb-16 pt-4 lg:px-10">
|
||||||
|
<div className="rounded-[32px] border border-slate-200/80 bg-white/90 p-8 shadow-sm backdrop-blur lg:p-10">
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[1fr_auto] lg:items-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-600">Admin access</div>
|
||||||
|
<h2 className="mt-2 text-3xl font-black tracking-tight text-slate-950">Masuk ke panel admin untuk lanjut setup dan transaksi.</h2>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-600">
|
||||||
|
Link login tetap tersedia di halaman publik ini. Setelah masuk, Anda akan menemukan navigation untuk POS Checkout, produk, stok, penjualan, pembayaran, dan laporan lainnya.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<BaseButton href="/login" label="Login" color="whiteDark" />
|
||||||
|
<BaseButton href="/login" label="Masuk ke admin" color="info" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="border-t border-slate-200/80 bg-white/70">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-6 py-6 text-center text-sm text-slate-500 md:flex-row md:items-center md:justify-between lg:px-10">
|
||||||
|
<div>© 2026 POS Stok & Struk. Built for fast retail operations.</div>
|
||||||
|
<div className="flex items-center justify-center gap-5">
|
||||||
|
<Link href="/privacy-policy" className="hover:text-slate-900">
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
<Link href="/login" className="hover:text-slate-900">
|
||||||
|
Login Admin
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
1102
frontend/src/pages/pos/checkout.tsx
Normal file
1102
frontend/src/pages/pos/checkout.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,7 @@
|
|||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user