cct
This commit is contained in:
parent
a7dbcbf2d0
commit
91068a8627
@ -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',
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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:
|
||||
|
||||
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 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}%` }
|
||||
)
|
||||
)),
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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) => (
|
||||
<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 (
|
||||
<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>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('POS Stok & Struk')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<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">
|
||||
<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>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
<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">
|
||||
<section className="mx-auto max-w-7xl px-6 pb-10 pt-8 lg:px-10 lg:pt-10">
|
||||
<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">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<BaseButton href="/login" label="Login" color="whiteDark" />
|
||||
<BaseButton href="/login" label="Buka admin interface" color="info" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<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/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-8 lg:grid-cols-[1.15fr_0.85fr] lg:items-center">
|
||||
<div>
|
||||
<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">
|
||||
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>
|
||||
<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>
|
||||
|
||||
<CardBox className="border border-white/80 bg-white/85 shadow-[0_20px_70px_rgba(15,23,42,0.12)] backdrop-blur">
|
||||
<div className="rounded-[28px] bg-gradient-to-br from-slate-950 via-blue-950 to-cyan-900 p-6 text-white">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-cyan-200">What users get first</div>
|
||||
<div className="mt-3 text-3xl font-black leading-tight">POS Checkout + Receipt Center</div>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-200">
|
||||
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>
|
||||
|
||||
<div className="mt-6 grid gap-3">
|
||||
{journey.map((step, index) => (
|
||||
<div key={step} className="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur-sm">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-200">Langkah {index + 1}</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-100">{step}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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) {
|
||||
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 Head from 'next/head';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { useAppDispatch } from '../stores/hooks';
|
||||
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user