This commit is contained in:
Flatlogic Bot 2026-04-03 16:11:53 +00:00
parent a7dbcbf2d0
commit 91068a8627
12 changed files with 1797 additions and 172 deletions

View File

@ -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',
}
};

View File

@ -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 {

View File

@ -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:

View 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;
}
}
};

View File

@ -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}%` }
)
)),

View File

@ -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';

View File

@ -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'

View File

@ -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'

View File

@ -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',

View File

@ -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>;
};

File diff suppressed because it is too large Load Diff

View File

@ -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';