├── src
├── @types
│ ├── iso-date.ts
│ ├── base-64-blob.ts
│ ├── file.ts
│ └── react-barcode-reader.d.ts
├── components
│ ├── image-input
│ │ ├── index.ts
│ │ └── functions
│ │ │ └── resize-image.ts
│ ├── input-additional-content.tsx
│ ├── progress-bar.tsx
│ ├── redirect-if-unauthenticated.tsx
│ ├── theme-switcher.tsx
│ ├── input-controllers
│ │ ├── text-input.tsx
│ │ └── user-email.tsx
│ ├── redirect-if-authenticated.tsx
│ ├── bar-chart.tsx
│ ├── onboard-card.tsx
│ ├── line-chart.tsx
│ ├── confirmation-modal.tsx
│ ├── stepper.tsx
│ └── grid-pattern.tsx
├── app
│ ├── favicon.ico
│ ├── icon1.png
│ ├── icon2.png
│ ├── icon3.png
│ ├── icon4.png
│ ├── apple-icon.png
│ ├── (guest)
│ │ ├── forgot-password
│ │ │ ├── _components
│ │ │ │ ├── security-question
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── question-and-answer-field.tsx
│ │ │ │ │ └── security-question.tsx
│ │ │ │ └── email-form.tsx
│ │ │ ├── _stores
│ │ │ │ └── security-question.ts
│ │ │ └── page.tsx
│ │ ├── reset-password
│ │ │ ├── _components
│ │ │ │ └── reset-password-form
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── _stores
│ │ │ │ │ ├── pin-visibility.ts
│ │ │ │ │ └── password-visibility.ts
│ │ │ │ │ ├── _components
│ │ │ │ │ ├── password-field.tsx
│ │ │ │ │ ├── confirm-password-field.tsx
│ │ │ │ │ ├── pin-field.tsx
│ │ │ │ │ └── confirm-pin-field.tsx
│ │ │ │ │ └── reset-password-form.tsx
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── login
│ │ │ └── page.tsx
│ │ └── onboarding
│ │ │ ├── import-backup
│ │ │ └── page.tsx
│ │ │ └── page.tsx
│ ├── (authenticated user)
│ │ ├── data
│ │ │ ├── products
│ │ │ │ ├── _components
│ │ │ │ │ └── product-card
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── _hook.tsx
│ │ │ │ │ │ └── product-card.tsx
│ │ │ │ ├── (form)
│ │ │ │ │ ├── _components
│ │ │ │ │ │ ├── product-form
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── props.ts
│ │ │ │ │ │ │ ├── icon-button-input-content.tsx
│ │ │ │ │ │ │ └── hook.ts
│ │ │ │ │ │ └── page-template.tsx
│ │ │ │ │ ├── create
│ │ │ │ │ │ ├── page.tsx
│ │ │ │ │ │ └── _hook.ts
│ │ │ │ │ └── [uuid]
│ │ │ │ │ │ ├── page.tsx
│ │ │ │ │ │ └── _hook.ts
│ │ │ │ ├── _hook.ts
│ │ │ │ └── page.tsx
│ │ │ └── users
│ │ │ │ ├── (form)
│ │ │ │ ├── _types
│ │ │ │ │ └── form-values.ts
│ │ │ │ ├── create
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── hook.ts
│ │ │ │ ├── [uuid]
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── hook.ts
│ │ │ │ └── _functions
│ │ │ │ │ └── get-validated-form-values.ts
│ │ │ │ ├── hook.ts
│ │ │ │ └── page.tsx
│ │ ├── settings
│ │ │ ├── database
│ │ │ │ ├── export
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── sync
│ │ │ │ │ └── page.tsx
│ │ │ │ └── wipe
│ │ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ ├── reports
│ │ │ ├── sale-per-product
│ │ │ │ └── page.tsx
│ │ │ ├── sale-per-tx
│ │ │ │ └── page.tsx
│ │ │ ├── stock-in-out-per-product
│ │ │ │ └── page.tsx
│ │ │ ├── sale-per-product-category
│ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ ├── purchases
│ │ │ ├── layout.ts
│ │ │ ├── (form)
│ │ │ │ ├── _functions
│ │ │ │ │ ├── validate-form-values.ts
│ │ │ │ │ └── update-stocks-on-received.ts
│ │ │ │ ├── _types
│ │ │ │ │ └── form-values.ts
│ │ │ │ ├── [uuid]
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── page-hook.ts
│ │ │ │ ├── create
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── page-hook.ts
│ │ │ │ └── _components
│ │ │ │ │ └── costs-input.tsx
│ │ │ └── page-hook.ts
│ │ ├── layout.tsx
│ │ ├── onboarding
│ │ │ └── steps
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── 2-add-products-and-stock
│ │ │ │ ├── _component
│ │ │ │ │ ├── list-product.tsx
│ │ │ │ │ └── product-form.tsx
│ │ │ │ └── page.tsx
│ │ │ │ └── finish
│ │ │ │ └── page.tsx
│ │ ├── logout
│ │ │ └── page.tsx
│ │ ├── dashboard
│ │ │ └── page.tsx
│ │ └── sales
│ │ │ └── page.tsx
│ ├── _layout-components
│ │ ├── navbar
│ │ │ ├── components
│ │ │ │ ├── feedback-form-modal
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── hooks.ts
│ │ │ │ │ └── feedback-form-modal.tsx
│ │ │ │ ├── guest-navbar-items.tsx
│ │ │ │ └── auth-navbar-items
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ └── components
│ │ │ │ │ └── settings-dropdown-button.tsx
│ │ │ ├── navbar-items-no-ssr.tsx
│ │ │ ├── navbar-items.tsx
│ │ │ └── index.tsx
│ │ ├── font.ts
│ │ ├── hero.ts
│ │ ├── main.css
│ │ ├── page-indicator.tsx
│ │ └── providers.tsx
│ ├── (public)
│ │ └── onboarding
│ │ │ └── steps
│ │ │ ├── layout.tsx
│ │ │ └── 1-add-admin-user
│ │ │ └── page.tsx
│ ├── page.tsx
│ ├── manifest.webmanifest
│ ├── global-error.tsx
│ ├── _page-sections
│ │ ├── cta.tsx
│ │ ├── faq.tsx
│ │ ├── footer.tsx
│ │ └── hero.tsx
│ └── layout.tsx
├── enums
│ ├── role.ts
│ ├── security-question.ts
│ ├── permission.ts
│ └── page-url.ts
├── functions
│ ├── generate-ordered-uuid.ts
│ ├── is-uuid-string.ts
│ ├── format-number.ts
│ ├── get-hash.ts
│ ├── merge-class.ts
│ └── toast.tsx
├── hooks
│ ├── use-is-app-already-initialized.ts
│ ├── use-debounce.ts
│ └── use-auth.ts
├── models
│ ├── table-types
│ │ ├── product-movement
│ │ │ ├── sale-additional-info.ts
│ │ │ ├── additional-cost.ts
│ │ │ ├── item.ts
│ │ │ └── purchase-additional-info.ts
│ │ ├── user.ts
│ │ ├── product.ts
│ │ └── product-movement.ts
│ └── db.ts
├── stores
│ ├── form-submission.ts
│ └── input-error-message.ts
├── instrumentation.ts
├── data
│ ├── permission-templates.ts
│ └── penjualan.ts
├── instrumentation-client.ts
└── assets
│ └── logo.svg
├── .env.example
├── public
└── assets
│ └── pwas
│ ├── icon512_maskable.png
│ └── icon512_rounded.png
├── postcss.config.mjs
├── next-configs
├── with-bundler-analyzer.ts
└── with-sentry-config.ts
├── .gitignore
├── .github
└── workflows
│ └── code-check.yml
├── next.config.ts
├── sentry.server.config.ts
├── tsconfig.json
├── sentry.edge.config.ts
├── biome.json
├── LICENSE
├── README.md
├── package.json
└── docs
└── index.md
/src/@types/iso-date.ts:
--------------------------------------------------------------------------------
1 | export type ISODate = string
2 |
--------------------------------------------------------------------------------
/src/@types/base-64-blob.ts:
--------------------------------------------------------------------------------
1 | export type Base64Blob = string
2 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_APP_NAME="Sensasi POS"
2 | SENTRY_AUTH_TOKEN=
3 |
--------------------------------------------------------------------------------
/src/components/image-input/index.ts:
--------------------------------------------------------------------------------
1 | export { default as ImageInput } from './image-input'
2 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sensasi-apps/sensasi-pos/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/icon1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sensasi-apps/sensasi-pos/HEAD/src/app/icon1.png
--------------------------------------------------------------------------------
/src/app/icon2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sensasi-apps/sensasi-pos/HEAD/src/app/icon2.png
--------------------------------------------------------------------------------
/src/app/icon3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sensasi-apps/sensasi-pos/HEAD/src/app/icon3.png
--------------------------------------------------------------------------------
/src/app/icon4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sensasi-apps/sensasi-pos/HEAD/src/app/icon4.png
--------------------------------------------------------------------------------
/src/app/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sensasi-apps/sensasi-pos/HEAD/src/app/apple-icon.png
--------------------------------------------------------------------------------
/src/app/(guest)/forgot-password/_components/security-question/index.tsx:
--------------------------------------------------------------------------------
1 | export { default } from './security-question'
2 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/_components/product-card/index.ts:
--------------------------------------------------------------------------------
1 | export { ProductCard } from './product-card'
2 |
--------------------------------------------------------------------------------
/src/app/(guest)/reset-password/_components/reset-password-form/index.tsx:
--------------------------------------------------------------------------------
1 | export { default } from './reset-password-form'
2 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/(form)/_components/product-form/index.ts:
--------------------------------------------------------------------------------
1 | export { ProductForm } from './product-form'
2 |
--------------------------------------------------------------------------------
/public/assets/pwas/icon512_maskable.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sensasi-apps/sensasi-pos/HEAD/public/assets/pwas/icon512_maskable.png
--------------------------------------------------------------------------------
/public/assets/pwas/icon512_rounded.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sensasi-apps/sensasi-pos/HEAD/public/assets/pwas/icon512_rounded.png
--------------------------------------------------------------------------------
/src/app/_layout-components/navbar/components/feedback-form-modal/index.ts:
--------------------------------------------------------------------------------
1 | export { FeedbackFormModal } from './feedback-form-modal'
2 |
--------------------------------------------------------------------------------
/src/enums/role.ts:
--------------------------------------------------------------------------------
1 | export enum Role {
2 | OWNER = 'owner',
3 | MANAGER = 'manager',
4 | CASHIER = 'cashier',
5 | STOCKER = 'stocker',
6 | }
7 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | '@tailwindcss/postcss': {},
5 | },
6 | }
7 |
8 | export default config
9 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/settings/database/export/page.tsx:
--------------------------------------------------------------------------------
1 | export default function Page() {
2 | return (
3 |
4 |
(Halaman Ekspor Basis Data)
5 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/settings/database/sync/page.tsx:
--------------------------------------------------------------------------------
1 | export default function Page() {
2 | return (
3 |
4 |
(Halaman Sinkronisasi Basis Data)
5 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/reports/sale-per-product/page.tsx:
--------------------------------------------------------------------------------
1 | export default function Page() {
2 | return (
3 |
4 |
(Halaman Laporan Penjualan per Produk)
5 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/reports/sale-per-tx/page.tsx:
--------------------------------------------------------------------------------
1 | export default function page() {
2 | return (
3 |
4 |
(Halaman Laporan Penjualan per Transaksi)
5 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/_layout-components/font.ts:
--------------------------------------------------------------------------------
1 | import { Raleway } from 'next/font/google'
2 |
3 | export const sans = Raleway({
4 | subsets: ['latin'],
5 | display: 'swap',
6 | weight: ['300', '400', '500', '700'],
7 | })
8 |
--------------------------------------------------------------------------------
/src/functions/generate-ordered-uuid.ts:
--------------------------------------------------------------------------------
1 | import type { UUID } from 'node:crypto'
2 | import { v7 as generateUuid } from 'uuid'
3 |
4 | export function generateOrderedUuid(): UUID {
5 | return generateUuid() as UUID
6 | }
7 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/reports/stock-in-out-per-product/page.tsx:
--------------------------------------------------------------------------------
1 | export default function Page() {
2 | return (
3 |
4 |
(Halaman Laporan Stok Masuk-Keluar per Produk)
5 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/reports/sale-per-product-category/page.tsx:
--------------------------------------------------------------------------------
1 | export default function Page() {
2 | return (
3 |
4 |
(Halaman Laporan Penjualan per Kategori Produk)
5 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/src/functions/is-uuid-string.ts:
--------------------------------------------------------------------------------
1 | import { validate } from 'uuid'
2 |
3 | /**
4 | * Checks if the provided string is a valid UUID.
5 | */
6 | export function isUuidString(string: string): boolean {
7 | return validate(string)
8 | }
9 |
--------------------------------------------------------------------------------
/src/functions/format-number.ts:
--------------------------------------------------------------------------------
1 | export default function formatNumber(
2 | num: number,
3 | locale?: Intl.Locale,
4 | options?: Intl.NumberFormatOptions,
5 | ) {
6 | return new Intl.NumberFormat(locale ?? 'id-ID', options).format(num)
7 | }
8 |
--------------------------------------------------------------------------------
/next-configs/with-bundler-analyzer.ts:
--------------------------------------------------------------------------------
1 | import createWithBundleAnalyzer from '@next/bundle-analyzer'
2 |
3 | const withBundleAnalyzer = createWithBundleAnalyzer({
4 | enabled: process.env.ANALYZE === 'true',
5 | })
6 |
7 | export default withBundleAnalyzer
8 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/users/(form)/_types/form-values.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@/models/table-types/user'
2 |
3 | export type FormValues = Partial<
4 | Omit & {
5 | pin: string
6 | pin_confirmation: string
7 | }
8 | >
9 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/(form)/_components/product-form/props.ts:
--------------------------------------------------------------------------------
1 | import type { Product } from '@/models/table-types/product'
2 |
3 | export interface ProductFormProps {
4 | id: HTMLFormElement['id']
5 | data: Partial
6 | onSubmit?: (values: Partial) => void
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/(form)/create/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ProductFormPageTemplate } from '../_components/page-template'
4 | import { useHook } from './_hook'
5 |
6 | export default function ProductCreatePage() {
7 | return
8 | }
9 |
--------------------------------------------------------------------------------
/src/app/_layout-components/navbar/navbar-items-no-ssr.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import dynamic from 'next/dynamic'
4 |
5 | const NavbarItems = dynamic(() => import('./navbar-items'), {
6 | ssr: false,
7 | })
8 |
9 | export default function NavbarItemsNoSSR() {
10 | return
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/purchases/layout.ts:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 |
3 | export const metadata: Metadata = {
4 | title: `Pembelian — ${process.env.NEXT_PUBLIC_APP_NAME}`,
5 | }
6 |
7 | export default function Layout({ children }: { children: React.ReactNode }) {
8 | return children
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/purchases/(form)/_functions/validate-form-values.ts:
--------------------------------------------------------------------------------
1 | import type { ProductMovement } from '@/models/table-types/product-movement'
2 | import type { FormValues } from '../_types/form-values'
3 |
4 | export function validateFormValues(formValues: FormValues) {
5 | return formValues as ProductMovement
6 | }
7 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/purchases/(form)/_types/form-values.ts:
--------------------------------------------------------------------------------
1 | import type { ProductMovement } from '@/models/table-types/product-movement'
2 |
3 | export type FormValues = Partial & {
4 | items: Partial[]
5 | additional_costs: Partial[]
6 | }
7 |
--------------------------------------------------------------------------------
/src/hooks/use-is-app-already-initialized.ts:
--------------------------------------------------------------------------------
1 | import db from '@/models/db'
2 | import { useLiveQuery } from 'dexie-react-hooks'
3 |
4 | export default function useIsAppAlreadyInitialized() {
5 | return useLiveQuery(async () => {
6 | const userCount = await db.users.count()
7 | return userCount > 0
8 | }, [])
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/(guest)/layout.tsx:
--------------------------------------------------------------------------------
1 | import RedirectIfAuthenticated from '@/components/redirect-if-authenticated'
2 |
3 | export default function GuestLayout({
4 | children,
5 | }: {
6 | children: React.ReactNode
7 | }) {
8 | return (
9 | <>
10 |
11 | {children}
12 | >
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/_layout-components/hero.ts:
--------------------------------------------------------------------------------
1 | import { heroui } from '@heroui/theme'
2 |
3 | export default heroui({
4 | themes: {
5 | light: {
6 | colors: {
7 | background: '#f8f8f8',
8 | },
9 | },
10 | dark: {
11 | colors: {
12 | background: '#111111',
13 | },
14 | },
15 | },
16 | })
17 |
--------------------------------------------------------------------------------
/src/components/input-additional-content.tsx:
--------------------------------------------------------------------------------
1 | export function InputAdditionalContent({
2 | children,
3 | }: {
4 | children?: React.ReactNode
5 | }) {
6 | return (
7 |
8 | {children}
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/src/models/table-types/product-movement/sale-additional-info.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Interface representing additional information for a product movement sale.
3 | */
4 | export interface ProductMovementSaleAdditionalInfo {
5 | /**
6 | * The number of the receipt associated with the sale.
7 | */
8 | receipt_no: Readonly
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/(guest)/login/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 | import LoginForm from './login-form'
3 |
4 | export const metadata: Metadata = {
5 | title: 'Masuk — Sensasi POS',
6 | description: 'Halaman masuk untuk mengakses aplikasi Sensasi POS',
7 | }
8 |
9 | export default function Page() {
10 | return
11 | }
12 |
--------------------------------------------------------------------------------
/src/functions/get-hash.ts:
--------------------------------------------------------------------------------
1 | import { genSaltSync, hashSync } from 'bcryptjs'
2 |
3 | /**
4 | * Generates a hash for the given string using bcrypt.
5 | *
6 | * @param string - The string to be hashed.
7 | * @returns string - The hashed string.
8 | */
9 | export function getHash(string: string) {
10 | return hashSync(string, genSaltSync(10))
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/users/(form)/create/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { UserForm } from '../_components/form'
4 | import { useHook } from './hook'
5 |
6 | export default function Page() {
7 | return (
8 |
9 |
Tambah Data Pengguna
10 |
11 |
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/layout.tsx:
--------------------------------------------------------------------------------
1 | import RedirectIfUnauthenticated from '@/components/redirect-if-unauthenticated'
2 |
3 | export default function Layout({ children }: { children: React.ReactNode }) {
4 | return (
5 | <>
6 |
7 | {children}
8 | >
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/src/functions/merge-class.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | /**
5 | * Merge multiple class names into one.
6 | *
7 | * @param classNames - The class names to merge.
8 | * @returns string
9 | */
10 | export default function mergeClass(...classNames: ClassValue[]) {
11 | return twMerge(clsx(classNames))
12 | }
13 |
--------------------------------------------------------------------------------
/src/models/table-types/product-movement/additional-cost.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Interface representing an additional cost associated with a product movement.
3 | */
4 | export interface ProductMovementAdditionalCost {
5 | /**
6 | * Name of the additional cost.
7 | */
8 | name: string
9 |
10 | /**
11 | * Value of the additional cost.
12 | */
13 | value: number
14 | }
15 |
--------------------------------------------------------------------------------
/src/stores/form-submission.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand'
2 |
3 | export interface FormSubmissionState {
4 | isSubmitting: boolean
5 | toggleSubmitting: () => void
6 | }
7 |
8 | export const useFormSubmissionState = create(set => ({
9 | isSubmitting: false,
10 | toggleSubmitting: () => set(state => ({ isSubmitting: !state.isSubmitting })),
11 | }))
12 |
--------------------------------------------------------------------------------
/src/instrumentation.ts:
--------------------------------------------------------------------------------
1 | import * as Sentry from '@sentry/nextjs'
2 |
3 | export async function register() {
4 | if (process.env.NEXT_RUNTIME === 'nodejs') {
5 | await import('../sentry.server.config')
6 | }
7 |
8 | if (process.env.NEXT_RUNTIME === 'edge') {
9 | await import('../sentry.edge.config')
10 | }
11 | }
12 |
13 | export const onRequestError = Sentry.captureRequestError
14 |
--------------------------------------------------------------------------------
/src/functions/toast.tsx:
--------------------------------------------------------------------------------
1 | import toastVendor from 'react-hot-toast'
2 | import { Alert, type AlertProps } from '@heroui/alert'
3 |
4 | export function toast(message: React.ReactNode, color?: AlertProps['color']) {
5 | return toastVendor.custom(
6 | ,
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/src/app/(public)/onboarding/steps/layout.tsx:
--------------------------------------------------------------------------------
1 | import Steps from '@/components/stepper'
2 | import type { ReactNode } from 'react'
3 |
4 | const Layout = ({ children }: { children: ReactNode }) => {
5 | return (
6 | <>
7 |
8 |
9 |
10 | {children}
11 | >
12 | )
13 | }
14 |
15 | export default Layout
16 |
--------------------------------------------------------------------------------
/src/app/_layout-components/main.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | @plugin "./hero.ts";
4 |
5 | @source "../../../node_modules/@heroui/theme/dist/components/*.js";
6 |
7 | @layer components {
8 | .container {
9 | margin-right: auto;
10 | margin-left: auto;
11 | padding: 2rem;
12 | }
13 | }
14 |
15 | @custom-variant dark (&:is(.dark *));
16 |
17 | .text-align-unset {
18 | text-align: unset;
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/onboarding/steps/layout.tsx:
--------------------------------------------------------------------------------
1 | import Steps from '@/components/stepper'
2 | import type { ReactNode } from 'react'
3 |
4 | const Layout = ({ children }: { children: ReactNode }) => {
5 | return (
6 | <>
7 |
8 |
9 |
10 | {children}
11 | >
12 | )
13 | }
14 |
15 | export default Layout
16 |
--------------------------------------------------------------------------------
/src/app/_layout-components/navbar/navbar-items.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import useAuth from '@/hooks/use-auth'
4 | import { GuestNavbarItems } from './components/guest-navbar-items'
5 | import { AuthNavbarItems } from './components/auth-navbar-items'
6 |
7 | export default function NavbarItems() {
8 | const { user } = useAuth()
9 |
10 | if (!user) {
11 | return
12 | }
13 |
14 | return
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/(form)/[uuid]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ProductFormPageTemplate } from '../_components/page-template'
4 | import { useHook } from './_hook'
5 | import { use } from 'react'
6 |
7 | export default function ProductUpdatePage({
8 | params,
9 | }: {
10 | params: Promise<{ uuid: string }>
11 | }) {
12 | const { uuid } = use(params)
13 | return
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/progress-bar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { AppProgressBar } from 'next-nprogress-bar'
4 | import type { ReactNode } from 'react'
5 |
6 | export default function ProgressBar({ children }: { children: ReactNode }) {
7 | return (
8 | <>
9 | {children}
10 |
16 | >
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/hooks/use-debounce.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export function useDebounce(value: T, delay: number = 500): T {
4 | const [debouncedValue, setDebouncedValue] = useState(value)
5 |
6 | useEffect(() => {
7 | const handler = setTimeout(() => {
8 | setDebouncedValue(value)
9 | }, delay)
10 |
11 | return () => {
12 | clearTimeout(handler)
13 | }
14 | }, [value, delay])
15 |
16 | return debouncedValue
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/_hook.ts:
--------------------------------------------------------------------------------
1 | import db from '@/models/db'
2 | import { useLiveQuery } from 'dexie-react-hooks'
3 |
4 | export function useHook() {
5 | const products = useLiveQuery(() => db.products.toArray())
6 | const nProducts = products?.length ?? 0
7 | const categories =
8 | products
9 | ?.map(product => product.category)
10 | .filter((v, i, a) => v && a.indexOf(v) === i) ?? []
11 |
12 | return { products, nProducts, categories }
13 | }
14 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/logout/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import useAuth from '@/hooks/use-auth'
4 | import { Spinner } from '@heroui/spinner'
5 | import { useEffect, useEffectEvent } from 'react'
6 |
7 | export default function Page() {
8 | const { setLoggedInUser } = useAuth()
9 |
10 | const loggingOut = useEffectEvent(() => {
11 | setLoggedInUser(undefined)
12 | })
13 |
14 | useEffect(() => {
15 | loggingOut()
16 | }, [])
17 |
18 | return
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Footer } from './_page-sections/footer'
2 | import { Hero } from './_page-sections/hero'
3 | import { Faq } from './_page-sections/faq'
4 | import { Cta } from './_page-sections/cta'
5 | import RedirectIfAuthenticated from '@/components/redirect-if-authenticated'
6 |
7 | export default function Home() {
8 | return (
9 | <>
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | >
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/models/table-types/product-movement/item.ts:
--------------------------------------------------------------------------------
1 | import type { Product } from '../product'
2 |
3 | /**
4 | * Definition for the `ProductMovement.items[]` property.
5 | */
6 | export interface ProductMovementItem {
7 | /**
8 | * Product state at the time of the movement.
9 | */
10 | product_state: Readonly
11 |
12 | /**
13 | * Quantity of the product.
14 | */
15 | qty: number
16 |
17 | /**
18 | * Price of the product at the time of the movement. Wether it's a purchase or a sale.
19 | */
20 | price: number
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/users/(form)/[uuid]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { UUID } from 'node:crypto'
4 | import { UserForm } from '../_components/form'
5 | import { useHook } from './hook'
6 | import { use } from 'react'
7 |
8 | export default function Page({ params }: { params: Promise<{ uuid: UUID }> }) {
9 | const { uuid } = use(params)
10 | const hook = useHook(uuid)
11 |
12 | return (
13 |
14 |
(Halaman Ubah Data Pengguna) {uuid}
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/@types/file.ts:
--------------------------------------------------------------------------------
1 | import type { Base64Blob } from './base-64-blob'
2 | import type { ISODate } from './iso-date'
3 |
4 | /**
5 | * Represents a file with its metadata.
6 | */
7 | export interface File {
8 | /**
9 | * Name of the file.
10 | */
11 | name?: string
12 |
13 | /**
14 | * Base64 encoded blob of the file.
15 | */
16 | blob: Base64Blob
17 |
18 | /**
19 | * Description of the file.
20 | */
21 | description?: string
22 |
23 | /**
24 | * Date and time when the file was added.
25 | */
26 | created_at: Readonly
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/(public)/onboarding/steps/1-add-admin-user/page.tsx:
--------------------------------------------------------------------------------
1 | import ManagerUserForm from './manager-user-form'
2 |
3 | export default function Page() {
4 | return (
5 |
6 |
Buat akun pengelola
7 |
8 |
9 | Pengelola akan mendapatkan akses penuh untuk pengelolaan aplikasi.
10 |
11 |
12 |
13 |
14 | )
15 | }
16 |
17 | export const metadata = {
18 | title: 'Sensasi POS',
19 | }
20 |
--------------------------------------------------------------------------------
/src/models/table-types/product-movement/purchase-additional-info.ts:
--------------------------------------------------------------------------------
1 | import type { ISODate } from '@/@types/iso-date'
2 |
3 | /**
4 | * Interface representing additional information for product movement purchases.
5 | */
6 | export interface ProductMovementPurchaseAdditionalInfo {
7 | /**
8 | * The date and time when the purchase was received.
9 | */
10 | received_at?: ISODate
11 |
12 | /**
13 | * The date and time when the purchase was paid.
14 | */
15 | paid_at?: ISODate
16 |
17 | /**
18 | * The date and time when the purchase payment is due.
19 | */
20 | due_at?: ISODate
21 | }
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | .*
39 | !.github
40 | .github/copilot-instructions.md
--------------------------------------------------------------------------------
/src/components/redirect-if-unauthenticated.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import PageUrlEnum from '@/enums/page-url'
4 | import useAuth from '@/hooks/use-auth'
5 | import { usePathname, useRouter } from 'next/navigation'
6 | import { useEffect } from 'react'
7 |
8 | export default function RedirectIfUnauthenticated() {
9 | const { user } = useAuth()
10 | const pathname = usePathname()
11 | const router = useRouter()
12 |
13 | useEffect(() => {
14 | if (!user) {
15 | const redirectUrl = `${PageUrlEnum.LOGIN}?redirect=${encodeURIComponent(pathname)}`
16 | router.push(redirectUrl)
17 | }
18 | }, [user, pathname, router])
19 |
20 | return null
21 | }
22 |
--------------------------------------------------------------------------------
/.github/workflows/code-check.yml:
--------------------------------------------------------------------------------
1 | name: Code check
2 |
3 | permissions:
4 | contents: read
5 |
6 | on:
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | code-check:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: 📥 Checkout repository
17 | uses: actions/checkout@v4
18 |
19 | - name: 🥟 Setup Bun
20 | uses: oven-sh/setup-bun@v1
21 | with:
22 | bun-version: latest
23 |
24 | - name: 📦 Install dependencies
25 | run: bun install --frozen-lockfile
26 |
27 | - name: Run linter
28 | run: bun run lint
29 |
30 | - name: 🧪 Run tests
31 | run: bun run test
32 |
--------------------------------------------------------------------------------
/src/app/_layout-components/page-indicator.tsx:
--------------------------------------------------------------------------------
1 | export function PageIndicator() {
2 | if (process.env.NODE_ENV === 'production') return null
3 |
4 | return (
5 |
6 |
xs
7 |
sm
8 |
md
9 |
lg
10 |
xl
11 |
2xl
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/(guest)/onboarding/import-backup/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@heroui/button'
4 | import { useRouter } from 'next/navigation'
5 |
6 | export default function Page() {
7 | const router = useRouter()
8 |
9 | return (
10 |
11 |
(Halaman impor basis data)
12 |
13 |
14 |
15 | Unggah file hasil pencadangan basis data yang telah Anda buat
16 | sebelumnya
17 |
18 |
19 | Pindai kode QR dari perangkat lain
20 |
21 |
22 |
router.back()}>
23 | Kembali
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from 'next'
2 | import withBundleAnalyzer from './next-configs/with-bundler-analyzer'
3 | import withSentryConfig from './next-configs/with-sentry-config'
4 |
5 | const nextConfig: NextConfig = {
6 | experimental: {
7 | /**
8 | * Some libraries are optimized by default, see https://nextjs.org/docs/app/api-reference/config/next-config-js/optimizePackageImports for the full list.
9 | */
10 | optimizePackageImports: [
11 | '@heroui',
12 | 'framer-motion',
13 | 'react-hot-toast',
14 | 'next-nprogress-bar',
15 | 'next-themes',
16 | ],
17 | },
18 | }
19 |
20 | export default withBundleAnalyzer(withSentryConfig(nextConfig))
21 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/users/hook.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { User } from '@/models/table-types/user'
4 | import db from '@/models/db'
5 | import { useLiveQuery } from 'dexie-react-hooks'
6 |
7 | export function useHook() {
8 | return {
9 | users: useLiveQuery(() => db.users.toArray()),
10 |
11 | setUserActiveStatus: async (userUuid: User['uuid'], isActive: boolean) => {
12 | await db.users.update(userUuid, {
13 | /**
14 | * @todo Implement inactivated_by_user_state
15 | */
16 | // inactivated_by_user_state: getAuthUser(),
17 |
18 | inactivated_at: isActive ? undefined : new Date().toISOString(),
19 | })
20 | },
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/(guest)/onboarding/page.tsx:
--------------------------------------------------------------------------------
1 | import { OnBoardCard } from '@/components/onboard-card'
2 |
3 | export default function Page() {
4 | return (
5 |
6 |
7 | Selamat Datang di Sensasi POS
8 |
9 |
Pilih cara memulai aplikasi:
10 |
11 |
12 |
13 |
14 |
15 |
16 | Anda dapat menjelajahi aplikasi dengan data demo atau menggunakan data
17 | nyata untuk pengaturan lebih lanjut.
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/manifest.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "theme_color": "#2563eb",
3 | "background_color": "#2563eb",
4 | "icons": [
5 | {
6 | "purpose": "maskable",
7 | "sizes": "512x512",
8 | "src": "assets/pwas/icon512_maskable.png",
9 | "type": "image/png"
10 | },
11 | {
12 | "purpose": "any",
13 | "sizes": "512x512",
14 | "src": "assets/pwas/icon512_rounded.png",
15 | "type": "image/png"
16 | }
17 | ],
18 | "orientation": "any",
19 | "display": "fullscreen",
20 | "lang": "id-ID",
21 | "name": "Sensasi POS",
22 | "description": "Aplikasi Point of Sale sederhana yang dirancang untuk membantu pencatatan penjualan barang pada Warung / Toko / UMKM / Stan / Gerai / Swalayan."
23 | }
24 |
--------------------------------------------------------------------------------
/src/enums/security-question.ts:
--------------------------------------------------------------------------------
1 | export enum SecurityQuestion {
2 | NICKNAME = 'Apa nama panggilan masa kecil Anda di keluarga?',
3 | ELEMENTARY_SCHOOL = 'Apa nama sekolah dasar pertama Anda?',
4 | BIRTH_CITY = 'Di kota apa Anda dilahirkan?',
5 | GRANDPARENT_FULLNAME = 'Apa nama lengkap kakek atau nenek Anda dari pihak ibu?',
6 | FIRST_PET_NAME = 'Apa nama hewan peliharaan pertama Anda?',
7 | BEST_FRIEND = 'Apa nama sahabat terbaik Anda di masa kecil?',
8 | FAVORITE_FOOD = 'Apa makanan favorit Anda saat kecil?',
9 | FIRST_VACATION_LOCATION = 'Di mana lokasi liburan pertama Anda?',
10 | FAVORITE_COLOR = 'Apa warna favorit Anda saat masih bersekolah?',
11 | FAVORITE_SONG = 'Apa judul lagu yang paling Anda suka di masa remaja?',
12 | }
13 |
--------------------------------------------------------------------------------
/sentry.server.config.ts:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the server.
2 | // The config you add here will be used whenever the server handles a request.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from '@sentry/nextjs'
6 |
7 | if (process.env.VERCEL_ENV === 'production') {
8 | Sentry.init({
9 | dsn: 'https://0abb76d5407d5260770cdba7c3c07af0@o1289319.ingest.us.sentry.io/4507935813730304',
10 |
11 | // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
12 | tracesSampleRate: 1,
13 |
14 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
15 | debug: false,
16 | })
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/global-error.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as Sentry from '@sentry/nextjs'
4 | import NextError from 'next/error'
5 | import { useEffect } from 'react'
6 |
7 | export default function GlobalError({
8 | error,
9 | }: {
10 | error: Error & { digest?: string }
11 | }) {
12 | useEffect(() => {
13 | Sentry.captureException(error)
14 | }, [error])
15 |
16 | return (
17 |
18 |
19 | {/* `NextError` is the default Next.js error page component. Its type
20 | definition requires a `statusCode` prop. However, since the App Router
21 | does not expose status codes for errors, we simply pass 0 to render a
22 | generic error message. */}
23 |
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/(guest)/forgot-password/_stores/security-question.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand'
2 |
3 | export interface SecurityQuestionState {
4 | selectedQuestionNumbers: number[]
5 | pushSelectedQuestionNumbers: (questionNumber: number) => void
6 | hasQuestionNumber: (questionNumber: number) => boolean
7 | }
8 |
9 | export const useSecurityQuestionState = create(
10 | (set, get) => ({
11 | selectedQuestionNumbers: [],
12 | pushSelectedQuestionNumbers: (questionNumber: number) =>
13 | set(state => ({
14 | selectedQuestionNumbers: [
15 | ...state.selectedQuestionNumbers,
16 | questionNumber,
17 | ],
18 | })),
19 | hasQuestionNumber: (questionNumber: number) =>
20 | get().selectedQuestionNumbers.some(number => number === questionNumber),
21 | }),
22 | )
23 |
--------------------------------------------------------------------------------
/src/app/(guest)/reset-password/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // vendors
4 | import { Card, CardBody, CardHeader } from '@heroui/card'
5 | import { Divider } from '@heroui/divider'
6 | //
7 | import ResetPasswordForm from './_components/reset-password-form/reset-password-form'
8 |
9 | export default function Page() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 | Atur Ulang Kata Sandi
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "react-jsx",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | },
23 | "target": "ES2018"
24 | },
25 | "include": [
26 | "next-env.d.ts",
27 | "**/*.ts",
28 | "**/*.tsx",
29 | ".next/types/**/*.ts",
30 | ".next/dev/types/**/*.ts",
31 | ".next\\dev/types/**/*.ts"
32 | ],
33 | "exclude": ["node_modules", ".next"]
34 | }
35 |
--------------------------------------------------------------------------------
/src/@types/react-barcode-reader.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'react-barcode-reader' {
2 | export interface BarcodeReaderProps {
3 | onScan?: (barcode: string) => void
4 | onError?: (barcode: string, message: string) => void
5 | onReceive?: (char: string) => void
6 | onKeyDetect?: (char: string) => void
7 | timeBeforeScanTest?: number
8 | avgTimeByChar?: number
9 | minLength?: number
10 | endChar?: number[]
11 | startChar?: number[]
12 | scanButtonKeyCode?: number
13 | scanButtonLongPressThreshold?: number
14 | onScanButtonLongPressed?: () => void
15 | stopPropagation?: boolean
16 | preventDefault?: boolean
17 | testCode?: string
18 | }
19 |
20 | declare class BarcodeReader extends React.Component<
21 | BarcodeReaderProps,
22 | undefined
23 | > {}
24 |
25 | export default BarcodeReader
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/theme-switcher.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useTheme } from 'next-themes'
4 | import { MoonStar, Sun } from 'lucide-react'
5 | import type { SwitchProps } from '@heroui/switch'
6 | import dynamic from 'next/dynamic'
7 |
8 | const Switch = dynamic(
9 | () => import('@heroui/switch').then(mod => mod.Switch),
10 | {
11 | ssr: false,
12 | },
13 | )
14 |
15 | export default function ThemeSwitcher() {
16 | const { resolvedTheme, setTheme } = useTheme()
17 |
18 | return (
19 | {
25 | setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
26 | }}
27 | color="primary"
28 | startContent={ }
29 | endContent={ }
30 | />
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/src/models/db.ts:
--------------------------------------------------------------------------------
1 | import Dexie, { type EntityTable } from 'dexie'
2 | import type { Product } from './table-types/product'
3 | import type { ProductMovement } from './table-types/product-movement'
4 | import type { User } from './table-types/user'
5 |
6 | interface Tables {
7 | products: EntityTable
8 | productMovements: EntityTable
9 | users: EntityTable
10 | }
11 |
12 | const db = new Dexie('sensasi-pos-db') as Dexie & Tables
13 |
14 | /**
15 | * @see https://dexie.org/docs/Version/Version.stores()
16 | */
17 | db.version(1).stores({
18 | users: '&uuid, &email, name, *roles, created_at',
19 | products:
20 | '&uuid, &code, &barcode_reg_id, &name, description, category, created_at',
21 | productMovements:
22 | '&uuid, at, type, note, item.product_state.uuid, created_at',
23 | })
24 |
25 | export default db
26 |
--------------------------------------------------------------------------------
/src/app/(guest)/reset-password/_components/reset-password-form/_stores/pin-visibility.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand'
2 |
3 | interface PinVisibilityState {
4 | pin: {
5 | isVisible: boolean
6 | toggleVisibility: () => void
7 | }
8 | confirmPin: {
9 | isVisible: boolean
10 | toggleVisibility: () => void
11 | }
12 | }
13 |
14 | export const usePinVisibilityState = create(set => ({
15 | pin: {
16 | isVisible: false,
17 | toggleVisibility: () =>
18 | set(state => ({
19 | pin: { ...state.pin, isVisible: !state.pin.isVisible },
20 | })),
21 | },
22 | confirmPin: {
23 | isVisible: false,
24 | toggleVisibility: () =>
25 | set(state => ({
26 | confirmPin: {
27 | ...state.confirmPin,
28 | isVisible: !state.confirmPin.isVisible,
29 | },
30 | })),
31 | },
32 | }))
33 |
--------------------------------------------------------------------------------
/src/app/_layout-components/providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { ReactNode } from 'react'
4 | import { HeroUIProvider } from '@heroui/system'
5 | import { ThemeProvider as NextThemeProvider } from 'next-themes'
6 | import { Toaster } from 'react-hot-toast'
7 | import { useRouter } from 'next/navigation'
8 |
9 | /**
10 | * Providers:
11 | * - HeroUIProvider: Global styles
12 | * - NextThemeProvider: Required by HeroUI for Theme Switcher
13 | *
14 | * @see https://www.heroui.com/docs/customization/dark-mode
15 | */
16 | export default function Providers({ children }: { children: ReactNode }) {
17 | const router = useRouter()
18 | return (
19 |
20 |
21 | {children}
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/enums/permission.ts:
--------------------------------------------------------------------------------
1 | export enum Permission {
2 | READ_REPORT = 'read:report',
3 | READ_DASHBOARD = 'read:dashboard',
4 |
5 | // PRODUCT
6 | CREATE_PRODUCT = 'create:product',
7 | READ_PRODUCT = 'read:product',
8 | UPDATE_PRODUCT = 'update:product',
9 | DELETE_PRODUCT = 'delete:product',
10 |
11 | // USER
12 | CREATE_USER = 'create:user',
13 | READ_USER = 'read:user',
14 | UPDATE_USER = 'update:user',
15 | DELETE_USER = 'delete:user',
16 |
17 | // PURCHASE
18 | CREATE_PURCHASE = 'create:purchase',
19 | READ_PURCHASE = 'read:purchase',
20 | UPDATE_PURCHASE = 'update:purchase',
21 | DELETE_PURCHASE = 'delete:purchase',
22 |
23 | // SALE
24 | CREATE_SALE = 'create:sale',
25 | READ_SALE = 'read:sale',
26 | UPDATE_SALE = 'update:sale',
27 | DELETE_SALE = 'delete:sale',
28 |
29 | // DB
30 | EXPORT_DB = 'export:db',
31 | SYNC_DB = 'sync:db',
32 | WIPE_DB = 'wipe:db',
33 | }
34 |
--------------------------------------------------------------------------------
/sentry.edge.config.ts:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
2 | // The config you add here will be used whenever one of the edge features is loaded.
3 | // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
4 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
5 |
6 | import * as Sentry from '@sentry/nextjs'
7 |
8 | if (process.env.VERCEL_ENV === 'production') {
9 | Sentry.init({
10 | dsn: 'https://0abb76d5407d5260770cdba7c3c07af0@o1289319.ingest.us.sentry.io/4507935813730304',
11 |
12 | // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
13 | tracesSampleRate: 1,
14 |
15 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
16 | debug: false,
17 | })
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/onboarding/steps/2-add-products-and-stock/_component/list-product.tsx:
--------------------------------------------------------------------------------
1 | import db from '@/models/db'
2 | import { Alert } from '@heroui/alert'
3 | import { useLiveQuery } from 'dexie-react-hooks'
4 |
5 | export function ProductList() {
6 | const products = useLiveQuery(() => db.products.toArray(), [])
7 |
8 | if (!products || products.length === 0) {
9 | return (
10 |
14 | Anda belum menambahkan produk, beserta stok nya.
15 |
16 | )
17 | }
18 |
19 | return (
20 |
21 |
22 | {products.map(p => (
23 |
24 | {p.name} ({p.stock.qty} {p.qty_unit})
25 |
26 | ))}
27 |
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/(form)/_components/product-form/icon-button-input-content.tsx:
--------------------------------------------------------------------------------
1 | // types
2 | import type { ForwardRefExoticComponent, RefAttributes } from 'react'
3 | import type { LucideProps } from 'lucide-react'
4 | // vendors
5 | import { Button, type ButtonProps } from '@heroui/button'
6 | import { Tooltip } from '@heroui/tooltip'
7 |
8 | export function IconButtonInputContent({
9 | text,
10 | icon: IconComponent,
11 | onClick,
12 | }: {
13 | text: string
14 | children?: never
15 | icon: ForwardRefExoticComponent<
16 | Omit & RefAttributes
17 | >
18 | onClick?: ButtonProps['onClick']
19 | }) {
20 | return (
21 |
22 |
23 |
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/input-controllers/text-input.tsx:
--------------------------------------------------------------------------------
1 | import { Input, type InputProps } from '@heroui/input'
2 | import {
3 | Controller,
4 | type FieldValues,
5 | useFormContext,
6 | type ControllerProps,
7 | } from 'react-hook-form'
8 |
9 | export default function TextInput({
10 | slotProps,
11 | ...props
12 | }: Pick, 'rules' | 'name'> & {
13 | slotProps?: {
14 | input?: InputProps
15 | }
16 | }) {
17 | const { control } = useFormContext()
18 |
19 | return (
20 | (
24 |
32 | )}
33 | />
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/purchases/(form)/_functions/update-stocks-on-received.ts:
--------------------------------------------------------------------------------
1 | import type { ProductMovement } from '@/models/table-types/product-movement'
2 | import db from '@/models/db'
3 |
4 | export function updateStocksOnReceived(productMovement: ProductMovement) {
5 | productMovement.items.forEach(item => {
6 | db.products
7 | .update(item.product_state.uuid, product => {
8 | const inQty = item.qty
9 | const inWorth = item.qty * item.price
10 |
11 | const existsStock = product.stock
12 |
13 | const existsQty = existsStock?.qty ?? 0
14 | const existsWorth = existsStock?.cost ?? 0
15 |
16 | const newQty = existsQty + inQty
17 | const newWorth = existsWorth + inWorth
18 | const newCost = newWorth / newQty
19 |
20 | existsStock.qty = newQty
21 | existsStock.cost = newCost
22 | })
23 | .catch(error => {
24 | throw error
25 | })
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/_layout-components/navbar/components/guest-navbar-items.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // vendors
4 | import { NavbarItem } from '@heroui/navbar'
5 | import { Link } from '@heroui/link'
6 | // globals
7 | import PageUrlEnum from '@/enums/page-url'
8 | import ThemeSwitcher from '@/components/theme-switcher'
9 | import useIsAppAlreadyInitialized from '@/hooks/use-is-app-already-initialized'
10 |
11 | /**
12 | * Navbar items for guest users
13 | */
14 | export function GuestNavbarItems() {
15 | const appIsAlreadyInitialized = useIsAppAlreadyInitialized()
16 |
17 | return (
18 | <>
19 |
20 |
24 | {appIsAlreadyInitialized ? 'Login' : 'Coba Sekarang'}
25 |
26 |
27 |
28 |
29 |
30 |
31 | >
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/image-input/functions/resize-image.ts:
--------------------------------------------------------------------------------
1 | import FileResizer from 'react-image-file-resizer'
2 |
3 | export default function resizeImage(
4 | file: File,
5 | maxWidth = 640,
6 | maxHeight = 640,
7 | ) {
8 | return new Promise(resolve => {
9 | FileResizer.imageFileResizer(
10 | file,
11 | maxWidth,
12 | maxHeight,
13 | 'webp',
14 | 100,
15 | 0,
16 | value => {
17 | if (typeof value === 'string') {
18 | resolve(value)
19 | return
20 | }
21 |
22 | if (value instanceof Blob || value instanceof File) {
23 | const reader = new FileReader()
24 | reader.onload = () => {
25 | if (typeof reader.result === 'string') resolve(reader.result)
26 | }
27 |
28 | reader.readAsDataURL(value)
29 | return
30 | }
31 |
32 | throw new Error('Unexpected type')
33 | },
34 | 'base64',
35 | )
36 | })
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/(guest)/reset-password/_components/reset-password-form/_stores/password-visibility.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand'
2 |
3 | interface PasswordVisibilityState {
4 | password: {
5 | isVisible: boolean
6 | toggleVisibility: () => void
7 | }
8 | confirmPassword: {
9 | isVisible: boolean
10 | toggleVisibility: () => void
11 | }
12 | }
13 |
14 | export const usePasswordVisibilityState = create(
15 | set => ({
16 | password: {
17 | isVisible: false,
18 | toggleVisibility: () =>
19 | set(state => ({
20 | password: { ...state.password, isVisible: !state.password.isVisible },
21 | })),
22 | },
23 | confirmPassword: {
24 | isVisible: false,
25 | toggleVisibility: () =>
26 | set(state => ({
27 | confirmPassword: {
28 | ...state.confirmPassword,
29 | isVisible: !state.confirmPassword.isVisible,
30 | },
31 | })),
32 | },
33 | }),
34 | )
35 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/reports/page.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import { Link } from '@heroui/link'
3 | //
4 | import PageUrlEnum from '@/enums/page-url'
5 |
6 | export default function page() {
7 | return (
8 |
9 |
(Halaman Daftar Laporan)
10 |
11 |
12 |
13 |
14 | Penjualan per produk
15 |
16 |
17 |
18 |
19 |
20 | Penjualan per kategori produk
21 |
22 |
23 |
24 |
25 |
26 | Penjualan per transaksi
27 |
28 |
29 |
30 |
31 |
32 | Stok masuk/keluar per produk
33 |
34 |
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/users/(form)/_functions/get-validated-form-values.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@/models/table-types/user'
2 | import type { FormValues } from '../_types/form-values'
3 | import { getHash } from '@/functions/get-hash'
4 |
5 | export function getValidatedFormValues(formValues: FormValues): User {
6 | if (
7 | !formValues.uuid ||
8 | !formValues.pin ||
9 | !formValues.name ||
10 | !formValues.email ||
11 | !formValues.roles?.length ||
12 | !formValues.permissions?.length ||
13 | !formValues.created_at ||
14 | !formValues.updated_at
15 | ) {
16 | throw new Error('Data pengguna tidak lengkap')
17 | }
18 |
19 | return {
20 | uuid: formValues.uuid,
21 | name: formValues.name,
22 | email: formValues.email,
23 |
24 | pin__hashed: getHash(formValues.pin),
25 |
26 | roles: formValues.roles,
27 | permissions: formValues.permissions,
28 |
29 | created_at: formValues.created_at,
30 | updated_at: new Date().toISOString(),
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/redirect-if-authenticated.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import PageUrlEnum from '@/enums/page-url'
4 | import useAuth from '@/hooks/use-auth'
5 | import { useRouter, useSearchParams } from 'next/navigation'
6 | import { Suspense, useEffect } from 'react'
7 |
8 | function RedirectIfAuthenticatedContent() {
9 | const { user } = useAuth()
10 | const searchParams = useSearchParams()
11 | const router = useRouter()
12 |
13 | useEffect(() => {
14 | if (user) {
15 | const redirectUrl = searchParams.get('redirect')
16 | // Redirect to the provided redirect URL if it exists, otherwise to dashboard
17 | if (redirectUrl) {
18 | router.push(redirectUrl)
19 | } else {
20 | router.push(PageUrlEnum.DASHBOARD)
21 | }
22 | }
23 | }, [user, searchParams, router])
24 |
25 | return null
26 | }
27 |
28 | export default function RedirectIfAuthenticated() {
29 | return (
30 |
31 |
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/_page-sections/cta.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import PageUrlEnum from '@/enums/page-url'
4 | import { Button } from '@heroui/button'
5 | import { Link } from '@heroui/link'
6 | import useIsAppAlreadyInitialized from '@/hooks/use-is-app-already-initialized'
7 |
8 | export function Cta() {
9 | const appIsAlreadyInitialized = useIsAppAlreadyInitialized()
10 |
11 | return (
12 |
13 |
14 |
15 | Mulai Mengelola Usaha Anda Sekarang
16 |
17 |
18 |
24 | {appIsAlreadyInitialized ? 'Login' : 'Coba Sekarang'}
25 |
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/_layout-components/navbar/index.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import {
3 | Navbar as VendorNavbar,
4 | NavbarBrand,
5 | NavbarContent,
6 | } from '@heroui/navbar'
7 | import NextLink from 'next/link'
8 | // icons
9 | import { ComputerIcon } from 'lucide-react'
10 | // globals
11 | import PageUrlEnum from '@/enums/page-url'
12 | // components
13 | import NavbarItemsNoSSR from './navbar-items-no-ssr'
14 |
15 | export default function Navbar() {
16 | return (
17 |
20 |
21 |
22 |
23 |
24 | Sensasi POS
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
3 | "css": {
4 | "parser": {
5 | "tailwindDirectives": true
6 | }
7 | },
8 | "vcs": {
9 | "enabled": true,
10 | "clientKind": "git",
11 | "useIgnoreFile": true
12 | },
13 | "files": {
14 | "ignoreUnknown": false
15 | },
16 | "formatter": {
17 | "bracketSameLine": true,
18 | "enabled": true,
19 | "indentStyle": "space",
20 | "indentWidth": 2,
21 | "lineEnding": "lf"
22 | },
23 | "linter": {
24 | "enabled": true,
25 | "domains": {
26 | "next": "recommended",
27 | "react": "recommended",
28 | "tailwind": "recommended"
29 | },
30 | "rules": {
31 | "recommended": true
32 | }
33 | },
34 | "javascript": {
35 | "formatter": {
36 | "quoteStyle": "single",
37 | "semicolons": "asNeeded",
38 | "arrowParentheses": "asNeeded"
39 | }
40 | },
41 | "assist": {
42 | "enabled": true,
43 | "actions": {
44 | "source": {
45 | "organizeImports": "off"
46 | }
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Adam Akbar
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/hooks/use-auth.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // local
4 | import type { User } from '@/models/table-types/user'
5 | import type { Permission } from '@/enums/permission'
6 | import { useLocalStorage } from 'usehooks-ts'
7 |
8 | export default function useAuth() {
9 | const [loggedInUser, setLoggedInUser, removeLoggedInUser] = useLocalStorage<
10 | User | undefined
11 | >('logged-in-user', undefined)
12 |
13 | return {
14 | user: loggedInUser,
15 | setLoggedInUser: (user: User | undefined) =>
16 | user ? setLoggedInUser(user) : removeLoggedInUser(),
17 |
18 | /**
19 | * Checks if the user has any of the given permissions
20 | */
21 | hasAnyPermissions: (permissions: Permission[]): boolean => {
22 | if (!loggedInUser) {
23 | return false
24 | }
25 |
26 | return hasAnyPermissions(permissions, loggedInUser)
27 | },
28 | }
29 | }
30 |
31 | /**
32 | * Check if the user has any of the provided permissions
33 | */
34 | function hasAnyPermissions(permissions: Permission[], user: User): boolean {
35 | return permissions.some(permission => user.permissions.includes(permission))
36 | }
37 |
--------------------------------------------------------------------------------
/src/stores/input-error-message.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand'
2 |
3 | interface InputErrorMessageState {
4 | identifier: string
5 | message: string
6 | }
7 |
8 | interface ErrorMessageState {
9 | fields: InputErrorMessageState[]
10 | setErrorMessage: (identifier: string, message: string) => void
11 | clearErrorMessages: () => void
12 | }
13 |
14 | export const useErrorMessageState = create(set => ({
15 | fields: [],
16 | setErrorMessage: (identifier: string, message: string) =>
17 | set(state => {
18 | const existingFieldIndex = state.fields.findIndex(
19 | field => field.identifier === identifier,
20 | )
21 |
22 | if (existingFieldIndex !== -1) {
23 | const updatedFields = [...state.fields]
24 |
25 | updatedFields[existingFieldIndex] = { identifier, message }
26 |
27 | return { fields: updatedFields }
28 | }
29 |
30 | return {
31 | fields: [...state.fields, { identifier, message }],
32 | }
33 | }),
34 | clearErrorMessages: () =>
35 | set(() => {
36 | return {
37 | fields: [],
38 | }
39 | }),
40 | }))
41 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/purchases/(form)/[uuid]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // vendors
4 | import { Button } from '@heroui/button'
5 | import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card'
6 | //
7 | import { usePageHook } from './page-hook'
8 | import { use } from 'react'
9 |
10 | const FORM_ID = 'purchase-update-form'
11 |
12 | export default function PurchaseFormPage({
13 | params,
14 | }: {
15 | params: Promise<{ uuid: string }>
16 | }) {
17 | const { uuid } = use(params)
18 | const { handleSubmit, handleCancel, formValues } = usePageHook(uuid)
19 |
20 | return (
21 |
22 | Sunting Pembelian — NO. {formValues?.uuid}
23 |
24 |
25 |
30 |
31 |
32 |
33 |
34 | Batal
35 |
36 |
37 |
38 | Simpan
39 |
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/src/data/permission-templates.ts:
--------------------------------------------------------------------------------
1 | import { Permission } from '@/enums/permission'
2 | import { Role } from '@/enums/role'
3 |
4 | export const PERMISSION_TEMPLATES: {
5 | [Role.OWNER]: Permission[]
6 | [Role.MANAGER]: Permission[]
7 | [Role.CASHIER]: Permission[]
8 | [Role.STOCKER]: Permission[]
9 | } = {
10 | owner: [Permission.READ_REPORT, Permission.READ_DASHBOARD],
11 | manager: [
12 | Permission.READ_REPORT,
13 | Permission.READ_DASHBOARD,
14 | Permission.CREATE_PRODUCT,
15 | Permission.READ_PRODUCT,
16 | Permission.UPDATE_PRODUCT,
17 | Permission.DELETE_PRODUCT,
18 | Permission.CREATE_USER,
19 | Permission.READ_USER,
20 | Permission.UPDATE_USER,
21 | Permission.DELETE_USER,
22 | Permission.CREATE_PURCHASE,
23 | Permission.READ_PURCHASE,
24 | Permission.UPDATE_PURCHASE,
25 | Permission.DELETE_PURCHASE,
26 | Permission.CREATE_SALE,
27 | Permission.READ_SALE,
28 | Permission.UPDATE_SALE,
29 | Permission.DELETE_SALE,
30 | Permission.EXPORT_DB,
31 | Permission.SYNC_DB,
32 | Permission.WIPE_DB,
33 | ],
34 | cashier: [Permission.READ_SALE, Permission.CREATE_SALE],
35 | stocker: [Permission.READ_PURCHASE, Permission.CREATE_PURCHASE],
36 | }
37 |
--------------------------------------------------------------------------------
/src/instrumentation-client.ts:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the client.
2 | // The config you add here will be used whenever a users loads a page in their browser.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from '@sentry/nextjs'
6 |
7 | export const onRouterTransitionStart = Sentry.captureRouterTransitionStart
8 |
9 | if (process.env.VERCEL_ENV === 'production') {
10 | Sentry.init({
11 | dsn: 'https://0abb76d5407d5260770cdba7c3c07af0@o1289319.ingest.us.sentry.io/4507935813730304',
12 |
13 | // Add optional integrations for additional features
14 | integrations: [Sentry.replayIntegration()],
15 |
16 | // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
17 | tracesSampleRate: 1,
18 |
19 | // Define how likely Replay events are sampled.
20 | // This sets the sample rate to be 10%. You may want this to be 100% while
21 | // in development and sample at a lower rate in production
22 | replaysSessionSampleRate: 0.1,
23 |
24 | // Define how likely Replay events are sampled when an error occurs.
25 | replaysOnErrorSampleRate: 1.0,
26 |
27 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
28 | debug: false,
29 | })
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/_components/product-card/_hook.tsx:
--------------------------------------------------------------------------------
1 | // types
2 | import type { Product } from '@/models/table-types/product'
3 | // vendors
4 | import dayjs from 'dayjs'
5 | import { useState } from 'react'
6 | // components
7 | import { ConfirmationModal } from '@/components/confirmation-modal'
8 | import { toast } from '@/functions/toast'
9 | // models
10 | import db from '@/models/db'
11 |
12 | export function useHook(productUuid: Product['uuid']) {
13 | const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
14 |
15 | return {
16 | handleOpenDeleteModal: () => {
17 | setIsDeleteModalOpen(true)
18 | },
19 |
20 | deleteConfirmationModal: (
21 | {
24 | setIsDeleteModalOpen(false)
25 | }}
26 | onAccept={() => {
27 | db.products
28 | .update(productUuid, { deleted_at: dayjs().toISOString() })
29 | .then(() => {
30 | setIsDeleteModalOpen(false)
31 | toast('Produk berhasil dihapus', 'warning')
32 | })
33 | .catch((err: Error) => {
34 | throw err
35 | })
36 | }}>
37 | Apakah Anda yakin ingin menghapus produk ini?
38 |
39 | ),
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/purchases/(form)/create/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // vendors
4 | import { Button } from '@heroui/button'
5 | import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card'
6 | import { FormProvider } from 'react-hook-form'
7 | //
8 | import { usePageHook } from './page-hook'
9 | import { ProductPurchaseForm } from '../_components/form'
10 |
11 | const FORM_ID = 'purchase-create-form'
12 |
13 | export default function PurchaseFormPage() {
14 | const { handleSubmit, handleCancel, formContextValue } = usePageHook()
15 |
16 | return (
17 |
18 |
19 | Tambah Data Pengadaan Stok
20 |
21 |
22 |
23 | {
26 | handleSubmit().catch(err => {
27 | throw err
28 | })
29 | }}
30 | />
31 |
32 |
33 |
34 |
35 |
36 | Batal
37 |
38 |
39 |
40 | Simpan
41 |
42 |
43 |
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/users/(form)/create/hook.ts:
--------------------------------------------------------------------------------
1 | // types
2 | import type { FormValues } from '../_types/form-values'
3 | // vendors
4 | import { useForm } from 'react-hook-form'
5 | import { useLiveQuery } from 'dexie-react-hooks'
6 | import { useRouter } from 'next/navigation'
7 | // functions
8 | import { generateOrderedUuid } from '@/functions/generate-ordered-uuid'
9 | import { getValidatedFormValues } from '../_functions/get-validated-form-values'
10 | import { toast } from '@/functions/toast'
11 | // db
12 | import db from '@/models/db'
13 |
14 | export function useHook() {
15 | const router = useRouter()
16 |
17 | const formContextValue = useForm()
18 |
19 | return {
20 | formContextValue,
21 |
22 | userEmails:
23 | useLiveQuery(() => db.users.toArray())?.map(user => user.email) ?? [],
24 |
25 | handleCancel: () => {
26 | router.back()
27 | },
28 |
29 | handleSubmit: formContextValue.handleSubmit(formValues => {
30 | formValues.uuid = generateOrderedUuid()
31 | formValues.created_at = new Date().toISOString()
32 | formValues.updated_at = new Date().toISOString()
33 |
34 | const newUser = getValidatedFormValues(formValues)
35 |
36 | db.users
37 | .add(newUser)
38 | .then(() => {
39 | toast('Data pengguna berhasil disimpan')
40 | router.back()
41 | })
42 | .catch(() => {
43 | toast('Data pengguna gagal disimpan')
44 | })
45 | }),
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 💼📊 Sensasi POS
2 |
3 | **Sensasi POS** adalah aplikasi Point of Sale sederhana yang dirancang untuk membantu pengusaha kecil dan menengah dalam mengelola bisnis! 🌐💡
4 |
5 | Data transaksi disimpan di dalam masing-masing perangkat sehingga tidak memerlukan internet untuk menggunakannya. 🎉
6 |
7 | Aplikasi ini memiliki berbagai fitur yang memudahkan pengguna dalam melakukan transaksi, mengelola stok barang, dan melacak penjualan.
8 |
9 | ## ✨ Fitur Utama
10 |
11 | - 🗒️ Pencatatan penjualan.
12 | - 🛒 Pencatatan pengadaan/pembelian/penyetokan barang.
13 | - 📦 Pengawasan jumlah/stok barang secara.
14 | - 📈 Laporan penjualan, pembelian, pergerakan barang, dan marjin secara ringkas dan terperinci.
15 |
16 | Selain fitur-fitur di atas, **Sensasi POS** juga memiliki fitur-fitur lain yang dapat mengakomodasi data penjualan dan pembelian dari beberapa cabang usaha.
17 |
18 | ## 📖 Cara Penggunaan
19 |
20 | Ingin tahu cara menggunakan **Sensasi POS**? Ikuti panduan langkah demi langkah di sini: 👉 [Pedoman Penggunaan Sensasi POS](https://github.com/sensasi-apps/sensasi-pos#user-guide)
21 |
22 | ## 🤝 Kontribusi
23 |
24 | Kami menyambut kontribusi dari siapa pun! Jika ingin membantu mengembangkan **Sensasi POS**, silakan membaca [panduan kontribusi](https://github.com/sensasi-apps/sensasi-pos/blob/main/CONTRIBUTING.md) untuk informasi lebih lanjut.
25 |
26 | ## Lisensi
27 |
28 | **Sensasi POS** bersifat _open-source_ dan dilindungi Lisensi MIT. Silakan membaca _file_ [LICENSE](https://github.com/sensasi-apps/sensasi-pos/blob/main/LICENSE) untuk informasi lebih lanjut.
29 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/(form)/_components/page-template.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // vendors
4 | import { Button } from '@heroui/button'
5 | import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card'
6 | // sub-components
7 | import { ProductForm } from '@/app/(authenticated user)/data/products/(form)/_components/product-form'
8 | import type { useHook as updateUseHook } from '../[uuid]/_hook'
9 | import type { useHook as createUseHook } from '../create/_hook'
10 |
11 | export function ProductFormPageTemplate(
12 | props: ReturnType | ReturnType,
13 | ) {
14 | const { product, handleSubmit, handleCancel } = props
15 |
16 | const cardTitile = product?.name
17 | ? `Perbaharui Data — ${product.name}`
18 | : 'Masukkan Data Produk'
19 |
20 | return (
21 |
22 |
23 | {cardTitile}
24 |
25 |
26 | {product && (
27 |
32 | )}
33 |
34 |
35 |
36 |
37 | Batal
38 |
39 |
40 |
41 | Simpan
42 |
43 |
44 |
45 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/src/app/(guest)/reset-password/_components/reset-password-form/_components/password-field.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import { Input } from '@heroui/input'
3 | // icons
4 | import { Eye, EyeOff } from 'lucide-react'
5 | //
6 | import { usePasswordVisibilityState } from '../_stores/password-visibility'
7 | import { useFormSubmissionState } from '@/stores/form-submission'
8 | import { useErrorMessageState } from '@/stores/input-error-message'
9 |
10 | export default function PasswordField() {
11 | // Stores
12 | const { isSubmitting } = useFormSubmissionState()
13 | const { isVisible, toggleVisibility } = usePasswordVisibilityState(
14 | state => state.password,
15 | )
16 | const { fields } = useErrorMessageState()
17 |
18 | const errorMessage = fields.find(
19 | field => field.identifier === 'password',
20 | )?.message
21 |
22 | return (
23 | toggleVisibility()}
36 | aria-label="toggle password visibility">
37 | {isVisible ? (
38 |
39 | ) : (
40 |
41 | )}
42 |
43 | }
44 | type={isVisible ? 'text' : 'password'}
45 | />
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/input-controllers/user-email.tsx:
--------------------------------------------------------------------------------
1 | import db from '@/models/db'
2 | import { Input, type InputProps } from '@heroui/input'
3 | import { useLiveQuery } from 'dexie-react-hooks'
4 | import { Controller, type FieldValues, useFormContext } from 'react-hook-form'
5 |
6 | export default function UserEmail({
7 | notEqual,
8 | slotProps,
9 | }: {
10 | notEqual?: string
11 | slotProps?: { input?: InputProps }
12 | }) {
13 | const { control } = useFormContext()
14 |
15 | const emailExists =
16 | useLiveQuery(() => {
17 | const query = db.users
18 |
19 | if (notEqual) {
20 | query.where('email').notEqual(notEqual)
21 | }
22 |
23 | return db.users.toArray()
24 | })?.map(user => user.email) ?? []
25 |
26 | return (
27 | {
33 | if (!value) return 'Surel harus diisi'
34 |
35 | if (!new RegExp(/^.+@.+\..+$/).test(value)) return 'Surel tidak valid'
36 |
37 | if (emailExists.includes(value)) return 'Surel sudah digunakan'
38 |
39 | return true
40 | },
41 | }}
42 | render={({ field: { value, ...rest }, fieldState: { error } }) => (
43 |
54 | )}
55 | />
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/src/app/_layout-components/navbar/components/feedback-form-modal/hooks.ts:
--------------------------------------------------------------------------------
1 | import * as Sentry from '@sentry/nextjs'
2 | import { useState } from 'react'
3 | import { useDebouncedCallback } from 'use-debounce'
4 |
5 | /**
6 | * Custom hook to manage the state and behavior of a feedback form modal.
7 | */
8 | export function useFeedbackFormModalHook() {
9 | const [message, setMessage] = useState('')
10 | const [isSubmitted, setIsSubmitted] = useState(false)
11 |
12 | const setMessageDebounced = useDebouncedCallback(setMessage, 500)
13 |
14 | return {
15 | /**
16 | * Determines whether the form data is valid.
17 | */
18 | isFormDataValid: !!message && message.length >= 10,
19 |
20 | /**
21 | * Determines whether the form has been submitted.
22 | */
23 | isSubmitted,
24 |
25 | /**
26 | * Handles the change of form data.
27 | */
28 | handleFormDataChange: (
29 | // key: 'message',
30 | value: string,
31 | ) => {
32 | // if (key !== 'message') throw new Error('Invalid key')
33 |
34 | setMessageDebounced(value)
35 | },
36 |
37 | /**
38 | * Handles the submission of the form.
39 | *
40 | * @see https://docs.sentry.io/platforms/javascript/guides/nextjs/user-feedback/#user-feedback-api
41 | */
42 | handleSubmit: () => {
43 | Sentry.captureFeedback({
44 | message,
45 | associatedEventId: Sentry.lastEventId(),
46 | })
47 |
48 | setIsSubmitted(true)
49 | },
50 |
51 | /**
52 | * Handles the reset of the form.
53 | */
54 | handleReset: () => {
55 | setMessage('')
56 | setIsSubmitted(false)
57 | },
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/app/(guest)/reset-password/_components/reset-password-form/_components/confirm-password-field.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import { Input } from '@heroui/input'
3 | // icons
4 | import { Eye, EyeOff } from 'lucide-react'
5 | //
6 | import { usePasswordVisibilityState } from '../_stores/password-visibility'
7 | import { useFormSubmissionState } from '@/stores/form-submission'
8 | import { useErrorMessageState } from '@/stores/input-error-message'
9 |
10 | export default function ConfirmPasswordField() {
11 | // Stores
12 | const { isSubmitting } = useFormSubmissionState()
13 | const { isVisible, toggleVisibility } = usePasswordVisibilityState(
14 | state => state.confirmPassword,
15 | )
16 | const { fields } = useErrorMessageState()
17 |
18 | const errorMessage = fields.find(
19 | field => field.identifier === 'confirm_password',
20 | )?.message
21 |
22 | return (
23 | toggleVisibility()}
36 | aria-label="toggle confirm-password visibility">
37 | {isVisible ? (
38 |
39 | ) : (
40 |
41 | )}
42 |
43 | }
44 | type={isVisible ? 'text' : 'password'}
45 | />
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/src/app/(guest)/forgot-password/_components/email-form.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import { Button } from '@heroui/button'
3 | import { type FormEvent, useState } from 'react'
4 | import { Input } from '@heroui/input'
5 | //
6 | import { useFormSubmissionState } from '@/stores/form-submission'
7 |
8 | export default function EmailForm() {
9 | // States
10 | const [hasValidEmail, setHasValidEmail] = useState(false)
11 |
12 | // Stores
13 | const { isSubmitting, toggleSubmitting } = useFormSubmissionState()
14 |
15 | const handleForgotPasswordByEmail = (event: FormEvent) => {
16 | event.preventDefault()
17 |
18 | toggleSubmitting()
19 |
20 | setTimeout(() => {
21 | toggleSubmitting()
22 | }, 2000)
23 | }
24 |
25 | return (
26 | <>
27 |
28 |
29 | Masukkan Surel Anda Untuk Menerima Tautan Atur Ulang Kata Sandi
30 |
31 |
32 |
33 |
55 | >
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/(form)/create/_hook.ts:
--------------------------------------------------------------------------------
1 | // types
2 | import type { Product } from '@/models/table-types/product'
3 | import type { ProductFormProps } from '../_components/product-form/props'
4 | // vendors
5 | import { useRouter } from 'next/navigation'
6 | import dayjs from 'dayjs'
7 | // globals
8 | import { generateOrderedUuid } from '@/functions/generate-ordered-uuid'
9 | import PageUrlEnum from '@/enums/page-url'
10 | // models
11 | import db from '@/models/db'
12 | import { toast } from '@/functions/toast'
13 |
14 | /**
15 | * Custom hook for handling product form creation.
16 | *
17 | * @returns An object containing:
18 | * - `product`: The product form data.
19 | * - `handleCancel`: Function to navigate back to the previous page.
20 | * - `handleSubmit`: Function to handle form submission, add a new product to the database, and navigate to the product list page.
21 | */
22 | export function useHook() {
23 | const router = useRouter()
24 |
25 | return {
26 | product: {} as ProductFormProps['data'],
27 |
28 | handleCancel: () => {
29 | router.back()
30 | },
31 |
32 | handleSubmit: (values: ProductFormProps['data']) => {
33 | db.products
34 | .add({
35 | ...values,
36 | uuid: generateOrderedUuid(),
37 | stocks: [],
38 | created_at: dayjs().toISOString(),
39 | updated_at: dayjs().toISOString(),
40 | } as Product)
41 | .then(() => {
42 | router.push(PageUrlEnum.PRODUCT_LIST)
43 | toast('Produk berhasil ditambahkan')
44 | })
45 | .catch(handleFailure)
46 | },
47 | }
48 | }
49 |
50 | function handleFailure(err: Error) {
51 | throw err
52 | }
53 |
--------------------------------------------------------------------------------
/src/app/(guest)/forgot-password/_components/security-question/question-and-answer-field.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import { Input } from '@heroui/input'
3 | import { Select, SelectItem, type SelectProps } from '@heroui/select'
4 | //
5 | import type { SecurityQuestion } from '@/enums/security-question'
6 | import { useFormSubmissionState } from '@/stores/form-submission'
7 | import { useSecurityQuestionState } from '../../_stores/security-question'
8 |
9 | export default function QuestionAndAnswerField({
10 | securityQuestions,
11 | questionNumber,
12 | }: {
13 | securityQuestions: [string, SecurityQuestion][]
14 | questionNumber: number
15 | }) {
16 | // Stores
17 | const { isSubmitting } = useFormSubmissionState()
18 | const { pushSelectedQuestionNumbers, hasQuestionNumber } =
19 | useSecurityQuestionState()
20 |
21 | const handleSelectionChange: SelectProps['onSelectionChange'] = ({
22 | currentKey,
23 | }) => {
24 | if (!currentKey) return
25 |
26 | pushSelectedQuestionNumbers(questionNumber)
27 | }
28 |
29 | return (
30 | <>
31 |
37 | {securityQuestions.map(([key, value]: [string, string]) => (
38 | {value}
39 | ))}
40 |
41 |
42 |
48 | >
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/next-configs/with-sentry-config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from 'next'
2 | import { withSentryConfig as withSentryConfigVendor } from '@sentry/nextjs'
3 |
4 | const withSentryConfig = (nextConfig: NextConfig) =>
5 | withSentryConfigVendor(nextConfig, {
6 | // For all available options, see:
7 | // https://github.com/getsentry/sentry-webpack-plugin#options
8 |
9 | org: 'sensasi-apps',
10 | project: 'sensasi-pos',
11 | sentryUrl: 'https://sentry.io/',
12 |
13 | // Only print logs for uploading source maps in CI
14 | silent: !process.env.CI,
15 |
16 | // For all available options, see:
17 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
18 |
19 | // Upload a larger set of source maps for prettier stack traces (increases build time)
20 | widenClientFileUpload: true,
21 |
22 | // Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
23 | // This can increase your server load as well as your hosting bill.
24 | // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
25 | // side errors will fail.
26 | // tunnelRoute: "/monitoring",
27 |
28 | sourcemaps: {
29 | disable: true,
30 | },
31 |
32 | // Automatically tree-shake Sentry logger statements to reduce bundle size
33 | disableLogger: true,
34 |
35 | // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
36 | // See the following for more information:
37 | // https://docs.sentry.io/product/crons/
38 | // https://vercel.com/docs/cron-jobs
39 | automaticVercelMonitors: true,
40 | })
41 |
42 | export default withSentryConfig
43 |
--------------------------------------------------------------------------------
/src/components/bar-chart.tsx:
--------------------------------------------------------------------------------
1 | import { dataPenjualan } from '@/data/penjualan'
2 | import { useTheme } from 'next-themes'
3 | import {
4 | ResponsiveContainer,
5 | BarChart as Chart,
6 | CartesianGrid,
7 | XAxis,
8 | YAxis,
9 | Tooltip,
10 | Legend,
11 | Bar,
12 | Rectangle,
13 | Label,
14 | } from 'recharts'
15 |
16 | interface BarChartProps {
17 | total?: number | string
18 | }
19 |
20 | const BarChart = ({ total }: BarChartProps) => {
21 | const { resolvedTheme } = useTheme()
22 |
23 | return (
24 |
25 |
33 |
34 |
35 |
36 |
42 |
43 |
44 |
45 | }
49 | />
50 | }
54 | />
55 |
56 |
57 | )
58 | }
59 |
60 | export default BarChart
61 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/users/(form)/[uuid]/hook.ts:
--------------------------------------------------------------------------------
1 | // types
2 | import type { FormValues } from '../_types/form-values'
3 | import type { UUID } from 'node:crypto'
4 | // vendors
5 | import { useForm } from 'react-hook-form'
6 | import { useLiveQuery } from 'dexie-react-hooks'
7 | import { useRouter } from 'next/navigation'
8 | // functions
9 | import { toast } from '@/functions/toast'
10 | // db
11 | import db from '@/models/db'
12 | import { getValidatedFormValues } from '../_functions/get-validated-form-values'
13 |
14 | export function useHook(userUuid: UUID) {
15 | const router = useRouter()
16 |
17 | const formContextValue = useForm()
18 |
19 | db.users
20 | .get(userUuid)
21 | .then(user => {
22 | formContextValue.reset({
23 | name: user?.name,
24 | email: user?.email,
25 | roles: user?.roles,
26 | })
27 | })
28 | .catch(() => {
29 | toast('Data pengguna tidak ditemukan', 'danger')
30 | router.back()
31 | })
32 |
33 | return {
34 | formContextValue,
35 |
36 | userEmails:
37 | useLiveQuery(() =>
38 | db.users.where('uuid').notEqual(userUuid).toArray(),
39 | )?.map(user => user.email) ?? [],
40 |
41 | handleCancel: () => {
42 | router.back()
43 | },
44 |
45 | handleSubmit: formContextValue.handleSubmit(formValues => {
46 | const updatedUser = getValidatedFormValues(formValues)
47 |
48 | db.users
49 | .update(userUuid, {
50 | ...updatedUser,
51 | })
52 | .then(() => {
53 | toast('Data pengguna berhasil disimpan')
54 | router.back()
55 | })
56 | .catch(() => {
57 | toast('Data pengguna gagal disimpan', 'danger')
58 | })
59 | }),
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import type { Metadata } from 'next'
3 | import { Analytics } from '@vercel/analytics/react'
4 | // local modules
5 | import './_layout-components/main.css'
6 | import Providers from './_layout-components/providers'
7 | import { PageIndicator } from './_layout-components/page-indicator'
8 | import { sans } from './_layout-components/font'
9 | import ProgressBar from '@/components/progress-bar'
10 | import Navbar from './_layout-components/navbar'
11 |
12 | export const metadata: Metadata = {
13 | title: 'Sensasi POS — Aplikasi Point of Sale Sederhana',
14 | description:
15 | 'Aplikasi Point of Sale sederhana yang dirancang untuk membantu pencatatan penjualan barang pada Warung / Toko / UMKM / Stan / Gerai / Swalayan.',
16 | }
17 |
18 | export default function RootLayout({
19 | children,
20 | }: Readonly<{
21 | children: React.ReactNode
22 | }>) {
23 | const isProduction = process.env.NODE_ENV === 'production'
24 |
25 | return (
26 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | ⓘ Aplikasi masih dalam tahap pengembangan. Silakan kunjungi lagi
42 | nanti.
43 |
44 |
45 | {children}
46 |
47 |
48 |
49 |
50 |
51 | {isProduction && }
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/enums/page-url.ts:
--------------------------------------------------------------------------------
1 | enum PageUrlEnum {
2 | HOME = '/',
3 | DASHBOARD = '/dashboard',
4 | APP_SETTING_PAGE_URL = '/settings',
5 | LOGIN = '/login',
6 | LOGOUT = '/logout',
7 | RESET_PASSWORD = '/reset-password',
8 | FORGOT_PASSWORD = '/forgot-password',
9 |
10 | // ONBOARDING
11 | ONBOARDING = '/onboarding',
12 | ONBOARDING_STEP_1 = '/onboarding/steps/1-add-admin-user',
13 | ONBOARDING_STEP_2 = '/onboarding/steps/2-add-products-and-stock',
14 | ONBOARDING_STEP_FINISH = '/onboarding/steps/finish',
15 | ONBOARDING_IMPORT_BACKUP = '/onboarding/import-backup',
16 |
17 | // REPORT
18 | REPORT_LIST = '/reports',
19 | REPORT_SALE_PER_TX = '/reports/sale-per-tx',
20 | REPORT_SALE_PER_PRODUCT = '/reports/sale-per-product',
21 | REPORT_SALE_PER_PRODUCT_CATEGORY = '/reports/sale-per-product-category',
22 | REPORT_STOCK_IN_OUT_PER_PRODUCT = '/reports/stock-in-out-per-product',
23 |
24 | // ############# PARENT DATA #############
25 | // PRODUCT
26 | PRODUCT_LIST = '/data/products',
27 | PRODUCT_CREATE = '/data/products/create',
28 | PRODUCT_EDIT = '/data/products/:uuid',
29 | PRODUCT_DELETE = '/data/products/:uuid/delete',
30 |
31 | // USER
32 | USER_LIST = '/data/users',
33 | USER_CREATE = '/data/users/create',
34 | USER_EDIT = '/data/users/:uuid',
35 | USER_DELETE = '/data/users/:uuid/delete',
36 |
37 | // ############# TRANSACTIONAL DATA #############
38 | // PURCHASE
39 | PURCHASE_LIST = '/purchases',
40 | PURCHASE_CREATE = '/purchases/create',
41 | PURCHASE_EDIT = '/purchases/:uuid',
42 | PURCHASE_DELETE = '/purchases/:uuid/delete',
43 |
44 | // SALE
45 | SALE_LIST = '/sales',
46 | SALE_CREATE = '/sales/create',
47 | SALE_EDIT = '/sales/:uuid',
48 | SALE_DELETE = '/sales/:uuid/delete',
49 |
50 | // DATABASE
51 | EXPORT_DATABASE = '/settings/database/export',
52 | SYNC_DATABASE = '/settings/database/sync',
53 | WIPE_DATABASE = '/settings/database/wipe',
54 | }
55 |
56 | export default PageUrlEnum
57 |
--------------------------------------------------------------------------------
/src/app/(guest)/forgot-password/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // vendors
4 | import { Card, CardBody, CardHeader } from '@heroui/card'
5 | import { Divider } from '@heroui/divider'
6 | import { useSearchParams } from 'next/navigation'
7 | import Link from 'next/link'
8 | //
9 | import { useFormSubmissionState } from '@/stores/form-submission'
10 | import EmailForm from './_components/email-form'
11 | import PageUrlEnum from '@/enums/page-url'
12 | import SecurityQuestionForm from './_components/security-question'
13 | import { Suspense } from 'react'
14 | import { Spinner } from '@heroui/spinner'
15 |
16 | export default function Page() {
17 | return (
18 |
19 |
20 |
21 |
22 |
23 | Lupa Kata Sandi
24 |
25 |
26 |
27 |
28 | }>
29 |
30 |
31 |
32 |
33 |
34 |
35 | )
36 | }
37 |
38 | function CardContent() {
39 | const { isSubmitting } = useFormSubmissionState()
40 | const query = useSearchParams()
41 | const selectedMethod = query.get('method')
42 |
43 | return (
44 | <>
45 | {selectedMethod === 'security-question' ? (
46 |
47 | ) : (
48 |
49 | )}
50 |
51 |
54 | Coba Cara Lain?
55 |
56 | >
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/src/data/penjualan.ts:
--------------------------------------------------------------------------------
1 | interface DataPenjualan {
2 | id: number
3 | tanggal: string
4 | kopi: number
5 | gorengan: number
6 | roti: number
7 | total_penjualan: number
8 | total_pembelian: number
9 | total_keuntungan: number
10 | }
11 |
12 | /**
13 | * Data dummy untuk pengembangan UI
14 | *
15 | * @todo Hapus data dummy ini ketika sudah terhubung dengan API
16 | */
17 | export const dataPenjualan: DataPenjualan[] = [
18 | {
19 | id: 1,
20 | tanggal: '2021-08-01',
21 | kopi: 50,
22 | gorengan: 25,
23 | roti: 15,
24 | total_penjualan: 1000000,
25 | total_pembelian: 500000,
26 | total_keuntungan: 500000,
27 | },
28 | {
29 | id: 2,
30 | tanggal: '2021-08-02',
31 | kopi: 40,
32 | gorengan: 37,
33 | roti: 10,
34 | total_penjualan: 2000000,
35 | total_pembelian: 1000000,
36 | total_keuntungan: 1000000,
37 | },
38 | {
39 | id: 3,
40 | tanggal: '2021-08-03',
41 | kopi: 41,
42 | gorengan: 29,
43 | roti: 20,
44 | total_penjualan: 3000000,
45 | total_pembelian: 1500000,
46 | total_keuntungan: 1500000,
47 | },
48 | {
49 | id: 4,
50 | tanggal: '2021-08-04',
51 | kopi: 30,
52 | gorengan: 20,
53 | roti: 11,
54 | total_penjualan: 4000000,
55 | total_pembelian: 2000000,
56 | total_keuntungan: 2000000,
57 | },
58 | {
59 | id: 5,
60 | tanggal: '2021-08-05',
61 | kopi: 31,
62 | gorengan: 22,
63 | roti: 15,
64 | total_penjualan: 5000000,
65 | total_pembelian: 2500000,
66 | total_keuntungan: 2500000,
67 | },
68 | {
69 | id: 6,
70 | tanggal: '2021-08-06',
71 | kopi: 25,
72 | gorengan: 20,
73 | roti: 10,
74 | total_penjualan: 6000000,
75 | total_pembelian: 3000000,
76 | total_keuntungan: 3000000,
77 | },
78 | {
79 | id: 7,
80 | tanggal: '2021-08-07',
81 | kopi: 30,
82 | gorengan: 25,
83 | roti: 15,
84 | total_penjualan: 7000000,
85 | total_pembelian: 3500000,
86 | total_keuntungan: 3500000,
87 | },
88 | ]
89 |
--------------------------------------------------------------------------------
/src/app/(guest)/reset-password/_components/reset-password-form/_components/pin-field.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import type { FormEvent } from 'react'
3 | import { Input } from '@heroui/input'
4 | // icons
5 | import { Eye, EyeOff } from 'lucide-react'
6 | //
7 | import { usePinVisibilityState } from '../_stores/pin-visibility'
8 | import { useFormSubmissionState } from '@/stores/form-submission'
9 | import { useErrorMessageState } from '@/stores/input-error-message'
10 |
11 | export default function PinField() {
12 | // Stores
13 | const { isSubmitting } = useFormSubmissionState()
14 | const { isVisible, toggleVisibility } = usePinVisibilityState(
15 | state => state.pin,
16 | )
17 | const { fields } = useErrorMessageState()
18 |
19 | const errorMessage = fields.find(field => field.identifier === 'pin')?.message
20 |
21 | const handleInput = (event: FormEvent) => {
22 | const element = event.target as HTMLInputElement
23 | const value = element.value
24 |
25 | if (!/^\d*$/.test(value)) {
26 | element.value = value.replace(/[^\d]/g, '')
27 | }
28 |
29 | if (element.value.length > 6) {
30 | element.value = element.value.slice(0, 6)
31 | }
32 | }
33 |
34 | return (
35 | toggleVisibility()}
50 | aria-label="toggle pin visibility">
51 | {isVisible ? (
52 |
53 | ) : (
54 |
55 | )}
56 |
57 | }
58 | type={isVisible ? 'text' : 'password'}
59 | />
60 | )
61 | }
62 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/purchases/(form)/create/page-hook.ts:
--------------------------------------------------------------------------------
1 | // types
2 | import type { FormValues } from '../_types/form-values'
3 | import type { ProductMovement } from '@/models/table-types/product-movement'
4 | // vendors
5 | import { useRouter } from 'next/navigation'
6 | import { useForm } from 'react-hook-form'
7 | import db from '@/models/db'
8 | // globals
9 | import { generateOrderedUuid } from '@/functions/generate-ordered-uuid'
10 | import { toast } from '@/functions/toast'
11 | // locals
12 | import { updateStocksOnReceived } from '../_functions/update-stocks-on-received'
13 | import useAuth from '@/hooks/use-auth'
14 |
15 | /**
16 | * Custom hook for handling the page logic in the purchase form.
17 | */
18 | export function usePageHook() {
19 | const formContextValue = useForm()
20 | const router = useRouter()
21 | const { user } = useAuth()
22 |
23 | return {
24 | /**
25 | * The context value for the form.
26 | */
27 | formContextValue,
28 |
29 | /**
30 | * Handler function to navigate back to the previous page.
31 | */
32 | handleCancel: () => {
33 | router.back()
34 | },
35 |
36 | /**
37 | * Handler function to submit the form data.
38 | */
39 | handleSubmit: formContextValue.handleSubmit(async formValues => {
40 | const productMovement = {
41 | ...formValues,
42 | type: 'purchase',
43 | uuid: generateOrderedUuid(),
44 | by_user_state: user,
45 | created_at: new Date().toISOString(),
46 | updated_at: new Date().toISOString(),
47 | } as ProductMovement // TODO: Should have other mechanism to determine the type of the product movement instead casting it like this
48 |
49 | await db.productMovements.add(productMovement)
50 |
51 | if (
52 | 'received_at' in productMovement.additional_info &&
53 | productMovement.additional_info.received_at !== undefined
54 | ) {
55 | updateStocksOnReceived(productMovement)
56 | }
57 |
58 | toast('Data pengadaan berhasil disimpan')
59 | router.back()
60 | }),
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/app/_page-sections/faq.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Accordion, AccordionItem } from '@heroui/accordion'
4 |
5 | export function Faq() {
6 | return (
7 |
8 | Pertanyaan Umum
9 |
10 |
11 |
12 | Sensasi POS adalah aplikasi Point of Sale sederhana yang dirancang
13 | untuk membantu pencatatan penjualan barang pada Warung / Toko / UMKM /
14 | Stan / Gerai / Swalayan.
15 |
16 |
17 |
18 | Sensasi POS adalah aplikasi POS yang dapat digunakan tanpa koneksi
19 | internet. Selain itu, aplikasi ini juga gratis dan tidak ada biaya
20 | berlangganan.
21 |
22 |
23 |
24 | Fitur yang ada di Sensasi POS antara lain:
25 |
26 | Manajemen Produk.
27 | Manajemen Penjualan.
28 | Manajemen Stok.
29 | Laporan Keuangan / Marjin.
30 | ...dan masih banyak lagi
31 |
32 |
33 |
34 |
35 | Pengguna dapat langsung menggunakan aplikasi tanpa perlu mendaftar.
36 | Cukup buka aplikasi di browser dan mulai gunakan.
37 |
38 |
39 |
40 | Tidak, Sensasi POS bisa digunakan sepenuhnya tanpa jaringan internet.
41 |
42 |
43 |
44 | Data disimpan secara lokal di browser dan dapat disinkronisasi dengan
45 | perangkat lain.
46 |
47 |
48 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/src/app/(guest)/reset-password/_components/reset-password-form/_components/confirm-pin-field.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import type { FormEvent } from 'react'
3 | import { Input } from '@heroui/input'
4 | // icons
5 | import { Eye, EyeOff } from 'lucide-react'
6 | //
7 | import { usePinVisibilityState } from '../_stores/pin-visibility'
8 | import { useFormSubmissionState } from '@/stores/form-submission'
9 | import { useErrorMessageState } from '@/stores/input-error-message'
10 |
11 | export default function ConfirmPinField() {
12 | // Stores
13 | const { isSubmitting } = useFormSubmissionState()
14 | const { isVisible, toggleVisibility } = usePinVisibilityState(
15 | state => state.confirmPin,
16 | )
17 | const { fields } = useErrorMessageState()
18 |
19 | const errorMessage = fields.find(
20 | field => field.identifier === 'confirm_pin',
21 | )?.message
22 |
23 | const handleInput = (event: FormEvent) => {
24 | const element = event.target as HTMLInputElement
25 | const value = element.value
26 |
27 | if (!/^\d*$/.test(value)) {
28 | element.value = value.replace(/[^\d]/g, '')
29 | }
30 |
31 | if (element.value.length > 6) {
32 | element.value = element.value.slice(0, 6)
33 | }
34 | }
35 |
36 | return (
37 | toggleVisibility()}
53 | aria-label="toggle confirm-pin visibility">
54 | {isVisible ? (
55 |
56 | ) : (
57 |
58 | )}
59 |
60 | }
61 | type={isVisible ? 'text' : 'password'}
62 | />
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/(form)/_components/product-form/hook.ts:
--------------------------------------------------------------------------------
1 | import type { Product } from '@/models/table-types/product'
2 | import { type FormEvent, useState } from 'react'
3 | import type { ProductFormProps } from './props'
4 | import { useDebouncedCallback } from 'use-debounce'
5 |
6 | export function useHook(
7 | data: ProductFormProps['data'],
8 | onSubmit?: ProductFormProps['onSubmit'],
9 | ) {
10 | const [formValues, setFormValues] = useState(data)
11 | const [errors, setErrors] = useState>({})
12 | const [isBarcodeScannerModalOpen, setIsBarcodeScannerModalOpen] =
13 | useState(false)
14 |
15 | const debouncedSetFormValues = useDebouncedCallback(setFormValues, 250)
16 |
17 | return {
18 | errors,
19 | formValues,
20 | isBarcodeScannerModalOpen,
21 |
22 | handleBarcodeReaderError: (_: string, message: string) => {
23 | setErrors({ barcode_reg_id: message })
24 | },
25 |
26 | handleOpenBarcodeScannerModal: () => {
27 | setIsBarcodeScannerModalOpen(true)
28 | },
29 |
30 | handleCloseBarcodeScannerModal: () => {
31 | setIsBarcodeScannerModalOpen(false)
32 | },
33 |
34 | handleValueChange: (key: keyof Product, value: Product[keyof Product]) => {
35 | debouncedSetFormValues(prev => ({
36 | ...prev,
37 | [key]: value,
38 | }))
39 |
40 | if (errors[key]) {
41 | setErrors(prev => {
42 | prev[key] = undefined
43 | return prev
44 | })
45 | }
46 | },
47 |
48 | handleSubmit: (ev: FormEvent) => {
49 | ev.preventDefault()
50 |
51 | const errors = validate(formValues)
52 |
53 | if (Object.keys(errors).length) {
54 | setErrors(errors)
55 | return
56 | }
57 |
58 | onSubmit?.(formValues)
59 | },
60 | }
61 | }
62 |
63 | function validate(values: ProductFormProps['data']) {
64 | const errors: Record = {}
65 |
66 | if (!values.name) {
67 | errors.name = 'Nama tidak boleh kosong'
68 | }
69 |
70 | if (!values.qty_unit) {
71 | errors.qty_unit = 'Satuan tidak boleh kosong'
72 | }
73 |
74 | if (!values.default_price) {
75 | errors.default_price = 'Harga jual default tidak boleh kosong'
76 | }
77 |
78 | return errors
79 | }
80 |
--------------------------------------------------------------------------------
/src/models/table-types/user.ts:
--------------------------------------------------------------------------------
1 | import type { ISODate } from '@/@types/iso-date'
2 | import type { Permission } from '@/enums/permission'
3 | import type { Role } from '@/enums/role'
4 | import type { UUID } from 'node:crypto'
5 |
6 | /**
7 | * Represents a user in the system.
8 | */
9 | export interface User {
10 | /**
11 | * The unique identifier for the user.
12 | */
13 | uuid: Readonly
14 |
15 | /**
16 | * The name of the user.
17 | */
18 | name: string
19 |
20 | /**
21 | * The email address of the user.
22 | */
23 | email: string
24 |
25 | /**
26 | * The encrypted password of the user.
27 | */
28 | password__hashed?: string
29 |
30 | /**
31 | * The PIN of the user.
32 | */
33 | pin__hashed?: string
34 |
35 | /**
36 | * The security questions and answers for the user
37 | * to recover their account.
38 | */
39 | sequrity_questions?: {
40 | /**
41 | * The hashed question.
42 | */
43 | question__hashed: string
44 |
45 | /**
46 | * The hashed answer.
47 | */
48 | answer__hashed: string
49 | }[]
50 |
51 | /**
52 | * The roles assigned to the user.
53 | */
54 | roles: Role[]
55 |
56 | /**
57 | * The permissions granted to the user.
58 | */
59 | permissions: Permission[]
60 |
61 | /**
62 | * Optional user preferences.
63 | */
64 | // preferences?: {
65 |
66 | /**
67 | * The user's preferred timezone.
68 | */
69 | // timezone?: string
70 |
71 | /**
72 | * The user's preferred locale.
73 | */
74 | // locale?: string
75 | // }
76 |
77 | /**
78 | * The date and time when the user was created.
79 | */
80 | created_at: Readonly
81 |
82 | /**
83 | * The date and time when the user was last updated
84 | */
85 | updated_at?: ISODate
86 |
87 | /**
88 | * The date and time when the user was last logged in.
89 | */
90 | inactivated_at?: ISODate
91 |
92 | /**
93 | * The user who inactivated this user, if applicable.
94 | */
95 | inactivated_by_user_state?: User
96 |
97 | /**
98 | * The date and time when the user was deleted, if applicable.
99 | */
100 | deleted_at?: ISODate
101 |
102 | /**
103 | * The user who deleted this user, if applicable.
104 | */
105 | deleted_by_user_state?: User
106 | }
107 |
--------------------------------------------------------------------------------
/src/models/table-types/product.ts:
--------------------------------------------------------------------------------
1 | // vendors
2 | import type { UUID } from 'node:crypto'
3 | // globals
4 | import type { File } from '@/@types/file'
5 | import type { ISODate } from '@/@types/iso-date'
6 |
7 | /**
8 | * Represents the product information.
9 | */
10 | export interface Product {
11 | /**
12 | * Unique identifier for the product.
13 | */
14 | uuid: Readonly
15 |
16 | /**
17 | * Unique identifier for the product.
18 | */
19 | code?: string
20 |
21 | /**
22 | * Barcode registration identifier for the product.
23 | */
24 | barcode_reg_id?: string
25 |
26 | /**
27 | * Name of the product.
28 | */
29 | name: string
30 |
31 | /**
32 | * Description of the product.
33 | */
34 | description?: string
35 |
36 | /**
37 | * Category of the product.
38 | */
39 | category?: string
40 |
41 | /**
42 | * Image file of the product.
43 | */
44 | image_file?: File['blob']
45 |
46 | /**
47 | * The date and time when the product was created.
48 | */
49 | created_at: Readonly
50 |
51 | /**
52 | * The date and time when the product was last updated.
53 | */
54 | updated_at: ISODate
55 |
56 | /**
57 | * The date and time when the product was deleted.
58 | */
59 | deleted_at?: ISODate
60 |
61 | /**
62 | * The unit of measurement for the product.
63 | * @example 'pcs', 'kg', 'm', 'l'
64 | */
65 | qty_unit: string
66 |
67 | /**
68 | * The default price/unit of the product.
69 | */
70 | default_price: number
71 |
72 | /**
73 | * The threshold quantity of the product.
74 | */
75 | low_qty_threshold?: number
76 |
77 | /**
78 | * The current stock of the product.
79 | */
80 | stock: ProductStock
81 | }
82 |
83 | /**
84 | * Represents the stock information for a product.
85 | */
86 | interface ProductStock {
87 | /**
88 | * The quantity of the product available in the stock.
89 | */
90 | qty: number
91 |
92 | /**
93 | * The cost/unit of the product.
94 | */
95 | cost: number
96 |
97 | /**
98 | * The default price of the product.
99 | * @default Product.default_price
100 | */
101 | default_price?: number
102 |
103 | /**
104 | * The threshold quantity of the product.
105 | * @default Product.low_qty_threshold
106 | */
107 | low_qty_threshold?: number
108 | }
109 |
--------------------------------------------------------------------------------
/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
19 |
41 |
43 |
47 |
51 |
59 |
67 |
71 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/(form)/[uuid]/_hook.ts:
--------------------------------------------------------------------------------
1 | // types
2 | import type { Product } from '@/models/table-types/product'
3 | import type { ProductFormProps } from '../_components/product-form/props'
4 | // vendors
5 | import { useRouter } from 'next/navigation'
6 | import { useEffect, useState } from 'react'
7 | import dayjs from 'dayjs'
8 | // globals
9 | import { isUuidString } from '@/functions/is-uuid-string'
10 | import { toast } from '@/functions/toast'
11 | import PageUrlEnum from '@/enums/page-url'
12 | // models
13 | import db from '@/models/db'
14 |
15 | /**
16 | * Custom hook to manage product data update and form submission.
17 | *
18 | * @param {string} uuidFromPageParam - The UUID of the product from the page parameter.
19 | * @returns {object} An object containing the product data, handleCancel, and handleSubmit functions.
20 | *
21 | * @property {ProductFormProps['data'] | undefined} product - The product data fetched from the database.
22 | * @property {function} handleCancel - Function to navigate back to the previous page.
23 | * @property {function} handleSubmit - Function to handle form submission and update the product data in the database.
24 | *
25 | * @throws {Error} If the UUID from the page parameter is invalid.
26 | */
27 | export function useHook(uuidFromPageParam: string) {
28 | const router = useRouter()
29 | const [product, setProduct] = useState()
30 |
31 | useEffect(() => {
32 | if (!isUuidString(uuidFromPageParam)) {
33 | handleFailure(new Error('Invalid UUID'))
34 | }
35 |
36 | db.products
37 | .get(uuidFromPageParam as Product['uuid'])
38 | .then(setProduct)
39 | .catch(handleFailure)
40 |
41 | return () => {
42 | setProduct(undefined)
43 | }
44 | }, [uuidFromPageParam])
45 |
46 | function handleSuccess() {
47 | router.push(PageUrlEnum.PRODUCT_LIST)
48 | toast('Produk berhasil diperbarui')
49 | }
50 |
51 | return {
52 | product,
53 |
54 | handleCancel: () => {
55 | router.back()
56 | },
57 |
58 | handleSubmit: (values: ProductFormProps['data']) => {
59 | if (!values.uuid) {
60 | handleFailure(new Error('Invalid UUID'))
61 | return
62 | }
63 |
64 | db.products
65 | .update(values.uuid, {
66 | ...values,
67 | updated_at: dayjs().toISOString(),
68 | })
69 | .then(handleSuccess)
70 | .catch(handleFailure)
71 | },
72 | }
73 | }
74 |
75 | function handleFailure(err: Error) {
76 | throw err
77 | }
78 |
--------------------------------------------------------------------------------
/src/components/onboard-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // vendors
4 | import { Button } from '@heroui/button'
5 | import { Card, CardHeader, CardBody } from '@heroui/card'
6 | // icons
7 | import { FileDown, GalleryHorizontalEnd, TvMinimalPlay } from 'lucide-react'
8 | //
9 | import PageUrlEnum from '@/enums/page-url'
10 | import { Link } from '@heroui/link'
11 |
12 | export const OnBoardCard = () => {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | Gunakan Data Demo
22 | alert('Coming soon!')}>
28 | Mulai
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | Impor Data Hasil Pencadangan
40 |
41 |
48 | Mulai
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | Mulai Ulang Dari Awal
60 |
61 |
68 | Mulai
69 |
70 |
71 |
72 |
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/src/components/line-chart.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from 'next-themes'
2 | import {
3 | LineChart as Chart,
4 | Line,
5 | CartesianGrid,
6 | XAxis,
7 | YAxis,
8 | Tooltip,
9 | ResponsiveContainer,
10 | Legend,
11 | Label,
12 | } from 'recharts'
13 |
14 | // TODO: Hapus data dummy ini ketika sudah terhubung dengan API
15 | import { dataPenjualan } from '@/data/penjualan'
16 |
17 | interface LineChartProps {
18 | day?: number | string
19 | }
20 |
21 | const LineChart = ({ day }: LineChartProps) => {
22 | const { resolvedTheme } = useTheme()
23 |
24 | return (
25 |
26 |
34 |
40 | }
41 | />
42 |
43 |
48 |
49 |
54 |
55 |
56 |
57 |
60 |
61 |
62 |
63 |
64 |
65 |
74 |
75 |
76 |
77 |
78 | )
79 | }
80 |
81 | export default LineChart
82 |
83 | interface CustomizeLabelProps {
84 | x: number
85 | y: number
86 | stroke: string
87 | value: string | number
88 | }
89 |
90 | const CustomizedLabel = ({ x, y, stroke, value }: CustomizeLabelProps) => (
91 |
92 | {value}
93 |
94 | )
95 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sensasi-pos",
3 | "version": "0.0.1",
4 | "versionDate": "2025-11-04",
5 | "private": true,
6 | "scripts": {
7 | "build": "next build",
8 | "build:analyze": "cross-env ANALYZE=true next build",
9 | "dev": "next dev",
10 | "knip": "knip",
11 | "lint": "biome check && tsc --noEmit",
12 | "lint:fix": "tsc --noEmit && biome check --write",
13 | "start": "next start",
14 | "test": "biome check"
15 | },
16 | "dependencies": {
17 | "@heroui/accordion": "^2.2.24",
18 | "@heroui/alert": "^2.2.27",
19 | "@heroui/autocomplete": "^2.3.29",
20 | "@heroui/button": "^2.2.27",
21 | "@heroui/card": "^2.2.25",
22 | "@heroui/checkbox": "^2.3.27",
23 | "@heroui/chip": "^2.2.22",
24 | "@heroui/date-picker": "^2.3.28",
25 | "@heroui/divider": "^2.2.20",
26 | "@heroui/drawer": "^2.2.24",
27 | "@heroui/dropdown": "^2.3.27",
28 | "@heroui/image": "^2.2.17",
29 | "@heroui/input": "^2.4.28",
30 | "@heroui/link": "^2.2.23",
31 | "@heroui/modal": "^2.2.24",
32 | "@heroui/navbar": "^2.2.25",
33 | "@heroui/pagination": "^2.2.24",
34 | "@heroui/popover": "^2.3.27",
35 | "@heroui/select": "^2.4.28",
36 | "@heroui/spinner": "^2.2.24",
37 | "@heroui/switch": "^2.2.24",
38 | "@heroui/system": "^2.4.23",
39 | "@heroui/table": "^2.2.27",
40 | "@heroui/theme": "^2.4.23",
41 | "@heroui/tooltip": "^2.2.24",
42 | "@internationalized/date": "^3.10.0",
43 | "@sentry/nextjs": "^10.30.0",
44 | "@tailwindcss/postcss": "^4.1.18",
45 | "@vercel/analytics": "^1.6.1",
46 | "bcryptjs": "^2.4.3",
47 | "clsx": "^2.1.1",
48 | "dayjs": "^1.11.19",
49 | "dexie": "^4.2.1",
50 | "dexie-react-hooks": "^1.1.7",
51 | "framer-motion": "^12.23.26",
52 | "lucide-react": "^0.552.0",
53 | "next": "^16.0.10",
54 | "next-nprogress-bar": "^2.4.7",
55 | "next-themes": "^0.4.6",
56 | "react-barcode-reader": "^0.0.2",
57 | "react-barcode-scanner": "^2.1.0",
58 | "react-hook-form": "^7.68.0",
59 | "react-hot-toast": "^2.6.0",
60 | "react-image-file-resizer": "^0.4.8",
61 | "recharts": "^2.15.4",
62 | "tailwind-merge": "^3.4.0",
63 | "tailwindcss": "^4.1.18",
64 | "use-debounce": "^10.0.6",
65 | "usehooks-ts": "^3.1.1",
66 | "uuid": "^10.0.0",
67 | "zustand": "^5.0.9"
68 | },
69 | "devDependencies": {
70 | "@biomejs/biome": "^2.3.8",
71 | "@next/bundle-analyzer": "^16.0.10",
72 | "@types/bcryptjs": "^2.4.6",
73 | "@types/node": "^22.19.2",
74 | "@types/react": "^19.2.7",
75 | "@types/react-dom": "^19.2.3",
76 | "@types/uuid": "^10.0.0",
77 | "cross-env": "^10.1.0",
78 | "knip": "^5.73.4",
79 | "postcss": "^8.5.6",
80 | "typescript": "^5.9.3"
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/components/confirmation-modal.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import { Button } from '@heroui/button'
3 | import {
4 | Modal,
5 | ModalBody,
6 | ModalContent,
7 | ModalFooter,
8 | ModalHeader,
9 | type ModalProps,
10 | } from '@heroui/modal'
11 | //
12 | import mergeClass from '@/functions/merge-class'
13 |
14 | type ColorType =
15 | | 'primary'
16 | | 'success'
17 | | 'warning'
18 | | 'danger'
19 | | 'secondary'
20 | | 'default'
21 |
22 | export function ConfirmationModal({
23 | title = 'Konfirmasi Tindakan Anda',
24 | color = 'warning',
25 | rejectText = 'Batal',
26 | acceptText = 'Yakin',
27 | onAccept,
28 | onReject,
29 | children,
30 | className,
31 | ...restProps
32 | }: Omit & {
33 | rejectText?: string
34 | acceptText?: string
35 | onAccept: () => void
36 | onReject: ModalProps['onClose']
37 | color?: ColorType
38 | }) {
39 | const bgColorClass = getBgColorClass(color)
40 | const textColorClass = getTextColorClass(color)
41 | const buttonColor = color === 'default' ? 'primary' : color
42 |
43 | return (
44 |
52 |
53 | {title}
54 |
55 | {children}
56 |
57 |
58 |
59 | {rejectText}
60 |
61 |
62 |
66 | {acceptText}
67 |
68 |
69 |
70 |
71 | )
72 | }
73 |
74 | function getBgColorClass(color: ColorType) {
75 | switch (color) {
76 | case 'primary':
77 | return 'bg-primary-100'
78 | case 'secondary':
79 | return 'bg-secondary-100'
80 | case 'success':
81 | return 'bg-success-100'
82 | case 'danger':
83 | return 'bg-danger-100'
84 |
85 | default:
86 | return 'bg-default-100'
87 | }
88 | }
89 |
90 | function getTextColorClass(color: ColorType) {
91 | switch (color) {
92 | case 'primary':
93 | return 'text-primary-100'
94 | case 'secondary':
95 | return 'text-secondary-100'
96 | case 'success':
97 | return 'text-success-100'
98 | case 'danger':
99 | return 'text-danger-100'
100 |
101 | default:
102 | return 'text-default-100'
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/app/_layout-components/navbar/components/auth-navbar-items/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // vendors
4 | import { Button } from '@heroui/button'
5 | import { NavbarItem } from '@heroui/navbar'
6 | import { Tooltip } from '@heroui/tooltip'
7 | import {
8 | CalculatorIcon,
9 | FileSpreadsheetIcon,
10 | ShoppingCartIcon,
11 | } from 'lucide-react'
12 | import { useState } from 'react'
13 | // globals
14 | import { Permission } from '@/enums/permission'
15 | import PageUrlEnum from '@/enums/page-url'
16 | // siblings
17 | import { FeedbackFormModal } from '../feedback-form-modal'
18 | import { SettingDropdownButton } from './components/settings-dropdown-button'
19 | import useAuth from '@/hooks/use-auth'
20 | import { Link } from '@heroui/link'
21 |
22 | /**
23 | * Navbar items for authenticated users
24 | */
25 | export function AuthNavbarItems() {
26 | const { hasAnyPermissions } = useAuth()
27 | const [isFeedbackFormModalOpen, setIsFeedbackFormModalOpen] = useState(false)
28 |
29 | return (
30 | <>
31 |
32 |
33 | {hasAnyPermissions([Permission.READ_SALE]) && (
34 |
41 |
42 |
43 | )}
44 |
45 |
46 |
47 | {hasAnyPermissions([Permission.READ_PURCHASE]) && (
48 |
54 |
55 |
56 | )}
57 |
58 |
59 | {
61 | setIsFeedbackFormModalOpen(true)
62 | }}
63 | />
64 |
65 |
66 | {hasAnyPermissions([Permission.READ_DASHBOARD]) && (
67 |
68 | }
72 | variant="flat"
73 | color="primary">
74 | Laporan
75 |
76 |
77 | )}
78 |
79 | {
82 | setIsFeedbackFormModalOpen(false)
83 | }}
84 | />
85 | >
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/purchases/(form)/[uuid]/page-hook.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // types
4 | import type { ProductMovement } from '@/models/table-types/product-movement'
5 | import type { FormValues } from '../_types/form-values'
6 | // vendors
7 | import { useEffect, useState } from 'react'
8 | import { useRouter } from 'next/navigation'
9 | // globals
10 | import db from '@/models/db'
11 | import { isUuidString } from '@/functions/is-uuid-string'
12 | import { updateStocksOnReceived } from '../_functions/update-stocks-on-received'
13 | import { validateFormValues } from '../_functions/validate-form-values'
14 |
15 | export function usePageHook(uuidPageParam: string) {
16 | const router = useRouter()
17 |
18 | const [formValues, setFormValues] = useState()
19 |
20 | useEffect(() => {
21 | if (!isUuidString(uuidPageParam)) {
22 | throw new Error('Invalid UUID')
23 | }
24 |
25 | getData(uuidPageParam as ProductMovement['uuid'])
26 | .then(setFormValues)
27 | .catch(error => {
28 | throw error
29 | })
30 |
31 | return () => {
32 | setFormValues(undefined)
33 | }
34 | }, [uuidPageParam])
35 |
36 | return {
37 | formValues,
38 |
39 | handleCancel: () => {
40 | router.back()
41 | },
42 |
43 | handleSubmit: () => {
44 | if (!formValues) return
45 |
46 | const validated = validateFormValues(formValues)
47 |
48 | if (validated.type !== 'purchase') {
49 | throw new Error('Invalid type')
50 | }
51 |
52 | db.productMovements
53 | .update(validated.uuid, {
54 | ...validated,
55 | updated_at: new Date().toISOString(),
56 | })
57 | .then(() => {
58 | if (validated.additional_info.received_at) {
59 | updateStocksOnReceived(validated)
60 | }
61 | })
62 | .then(() => {
63 | router.back()
64 | })
65 | .catch(error => {
66 | throw error
67 | })
68 | },
69 | }
70 | }
71 |
72 | async function getData(uuid: ProductMovement['uuid']) {
73 | return new Promise((resolve, reject) => {
74 | db.productMovements
75 | .get(uuid)
76 | .then(data => {
77 | if (!data) {
78 | reject(new Error('not found'))
79 | return
80 | }
81 |
82 | if (data.type !== 'purchase') {
83 | reject(new Error('not found'))
84 | return
85 | }
86 |
87 | if (data.additional_info.received_at ?? data.additional_info.paid_at) {
88 | reject(
89 | new Error(
90 | 'Tidak dapat mengubah data yang sudah diterima atau dibayar',
91 | ),
92 | )
93 | }
94 |
95 | resolve(data)
96 | })
97 | .catch(reject)
98 | })
99 | }
100 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 | import { Card, CardBody } from '@heroui/card'
5 | import { Select, SelectItem } from '@heroui/select'
6 |
7 | import LineChart from '@/components/line-chart'
8 | import BarChart from '@/components/bar-chart'
9 |
10 | const nDayses = [1, 3, 7, 14, 30, 60, 90]
11 |
12 | const ReportPage = () => {
13 | const [value, setValue] = useState(7)
14 |
15 | return (
16 |
17 |
18 |
Laporan
19 |
20 |
21 |
22 |
23 |
24 |
Total Penjualan
25 |
26 | {
33 | setValue(Number(currentKey))
34 | }}
35 | isRequired>
36 | {nDayses.map(nDays => (
37 |
38 | {nDays} hari terakhir
39 |
40 | ))}
41 |
42 |
43 |
44 |
45 |
46 | Selected: {value}
47 |
48 |
49 |
50 |
51 |
52 |
53 |
Jumlah Transaksi
54 |
55 | {
62 | setValue(Number(currentKey))
63 | }}
64 | isRequired>
65 | {nDayses.map(nDays => (
66 |
67 | {nDays} hari terakhir
68 |
69 | ))}
70 |
71 |
72 |
73 |
74 |
75 | Selected: {value}
76 |
77 |
78 |
79 |
80 | )
81 | }
82 |
83 | export default ReportPage
84 |
--------------------------------------------------------------------------------
/src/app/_page-sections/footer.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@heroui/link'
2 | import { ArrowUpRightIcon, ComputerIcon } from 'lucide-react'
3 | import packageJson from '@/../package.json'
4 |
5 | export function Footer() {
6 | return (
7 |
8 |
9 |
10 | Sensasi POS adalah aplikasi Point of Sale sederhana yang dirancang
11 | untuk membantu pencatatan penjualan barang pada Warung / Toko / UMKM /
12 | Stan / Gerai / Swalayan.
13 |
14 |
15 |
16 |
17 | Sensasi POS bersifat open-source dan dapat dikembangkan oleh
18 | siapa saja termasuk Anda. Jika Anda tertarik, Silakan lihat{' '}
19 |
23 | tata cara kontribusi
24 | {' '}
25 | pada{' '}
26 |
30 | halaman GitHub kami
31 |
32 | .
33 |
34 |
35 |
36 |
37 | Masih memiliki pertanyaan? silahkan mengirimkannya melalui surel ke:{' '}
38 |
42 | sensasi.apps@gmail.com
43 |
44 |
45 |
46 |
47 |
48 |
49 | Sensasi POS v
50 | {packageJson.version} ({packageJson.versionDate})
51 |
52 |
53 |
•
54 |
55 |
56 | Dibuat dengan ❤️ oleh{' '}
57 |
61 | Sensasi Apps🍕
62 |
63 |
64 |
65 |
66 |
•
67 |
68 |
69 | Panjang umur untuk semua pengembang open-source di seluruh
70 | dunia 🌍
71 |
72 |
73 |
74 | )
75 | }
76 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/users/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // vendors
4 | import { Button } from '@heroui/button'
5 | import { Chip } from '@heroui/chip'
6 | import { Switch } from '@heroui/switch'
7 | import {
8 | Table,
9 | TableBody,
10 | TableCell,
11 | TableColumn,
12 | TableHeader,
13 | TableRow,
14 | } from '@heroui/table'
15 | // icons
16 | import { EditIcon } from 'lucide-react'
17 | //
18 | import { useHook } from './hook'
19 | import PageUrlEnum from '@/enums/page-url'
20 | import { Link } from '@heroui/link'
21 |
22 | export default function Page() {
23 | const { users, setUserActiveStatus } = useHook()
24 |
25 | return (
26 |
27 |
28 | Tambah data pengguna
29 |
30 |
31 |
32 |
33 | Nama
34 | Surel
35 | Peran
36 | Status (aktif/tidak)
37 | Sunting
38 |
39 |
40 |
41 | {(users ?? []).map(user => (
42 |
47 | {user.name}
48 | {user.email}
49 |
50 | {
51 | /**
52 | * @todo Terjemahkan peran pengguna ke dalam bahasa Indonesia
53 | */
54 | user.roles.map(role => {role} ) ??
55 | 'Tidak ada peran'
56 | }
57 |
58 |
59 | {
63 | setUserActiveStatus(user.uuid, !!user.inactivated_at)
64 | .then(() => {
65 | user.inactivated_at = !user.inactivated_at
66 | ? new Date().toISOString()
67 | : undefined
68 | })
69 | .catch(err => {
70 | throw err
71 | })
72 | }}
73 | />
74 |
75 |
76 |
80 |
81 |
82 |
83 |
84 | ))}
85 |
86 |
87 |
88 | )
89 | }
90 |
--------------------------------------------------------------------------------
/src/components/stepper.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import PageUrlEnum from '@/enums/page-url'
4 | import mergeClass from '@/functions/merge-class'
5 | import { Boxes, Check, CircleCheck, UserPlus } from 'lucide-react'
6 | import { usePathname } from 'next/navigation'
7 | import type { ReactElement } from 'react'
8 |
9 | interface Step {
10 | name: string
11 | stepImg: ReactElement
12 | stepImgCurrent: ReactElement
13 | url: string
14 | }
15 |
16 | const STEPS: Step[] = [
17 | {
18 | name: '1. Buat Akun Pengelola',
19 | stepImg: ,
20 | stepImgCurrent: ,
21 | url: PageUrlEnum.ONBOARDING_STEP_1,
22 | },
23 | {
24 | name: '2. Masukkan Data Produk dan Stok',
25 | stepImg: ,
26 | stepImgCurrent: ,
27 | url: PageUrlEnum.ONBOARDING_STEP_2,
28 | },
29 | {
30 | name: '3. Selesai',
31 | stepImg: ,
32 | stepImgCurrent: ,
33 | url: '/steps/finish',
34 | },
35 | ]
36 |
37 | const Steps = () => {
38 | const pathname = usePathname()
39 |
40 | return (
41 |
42 | {STEPS.map((step, i) => {
43 | const isCurrent = pathname.endsWith(step.url)
44 | const isCompleted = STEPS.slice(i + 1).some(step =>
45 | pathname.endsWith(step.url),
46 | )
47 |
48 | return (
49 |
50 |
51 |
61 |
62 |
67 |
68 | {isCompleted ? (
69 |
70 | ) : isCurrent ? (
71 | step.stepImgCurrent
72 | ) : (
73 | step.stepImg
74 | )}
75 |
76 |
77 |
78 |
86 | {step.name}
87 |
88 |
89 |
90 |
91 |
92 | )
93 | })}
94 |
95 | )
96 | }
97 |
98 | export default Steps
99 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # Panduan Penggunaan Aplikasi Sensasi POS
2 |
3 | **Sensasi POS** adalah aplikasi Point of Sale sederhana yang dirancang untuk membantu pengusaha kecil dan menengah dalam mengelola bisnis! 🌐💡
4 |
5 | > Panjang umur untuk semua pengembang _open source_ di seluruh dunia! 🌍
6 |
7 | > [!INFO]
8 | >
9 | > Masih memiliki pertanyaan atau kendala? silahkan mengirimkannya melalui surel ke: sensasi.apps@gmail.com
10 |
11 | ## Instalasi
12 |
13 | Akses aplikasi melalui tautan berikut: [**Sensasi POS**](https://sensasi-pos.vercel.app/) kemudian ikuti langkah-langkah berikut:
14 |
15 | 1. Klik tanda titik tiga di pojok kanan atas browser Anda.
16 | 2. Pilih **Install Sensasi POS**.
17 | 3. Ikuti petunjuk instalasi yang muncul di layar.
18 |
19 | Pastikan Anda menggunakan _browser_ modern seperti Google Chrome dengan versi terbaru untuk pengalaman terbaik.
20 |
21 | ## Penjelasan Fitur
22 |
23 | > \*Akan datang
24 |
25 |
62 |
63 | ## _Troubleshooting_
64 |
65 | > **Aplikasi tidak dapat dibuka**
66 | >
67 | > Pastikan _browser_ Anda telah diperbaharui hingga versi yang terbaru.
68 |
69 | > **Data transaksi hilang**
70 | >
71 | > Data mungkin terhapus jika _cache_ _browser_ dibersihkan/dihapus. Pertimbangkan menggunakan fitur **Pencadangan** secara berkala.
72 |
73 | ## Kontak Bantuan
74 |
75 | Masih memiliki pertanyaan atau kendala? silahkan mengirimkannya melalui surel ke: sensasi.apps@gmail.com
76 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/sales/page.tsx:
--------------------------------------------------------------------------------
1 | import { Image } from '@heroui/image'
2 |
3 | const dummyProducts = [
4 | {
5 | id: 1,
6 | name: 'Product 1',
7 | description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
8 | base_cost: 10000,
9 | default_price: 11000,
10 | category: 'Category 1',
11 | qty: 10,
12 | qty_unit: 'pcs',
13 | },
14 | {
15 | id: 2,
16 | name: 'Product 2',
17 | description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
18 | base_cost: 20000,
19 | default_price: 22000,
20 | category: 'Category 2',
21 | qty: 20,
22 | qty_unit: 'pcs',
23 | },
24 | {
25 | id: 3,
26 | name: 'Product 3',
27 | description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
28 | base_cost: 30000,
29 | default_price: 33000,
30 | category: 'Category 3',
31 | qty: 30,
32 | qty_unit: 'pcs',
33 | },
34 | {
35 | id: 4,
36 | name: 'Product 4',
37 | description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
38 | base_cost: 40000,
39 | default_price: 44000,
40 | category: 'Category 4',
41 | qty: 40,
42 | qty_unit: 'pcs',
43 | },
44 | {
45 | id: 5,
46 | name: 'Product 5',
47 | description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
48 | base_cost: 50000,
49 | default_price: 55000,
50 | category: 'Category 5',
51 | qty: 50,
52 | qty_unit: 'pcs',
53 | },
54 | {
55 | id: 6,
56 | name: 'Product 6',
57 | description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
58 | base_cost: 60000,
59 | default_price: 66000,
60 | category: 'Category 6',
61 | qty: 60,
62 | qty_unit: 'pcs',
63 | },
64 | ]
65 |
66 | /**
67 | * @todo Implement NextImage component
68 | */
69 | const Page = () => {
70 | return (
71 |
72 | {dummyProducts.map(data => {
73 | return (
74 |
77 |
78 |
86 |
87 |
88 | {data.name}
89 |
{data.description}
90 |
91 |
92 | Rp. {data.default_price}
93 |
94 |
95 |
96 |
97 | )
98 | })}
99 |
100 | )
101 | }
102 |
103 | export default Page
104 |
--------------------------------------------------------------------------------
/src/app/_layout-components/navbar/components/feedback-form-modal/feedback-form-modal.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // vendors
4 | import { Alert } from '@heroui/alert'
5 | import { Button } from '@heroui/button'
6 | import {
7 | Modal,
8 | ModalBody,
9 | ModalContent,
10 | ModalFooter,
11 | ModalHeader,
12 | } from '@heroui/modal'
13 | import { Textarea } from '@heroui/input'
14 | // icons
15 | import { SendIcon } from 'lucide-react'
16 | //
17 | import { useFeedbackFormModalHook } from './hooks'
18 |
19 | const FORM_ID = 'feedback-form'
20 |
21 | export function FeedbackFormModal({
22 | isOpen,
23 | onClose,
24 | }: {
25 | isOpen: boolean
26 | onClose: () => void
27 | }) {
28 | const {
29 | isFormDataValid,
30 | isSubmitted,
31 | handleReset,
32 | handleFormDataChange,
33 | handleSubmit,
34 | } = useFeedbackFormModalHook()
35 |
36 | return (
37 |
38 |
39 |
40 | Saran Perbaikan
41 |
42 |
43 |
44 | {isSubmitted ? (
45 |
46 | Terima kasih atas partisipasi Anda dalam pengembangan{' '}
47 | Sensasi POS
48 |
49 | ) : (
50 | <>
51 |
52 | Tuliskan saran perbaikan, laporan bug , atau fitur yang
53 | Anda inginkan di bawah ini:
54 |
55 |
59 | >
60 | )}
61 |
62 |
63 | Saran Anda akan sangat membantu kami untuk meningkatkan kualitas
64 | aplikasi.
65 |
66 |
67 |
68 |
69 | {
71 | onClose()
72 | handleReset()
73 | }}>
74 | {isSubmitted ? 'Tutup' : 'Batal'}
75 |
76 |
77 | }>
83 | Kirim
84 |
85 |
86 |
87 |
88 | )
89 | }
90 |
91 | function FeedbackForm({
92 | onSubmit,
93 | setFormData,
94 | }: {
95 | onSubmit: () => void
96 | setFormData: (
97 | // key: 'message',
98 | value: string,
99 | ) => void
100 | }) {
101 | return (
102 |
113 | )
114 | }
115 |
--------------------------------------------------------------------------------
/src/models/table-types/product-movement.ts:
--------------------------------------------------------------------------------
1 | // vendor
2 | import type { UUID } from 'node:crypto'
3 | // app types
4 | import type { File } from '@/@types/file'
5 | import type { ISODate } from '@/@types/iso-date'
6 | // other models
7 | import type { User } from './user'
8 | // local types
9 | import type { ProductMovementItem } from './product-movement/item'
10 | import type { ProductMovementAdditionalCost } from './product-movement/additional-cost'
11 | import type { ProductMovementPurchaseAdditionalInfo } from './product-movement/purchase-additional-info'
12 | import type { ProductMovementSaleAdditionalInfo } from './product-movement/sale-additional-info'
13 |
14 | /**
15 | * Interface representing the base structure for product movement.
16 | */
17 | interface BaseProductMovement {
18 | /**
19 | * Unique identifier for the product movement.
20 | * @readonly
21 | */
22 | uuid: Readonly
23 |
24 | /**
25 | * The date and time when the product movement occurred.
26 | */
27 | at: ISODate
28 |
29 | /**
30 | * User who initiated the product movement.
31 | * @readonly
32 | */
33 | by_user_state: Readonly
34 |
35 | /**
36 | * Optional note associated with the product movement.
37 | */
38 | note?: string
39 |
40 | /**
41 | * Optional reference identifier for the product movement.
42 | */
43 | ref?: string
44 |
45 | /**
46 | * List of items involved in the product movement.
47 | */
48 | items: ProductMovementItem[]
49 |
50 | /**
51 | * Additional costs associated with the product movement.
52 | */
53 | additional_costs: ProductMovementAdditionalCost[]
54 |
55 | /**
56 | * List of files associated with the product movement.
57 | */
58 | files: File[]
59 |
60 | /**
61 | * The date and time when the product movement was created.
62 | * @readonly
63 | */
64 | created_at: Readonly
65 |
66 | /**
67 | * The date and time when the product movement was last updated.
68 | */
69 | updated_at: ISODate
70 |
71 | /**
72 | * User who deleted the product movement.
73 | */
74 | deleted_by_user_state?: User
75 |
76 | /**
77 | * The date and time when the product movement was deleted.
78 | */
79 | deleted_at?: ISODate
80 | }
81 |
82 | /**
83 | * Additional information for a product movement purchase.
84 | */
85 |
86 | interface WithPurchaseAdditionalInfoProps {
87 | /**
88 | * Type of the product movement.
89 | */
90 | type: 'purchase'
91 |
92 | /**
93 | * Additional information for the product movement.
94 | */
95 | additional_info: ProductMovementPurchaseAdditionalInfo
96 | }
97 |
98 | /**
99 | * Additional information for a product movement sale.
100 | */
101 | interface WithSaleAdditionalInfoProps {
102 | /**
103 | * Type of the product movement.
104 | */
105 | type: 'sale'
106 |
107 | /**
108 | * Additional information for the product movement.
109 | */
110 | additional_info: ProductMovementSaleAdditionalInfo
111 | }
112 |
113 | /**
114 | * Interface representing a product movement.
115 | */
116 | export type ProductMovement = BaseProductMovement &
117 | (WithPurchaseAdditionalInfoProps | WithSaleAdditionalInfoProps)
118 |
--------------------------------------------------------------------------------
/src/app/(guest)/forgot-password/_components/security-question/security-question.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import { Autocomplete, AutocompleteItem } from '@heroui/autocomplete'
3 | import { Button } from '@heroui/button'
4 | import { type FormEvent, useState } from 'react'
5 | import { useLiveQuery } from 'dexie-react-hooks'
6 | //
7 | import { UserRound } from 'lucide-react'
8 | import { useFormSubmissionState } from '@/stores/form-submission'
9 | import { useSecurityQuestionState } from '../../_stores/security-question'
10 | import { SecurityQuestion } from '@/enums/security-question'
11 | import QuestionAndAnswerField from './question-and-answer-field'
12 | import db from '@/models/db'
13 |
14 | export default function SecurityQuestionForm() {
15 | const users = useLiveQuery(() => db.users.toArray(), [])
16 | const securityQuestions = Object.entries(SecurityQuestion)
17 |
18 | // States
19 | const [hasSelectedUser, setHasSelectedUser] = useState(false)
20 |
21 | // Stores
22 | const { isSubmitting, toggleSubmitting } = useFormSubmissionState()
23 | const { selectedQuestionNumbers } = useSecurityQuestionState()
24 |
25 | const toggleHasSelectedUser = (key: string | number | null) =>
26 | setHasSelectedUser(!!key)
27 |
28 | const handleForgotPasswordBySecurityQuestion = (
29 | event: FormEvent,
30 | ) => {
31 | event.preventDefault()
32 |
33 | toggleSubmitting()
34 |
35 | setTimeout(() => {
36 | toggleSubmitting()
37 | }, 2000)
38 | }
39 |
40 | if (!users)
41 | return (
42 |
43 | Tidak Ada Pengguna
44 |
45 | Silakan Tambahkan Pengguna Terlebih Dahulu
46 |
47 |
48 | )
49 |
50 | return (
51 | <>
52 |
53 |
54 | Jawab Pertanyaan Keamanan Untuk Mengatur Ulang Kata Sandi
55 |
56 |
57 |
60 |
66 | {users.map(user => (
67 |
71 | }>
72 | {user.name}
73 |
74 | ))}
75 |
76 |
77 | {hasSelectedUser &&
78 | [1, 2].map(questionNumber => (
79 |
84 | ))}
85 |
86 |
92 | Proses
93 |
94 |
95 | >
96 | )
97 | }
98 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/settings/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Card, CardBody, CardHeader } from '@heroui/card'
4 | import { Button } from '@heroui/button'
5 | import { Link } from '@heroui/link'
6 | import {
7 | Database,
8 | Download,
9 | RefreshCw,
10 | Trash2,
11 | type LucideIcon,
12 | } from 'lucide-react'
13 | import PageUrlEnum from '@/enums/page-url'
14 | import { Permission } from '@/enums/permission'
15 | import useAuth from '@/hooks/use-auth'
16 |
17 | interface SettingsMenuItemProps {
18 | href: string
19 | icon: LucideIcon
20 | title: string
21 | description: string
22 | color?: 'primary' | 'danger' | 'warning' | 'success' | 'secondary' | 'default'
23 | }
24 |
25 | function SettingsMenuItem({
26 | href,
27 | icon: Icon,
28 | title,
29 | description,
30 | color = 'primary',
31 | }: SettingsMenuItemProps) {
32 | return (
33 | }
39 | className="justify-start h-[unset] py-2">
40 |
41 | {title}
42 | {description}
43 |
44 |
45 | )
46 | }
47 |
48 | export default function Page() {
49 | const { hasAnyPermissions } = useAuth()
50 |
51 | return (
52 |
53 |
54 |
Pengaturan Aplikasi
55 |
56 | Kelola pengaturan dan data aplikasi Anda
57 |
58 |
59 |
60 |
70 |
71 |
72 |
73 |
Manajemen Basis Data
74 |
75 |
76 | Kelola data lokal aplikasi Anda
77 |
78 |
79 |
80 |
81 |
87 |
88 |
94 |
95 |
102 |
103 |
104 |
105 | )
106 | }
107 |
--------------------------------------------------------------------------------
/src/app/_page-sections/hero.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // vendors
4 | import { Card, CardHeader, CardBody } from '@heroui/card'
5 | import { Image } from '@heroui/image'
6 | // locals
7 | import GridPattern from '@/components/grid-pattern'
8 |
9 | interface FeatureItem {
10 | title: string
11 | description: string
12 | image: string
13 | }
14 |
15 | const FEATURES: FeatureItem[] = [
16 | {
17 | title: 'Tanpa Biaya',
18 | description: 'Aplikasi gratis dan tidak ada biaya berlangganan.',
19 | image:
20 | 'https://cdn.pixabay.com/photo/2018/07/19/07/17/wallet-3548021_1280.jpg',
21 | },
22 |
23 | {
24 | title: 'Tanpa Internet',
25 | description:
26 | 'Aplikasi sepenuhnya luring, semua data tersimpan pada perangkat Anda.',
27 | image:
28 | 'https://cdn.pixabay.com/photo/2018/08/10/05/45/computer-3596169_1280.jpg',
29 | },
30 |
31 | {
32 | title: 'Fleksibel',
33 | description: 'Dapat digunakan di perangkat apa pun yang memiliki browser.',
34 | image:
35 | 'https://cdn.pixabay.com/photo/2021/11/16/15/35/electronics-6801339_1280.jpg',
36 | },
37 | ]
38 |
39 | export function Hero() {
40 | return (
41 |
42 |
50 |
51 |
52 |
53 | Kelola Penjualan dengan Mudah, Kapan Saja
54 |
55 |
56 |
57 | Sistem Point of Sales modern yang dapat diakses bahkan tanpa
58 | internet. Pantau transaksi, kelola stok, dan buat laporan dengan
59 | cepat dalam satu platform yang intuitif dan selalu siap digunakan.
60 |
61 |
62 | {/* Disable for now */}
63 | {/*
67 | Coba Sekarang
68 | */}
69 |
70 |
71 | {FEATURES.map(feature => (
72 |
73 | ))}
74 |
75 |
76 |
77 |
78 | )
79 | }
80 |
81 | function FeatureItem({
82 | data: { title, description, image },
83 | }: {
84 | data: FeatureItem
85 | }) {
86 | return (
87 |
88 |
89 | {title}
90 | {description}
91 |
92 |
93 |
98 |
99 |
100 | )
101 | }
102 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/purchases/page-hook.ts:
--------------------------------------------------------------------------------
1 | import { toast } from '@/functions/toast'
2 | import db from '@/models/db'
3 | import type { ProductMovement } from '@/models/table-types/product-movement'
4 | import { useLiveQuery } from 'dexie-react-hooks'
5 | import { useEffect, useState } from 'react'
6 | import { usePathname, useRouter, useSearchParams } from 'next/navigation'
7 | import { useDebounce } from '@/hooks/use-debounce'
8 | import useAuth from '@/hooks/use-auth'
9 |
10 | export function usePageHook({
11 | page,
12 | rowsPerPage,
13 | q,
14 | }: {
15 | page: number
16 | rowsPerPage: number
17 | q: string
18 | }) {
19 | const { user } = useAuth()
20 | const [toBeDeletedProductMovement, setToBeDeletedProductMovement] =
21 | useState()
22 | const router = useRouter()
23 | const pathname = usePathname()
24 | const searchParams = useSearchParams()
25 |
26 | const [search, setSearch] = useState(q)
27 | const debouncedSearch = useDebounce(search, 300)
28 |
29 | useEffect(() => {
30 | if (q !== debouncedSearch) {
31 | const params = new URLSearchParams(searchParams)
32 | params.set('q', debouncedSearch)
33 | params.set('page', '1')
34 | router.push(`${pathname}?${params.toString()}`)
35 | }
36 | }, [debouncedSearch, q, searchParams, pathname, router])
37 |
38 | const productMovements = useLiveQuery(
39 | () =>
40 | createQuery(q)
41 | .offset((page - 1) * rowsPerPage)
42 | .limit(rowsPerPage)
43 | .toArray(),
44 | [page, rowsPerPage, q],
45 | )
46 |
47 | const totalProductMovements = useLiveQuery(() => createQuery(q).count(), [q])
48 |
49 | const handleDeleteProductMovement = () => {
50 | if (!toBeDeletedProductMovement) return
51 |
52 | db.productMovements
53 | .update(toBeDeletedProductMovement.uuid, {
54 | deleted_at: new Date().toISOString(),
55 | deleted_by_user_state: user,
56 | })
57 | .then(() => {
58 | setToBeDeletedProductMovement(undefined)
59 | toast('Data pembelian berhasil dihapus', 'warning')
60 | })
61 | .catch((err: Error) => {
62 | throw err
63 | })
64 | }
65 |
66 | return {
67 | productMovements,
68 | totalProductMovements,
69 | toBeDeletedProductMovement,
70 | setToBeDeletedProductMovement,
71 | handleDeleteProductMovement,
72 | search,
73 | setSearch,
74 | }
75 | }
76 |
77 | function createQuery(q: string) {
78 | const query = db.productMovements
79 | .orderBy('created_at')
80 | .reverse()
81 | .filter(movement => movement.type === 'purchase')
82 |
83 | if (q) {
84 | query.and(({ ref, note, by_user_state, items }) => {
85 | const lowerCaseQuery = q.toLowerCase()
86 |
87 | return (
88 | ref?.toLowerCase().includes(lowerCaseQuery) ||
89 | note?.toLowerCase().includes(lowerCaseQuery) ||
90 | //
91 | items.some(
92 | ({ product_state: { name, uuid } }) =>
93 | name?.toLowerCase().includes(lowerCaseQuery) ||
94 | uuid?.toLowerCase().includes(lowerCaseQuery),
95 | ) ||
96 | //
97 | by_user_state?.name
98 | ?.toLowerCase()
99 | .includes(lowerCaseQuery) ||
100 | by_user_state?.email?.toLowerCase().includes(lowerCaseQuery) ||
101 | by_user_state?.uuid?.toLowerCase().includes(lowerCaseQuery)
102 | )
103 | })
104 | }
105 |
106 | return query
107 | }
108 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/onboarding/steps/2-add-products-and-stock/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@heroui/button'
4 | import { useLiveQuery } from 'dexie-react-hooks'
5 | import { MoveLeftIcon, MoveRightIcon } from 'lucide-react'
6 |
7 | import PageUrlEnum from '@/enums/page-url'
8 |
9 | import db from '@/models/db'
10 |
11 | import { Card, CardBody } from '@heroui/card'
12 | import { useDisclosure } from '@heroui/modal'
13 | import {
14 | Drawer,
15 | DrawerContent,
16 | DrawerHeader,
17 | DrawerBody,
18 | DrawerFooter,
19 | } from '@heroui/drawer'
20 | import { ProductList } from './_component/list-product'
21 | import { ProductForm } from './_component/product-form'
22 | import { Link } from '@heroui/link'
23 |
24 | export default function AddProductsPage() {
25 | const products = useLiveQuery(() => db.products.toArray(), [])
26 | const nProducts = products?.length ?? 0
27 |
28 | const { isOpen, onOpen, onOpenChange } = useDisclosure()
29 |
30 | return (
31 |
32 |
33 |
34 |
35 |
36 | Tambahkan beberapa produk yang akan Anda jual. Anda dapat melewati
37 | langkah ini dan menambahkannya nanti.
38 |
39 |
40 | {}} />
41 |
42 |
43 | }
46 | as={Link}
47 | href={PageUrlEnum.ONBOARDING_STEP_1}
48 | color="primary">
49 | Kembali
50 |
51 |
52 | }
56 | as={Link}
57 | href={PageUrlEnum.ONBOARDING_STEP_FINISH}
58 | isDisabled={nProducts === 0}>
59 | Selesai
60 |
61 |
62 |
63 |
Lihat Produk
64 |
65 |
66 | {onClose => (
67 | <>
68 |
69 | Produk yang sudah ditambahkan:
70 |
71 |
72 |
73 |
74 |
75 |
79 | Close
80 |
81 |
82 | >
83 | )}
84 |
85 |
86 |
87 |
91 | Lewati untuk sekarang
92 |
93 |
94 |
95 |
96 |
97 |
98 | )
99 | }
100 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/purchases/(form)/_components/costs-input.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import { Controller, useFieldArray, useFormContext } from 'react-hook-form'
3 | import { Button } from '@heroui/button'
4 | import { Input } from '@heroui/input'
5 | import { PlusCircleIcon, TrashIcon } from 'lucide-react'
6 | // types
7 | import type { FormValues } from '../_types/form-values'
8 | // components
9 | import { InputAdditionalContent } from '@/components/input-additional-content'
10 |
11 | export function CostsInput() {
12 | const {
13 | control,
14 | formState: { errors },
15 | } = useFormContext()
16 | const { remove, fields, append } = useFieldArray({
17 | control,
18 | name: 'additional_costs',
19 | rules: {
20 | validate: costs => {
21 | const names = costs.map(cost => cost.name)
22 | return new Set(names).size === names.length
23 | ? undefined
24 | : 'Nama biaya tidak boleh sama'
25 | },
26 | },
27 | })
28 |
29 | return (
30 | <>
31 |
32 |
Biaya Lain
33 |
34 |
{
40 | append({
41 | name: '',
42 | value: 0,
43 | })
44 | }}>
45 |
46 |
47 |
48 |
49 | {errors.additional_costs && (
50 |
51 | {errors.additional_costs.root?.message}
52 |
53 | )}
54 |
55 | {fields.map((field, i) => (
56 |
57 | (
62 |
70 | )}
71 | />
72 |
73 | (
81 | {
87 | onChange(value ? Number(value) : undefined)
88 | }}
89 | startContent={
90 | Rp
91 | }
92 | {...rest}
93 | isInvalid={!!error}
94 | errorMessage={error?.message}
95 | />
96 | )}
97 | />
98 |
99 | remove(i)}>
106 |
107 |
108 |
109 | ))}
110 | >
111 | )
112 | }
113 |
--------------------------------------------------------------------------------
/src/app/(guest)/reset-password/_components/reset-password-form/reset-password-form.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import { Button } from '@heroui/button'
3 | import type { FormEvent } from 'react'
4 | // subcomponents
5 | import ConfirmPasswordField from './_components/confirm-password-field'
6 | import ConfirmPinField from './_components/confirm-pin-field'
7 | import PasswordField from './_components/password-field'
8 | import PinField from './_components/pin-field'
9 | // stores
10 | import { useErrorMessageState } from '@/stores/input-error-message'
11 | import { useFormSubmissionState } from '@/stores/form-submission'
12 |
13 | export default function ResetPasswordForm() {
14 | const { setErrorMessage, clearErrorMessages } = useErrorMessageState()
15 | const { isSubmitting, toggleSubmitting } = useFormSubmissionState()
16 |
17 | function handleResetPassword(event: FormEvent) {
18 | event.preventDefault()
19 |
20 | clearErrorMessages()
21 |
22 | const formData = new FormData(event.currentTarget)
23 | const formValues = getFormValues(formData)
24 |
25 | if (!validateInputFields(formValues, setErrorMessage)) {
26 | return
27 | }
28 |
29 | toggleSubmitting()
30 |
31 | setTimeout(() => {
32 | toggleSubmitting()
33 | }, 2000)
34 | }
35 |
36 | return (
37 |
38 |
39 |
40 |
41 |
42 |
43 |
48 | Simpan
49 |
50 |
51 | )
52 | }
53 |
54 | function validateInputFields(
55 | formValues: ReturnType,
56 | setErrorMessage: (identifier: string, message: string) => void,
57 | ) {
58 | const { password, confirmPassword, pin, confirmPin } = formValues
59 |
60 | let passed = true
61 |
62 | if (password.length < 1) {
63 | setErrorMessage('password', 'Kata sandi tidak boleh kosong')
64 |
65 | passed = false
66 | }
67 |
68 | if (confirmPassword.length < 1) {
69 | setErrorMessage(
70 | 'confirm_password',
71 | 'Konfirmasi kata sandi tidak boleh kosong',
72 | )
73 |
74 | passed = false
75 | }
76 |
77 | if (password.length > 0 && password.length < 8) {
78 | setErrorMessage('password', 'Panjang kata sandi minimal 8 karakter')
79 |
80 | passed = false
81 | }
82 |
83 | if (confirmPassword.length > 0 && confirmPassword.length < 8) {
84 | setErrorMessage(
85 | 'confirm_password',
86 | 'Panjang konfirmasi kata sandi minimal 8 karakter',
87 | )
88 | }
89 |
90 | if (
91 | password.length > 0 &&
92 | confirmPassword.length > 0 &&
93 | password !== confirmPassword
94 | ) {
95 | setErrorMessage('confirm_password', 'Kata sandi tidak cocok')
96 |
97 | passed = false
98 | }
99 |
100 | if (pin.length < 1) {
101 | setErrorMessage('pin', 'PIN tidak boleh kosong')
102 |
103 | passed = false
104 | }
105 |
106 | if (pin.length > 0 && pin.length < 6) {
107 | setErrorMessage('pin', 'Panjang PIN minimal 6 digit')
108 |
109 | passed = false
110 | }
111 |
112 | if (confirmPin.length < 1) {
113 | setErrorMessage('confirm_pin', 'Konfirmasi PIN tidak boleh kosong')
114 |
115 | passed = false
116 | }
117 |
118 | if (confirmPin.length > 0 && confirmPin.length < 6) {
119 | setErrorMessage('confirm_pin', 'Panjang konfirmasi PIN minimal 6 digit')
120 |
121 | passed = false
122 | }
123 |
124 | if (pin.length > 0 && confirmPin.length > 0 && pin !== confirmPin) {
125 | setErrorMessage('confirm_pin', 'PIN tidak cocok')
126 |
127 | passed = false
128 | }
129 |
130 | return passed
131 | }
132 |
133 | function getFormValues(formData: FormData) {
134 | return {
135 | password: formData.get('password') as string,
136 | confirmPassword: formData.get('confirm_password') as string,
137 | pin: formData.get('pin') as string,
138 | confirmPin: formData.get('confirm_pin') as string,
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/_components/product-card/product-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // types
4 | import type { Product } from '@/models/table-types/product'
5 | // vendors
6 | import { Button } from '@heroui/button'
7 | import { Card, CardBody, type CardProps } from '@heroui/card'
8 | import { Chip } from '@heroui/chip'
9 | import {
10 | Dropdown,
11 | DropdownTrigger,
12 | DropdownMenu,
13 | DropdownItem,
14 | } from '@heroui/dropdown'
15 | import { Image } from '@heroui/image'
16 | import { EditIcon, MoreVerticalIcon, TrashIcon } from 'lucide-react'
17 | // etc
18 | import formatNumber from '@/functions/format-number'
19 | import PageUrlEnum from '@/enums/page-url'
20 | import { useHook } from './_hook'
21 | import { Link } from '@heroui/link'
22 |
23 | export function ProductCard({
24 | data: { uuid, name, default_price, qty_unit, category, image_file, stock },
25 | className,
26 | as,
27 | }: {
28 | data: Product
29 | onClick?: () => void
30 | as?: CardProps['as']
31 | className?: CardProps['className']
32 | }) {
33 | const { handleOpenDeleteModal, deleteConfirmationModal } = useHook(uuid)
34 |
35 | const totalStock = stock.qty
36 | const cost = totalStock === 0 ? 0 : stock.cost
37 |
38 | return (
39 |
46 |
47 |
48 | {image_file && (
49 |
60 | )}
61 |
62 |
63 |
64 |
65 |
{name}
66 |
67 |
68 | {category ?? 'Tanpa Kategori'}
69 | •
70 |
71 | {formatNumber(totalStock)}
72 | {qty_unit}
73 |
74 |
75 |
76 |
77 |
78 |
HPP
79 |
{formatNumber(cost)}
80 |
81 |
82 |
83 |
Harga Jual
84 |
{formatNumber(default_price)}
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 | }
101 | as={Link}
102 | href={PageUrlEnum.PRODUCT_EDIT.replace(':uuid', uuid)}>
103 | Sunting
104 |
105 |
106 | }
109 | color="danger"
110 | className="text-danger"
111 | onClick={handleOpenDeleteModal}>
112 | Hapus
113 |
114 |
115 |
116 |
117 |
118 |
119 | {deleteConfirmationModal}
120 |
121 |
122 | )
123 | }
124 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/onboarding/steps/finish/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import PageUrlEnum from '@/enums/page-url'
4 | import { Button } from '@heroui/button'
5 | import { Card, CardBody, CardHeader } from '@heroui/card'
6 | import { Link } from '@heroui/link'
7 | import { CheckCircle2, Home, ShoppingCart, Package } from 'lucide-react'
8 |
9 | export default function Page() {
10 | return (
11 |
12 |
13 |
18 |
Selamat! 🎉
19 |
20 | Sensasi POS siap digunakan untuk mengelola bisnis Anda
21 |
22 |
23 |
24 |
25 |
26 |
27 | Setup Awal Selesai
28 |
29 |
30 |
31 |
32 |
33 |
34 |
Akun pengelola telah dibuat
35 |
36 | Anda sudah masuk dan siap mengelola aplikasi
37 |
38 |
39 |
40 |
41 |
42 |
43 |
Data produk dan stok siap
44 |
45 | Anda dapat menambah produk dan stok kapan saja
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | Langkah Selanjutnya
55 |
56 |
57 | }
63 | className="h-[unset] justify-start py-3">
64 |
65 | Mulai Transaksi Penjualan
66 |
67 | Catat setiap transaksi penjualan dengan mudah
68 |
69 |
70 |
71 |
72 | }
78 | className="h-[unset] justify-start py-3">
79 |
80 | Kelola Stok Produk
81 |
82 | Tambah, edit, atau hapus produk sesuai kebutuhan
83 |
84 |
85 |
86 |
87 | }
93 | className="h-[unset] justify-start py-3">
94 |
95 | Lihat Dashboard
96 |
97 | Pantau ringkasan bisnis Anda
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | Anda dapat mengakses pengaturan dan bantuan kapan saja dari menu
106 | navigasi
107 |
108 |
109 | )
110 | }
111 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/data/products/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // vendors
4 | import { Alert } from '@heroui/alert'
5 | import { Button } from '@heroui/button'
6 | import { Card, CardBody } from '@heroui/card'
7 | import { Chip } from '@heroui/chip'
8 | import { Input } from '@heroui/input'
9 | import { Link } from '@heroui/link'
10 | import { Select, SelectItem } from '@heroui/select'
11 | import {
12 | Barcode,
13 | // LayoutGridIcon, List,
14 | PlusCircle,
15 | Search,
16 | } from 'lucide-react'
17 | // components
18 | import PageUrlEnum from '@/enums/page-url'
19 | // sub-components
20 | import { useHook } from './_hook'
21 | import { ProductCard } from '@/app/(authenticated user)/data/products/_components/product-card'
22 |
23 | /**
24 | *
25 | * @todo Filter produk berdasarkan kategori.
26 | * @todo Tambahkan fitur pencarian produk.
27 | * @todo Tambahkan fitur pindai kode batang.
28 | */
29 | export default function ProductListPage() {
30 | const { categories, nProducts, products } = useHook()
31 |
32 | return (
33 | <>
34 |
35 |
36 |
Produk
37 |
38 | {nProducts} item
39 |
40 |
41 |
42 |
43 | }
48 | endContent={
49 |
53 |
54 |
55 | }
56 | />
57 |
58 | {/*
59 |
60 |
61 |
62 |
63 |
64 |
65 | */}
66 |
67 | }
71 | as={Link}
72 | href={PageUrlEnum.PRODUCT_CREATE}>
73 | Tambah Produk
74 |
75 |
76 |
77 |
78 |
79 | Menampilkan {nProducts} dari {nProducts} item
80 |
81 |
82 |
83 |
84 |
85 |
86 | Penyaring
87 |
88 |
98 | {categories.map(category => (
99 | {category}
100 | ))}
101 |
102 |
103 |
104 |
105 |
106 |
107 | {nProducts === 0 && (
108 |
109 |
110 | Tidak ada data produk yang ditemukan.
111 |
112 | Tambah produk baru?
113 |
114 |
115 |
116 | )}
117 |
118 |
119 | {products?.map(product => (
120 |
121 | ))}
122 |
123 |
124 |
125 | >
126 | )
127 | }
128 |
--------------------------------------------------------------------------------
/src/components/grid-pattern.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | useCallback,
5 | useEffect,
6 | useEffectEvent,
7 | useId,
8 | useRef,
9 | useState,
10 | } from 'react'
11 | import { motion } from 'framer-motion'
12 | import mergeClass from '@/functions/merge-class'
13 |
14 | interface GridPatternProps {
15 | width?: number
16 | height?: number
17 | x?: number
18 | y?: number
19 | strokeDasharray?: number
20 | numSquares?: number
21 | className?: string
22 | maxOpacity?: number
23 | duration?: number
24 | }
25 |
26 | export default function GridPattern({
27 | width = 40,
28 | height = 40,
29 | x = -1,
30 | y = -1,
31 | strokeDasharray = 0,
32 | numSquares = 50,
33 | className,
34 | maxOpacity = 0.5,
35 | duration = 4,
36 | ...props
37 | }: GridPatternProps) {
38 | const id = useId()
39 | const containerRef = useRef(null)
40 | const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
41 |
42 | const getPos = useCallback(() => {
43 | return [
44 | Math.floor((Math.random() * dimensions.width) / width),
45 | Math.floor((Math.random() * dimensions.height) / height),
46 | ]
47 | }, [dimensions.width, dimensions.height, width, height])
48 |
49 | // Adjust the generateSquares function to return objects with an id, x, and y
50 | const generateSquares = useCallback(
51 | (count: number) => {
52 | return Array.from({ length: count }, (_, i) => ({
53 | id: i,
54 | pos: getPos(),
55 | }))
56 | },
57 | [getPos],
58 | )
59 |
60 | const [squares, setSquares] = useState(() => generateSquares(numSquares))
61 |
62 | // Function to update a single square's position
63 | const updateSquarePosition = (id: number) => {
64 | setSquares(currentSquares =>
65 | currentSquares.map(sq =>
66 | sq.id === id
67 | ? {
68 | ...sq,
69 | pos: getPos(),
70 | }
71 | : sq,
72 | ),
73 | )
74 | }
75 |
76 | const updateSquares = useEffectEvent(
77 | (newSquares: ReturnType) => {
78 | setSquares(newSquares)
79 | },
80 | )
81 |
82 | // Update squares to animate in
83 | useEffect(() => {
84 | if (dimensions.width && dimensions.height) {
85 | updateSquares(generateSquares(numSquares))
86 | }
87 | }, [dimensions, numSquares, generateSquares])
88 |
89 | // Resize observer to update container dimensions
90 | useEffect(() => {
91 | const current = containerRef.current
92 | const resizeObserver = new ResizeObserver(entries => {
93 | for (const entry of entries) {
94 | setDimensions({
95 | width: entry.contentRect.width,
96 | height: entry.contentRect.height,
97 | })
98 | }
99 | })
100 |
101 | if (current) {
102 | resizeObserver.observe(current)
103 | }
104 |
105 | return () => {
106 | if (current) {
107 | resizeObserver.unobserve(current)
108 | }
109 | }
110 | }, [])
111 |
112 | return (
113 |
121 |
122 |
129 |
134 |
135 |
136 |
137 |
143 | {squares.map(({ pos: [x, y], id }, index) => (
144 | {
154 | updateSquarePosition(id)
155 | }}
156 | // biome-ignore lint: required to be unique
157 | key={`${x}-${y}-${index}`}
158 | width={width - 1}
159 | height={height - 1}
160 | x={x * width + 1}
161 | y={y * height + 1}
162 | fill="currentColor"
163 | strokeWidth="0"
164 | />
165 | ))}
166 |
167 |
168 | )
169 | }
170 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/settings/database/wipe/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 | import { Button } from '@heroui/button'
5 | import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card'
6 | import { useRouter } from 'next/navigation'
7 | import { Trash2, AlertTriangle } from 'lucide-react'
8 | import { ConfirmationModal } from '@/components/confirmation-modal'
9 | import { toast } from '@/functions/toast'
10 | import db from '@/models/db'
11 | import useAuth from '@/hooks/use-auth'
12 | import PageUrlEnum from '@/enums/page-url'
13 |
14 | export default function Page() {
15 | const [isModalOpen, setIsModalOpen] = useState(false)
16 | const [isDeleting, setIsDeleting] = useState(false)
17 | const router = useRouter()
18 | const { setLoggedInUser } = useAuth()
19 |
20 | const handleWipeDatabase = async () => {
21 | try {
22 | setIsDeleting(true)
23 |
24 | // Delete all data from all tables
25 | await db.users.clear()
26 | await db.products.clear()
27 | await db.productMovements.clear()
28 |
29 | // Clear local storage (logout user)
30 | setLoggedInUser(undefined)
31 |
32 | toast('Semua data berhasil dihapus', 'success')
33 |
34 | // Redirect to onboarding
35 | router.push(PageUrlEnum.ONBOARDING)
36 | } catch (error) {
37 | console.error('Error wiping database:', error)
38 | toast('Gagal menghapus data', 'danger')
39 | setIsDeleting(false)
40 | }
41 | }
42 |
43 | return (
44 |
45 |
46 |
47 |
48 |
49 |
Hapus Semua Data
50 |
51 |
52 | Menghapus seluruh data aplikasi secara permanen
53 |
54 |
55 |
56 |
57 |
58 |
59 |
63 |
64 |
Peringatan!
65 |
66 | Tindakan ini akan menghapus semua data yang
67 | tersimpan di aplikasi ini, termasuk:
68 |
69 |
70 | Semua data pengguna
71 | Semua data produk
72 | Semua data transaksi (pembelian & penjualan)
73 | Semua pengaturan aplikasi
74 |
75 |
76 | Data yang sudah dihapus tidak dapat dikembalikan!
77 |
78 |
79 |
80 |
81 |
82 |
83 |
Kapan menggunakan fitur ini?
84 |
85 | Untuk pengujian atau debugging
86 | Ketika data lokal rusak atau tidak sinkron
87 | Ingin memulai dari awal dengan data bersih
88 |
89 |
90 |
91 |
92 |
93 | router.back()}
97 | isDisabled={isDeleting}>
98 | Batal
99 |
100 | }
104 | onPress={() => setIsModalOpen(true)}
105 | isDisabled={isDeleting}>
106 | Hapus Semua Data
107 |
108 |
109 |
110 |
111 |
setIsModalOpen(false)}
114 | onAccept={handleWipeDatabase}
115 | title="Konfirmasi Penghapusan Data"
116 | rejectText="Batal"
117 | acceptText="Ya, Hapus Semua"
118 | color="danger">
119 |
120 | Apakah Anda yakin ingin menghapus semua data? Tindakan ini tidak dapat
121 | dibatalkan!
122 |
123 |
124 |
125 | )
126 | }
127 |
--------------------------------------------------------------------------------
/src/app/_layout-components/navbar/components/auth-navbar-items/components/settings-dropdown-button.tsx:
--------------------------------------------------------------------------------
1 | // vendors
2 | import { Button } from '@heroui/button'
3 | import {
4 | Dropdown,
5 | DropdownItem,
6 | DropdownMenu,
7 | DropdownSection,
8 | DropdownTrigger,
9 | } from '@heroui/dropdown'
10 | import { useTheme } from 'next-themes'
11 | // icons
12 | import {
13 | ChevronDown,
14 | MessageSquareTextIcon,
15 | PackageIcon,
16 | PowerCircleIcon,
17 | SettingsIcon,
18 | UserCogIcon,
19 | MenuIcon,
20 | CalculatorIcon,
21 | ShoppingCartIcon,
22 | FileSpreadsheetIcon,
23 | } from 'lucide-react'
24 | // globals
25 | import { Permission } from '@/enums/permission'
26 | import ThemeSwitcher from '@/components/theme-switcher'
27 | import PageUrlEnum from '@/enums/page-url'
28 | import useAuth from '@/hooks/use-auth'
29 | import { Link } from '@heroui/link'
30 |
31 | export function SettingDropdownButton({
32 | onFeedbackFormModalOpen,
33 | }: {
34 | onFeedbackFormModalOpen: () => void
35 | }) {
36 | const { hasAnyPermissions } = useAuth()
37 | const { theme, setTheme } = useTheme()
38 |
39 | return (
40 |
41 |
42 | }>
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | }>
62 | Kasir
63 |
64 |
65 | }>
73 | Pengadaan
74 |
75 |
76 | }>
84 | Laporan
85 |
86 |
87 |
88 | }
91 | onClick={() => {
92 | setTheme(theme === 'dark' ? 'light' : 'dark')
93 | }}>
94 | Mode Gelap
95 |
96 |
97 | }>
103 | Pengaturan
104 |
105 |
106 | }>
112 | Saran Perbaikan
113 |
114 |
115 |
123 | }>
129 | Produk
130 |
131 |
132 | }>
138 | Pengguna
139 |
140 |
141 |
142 | }>
149 | Log Keluar
150 |
151 |
152 |
153 | )
154 | }
155 |
--------------------------------------------------------------------------------
/src/app/(authenticated user)/onboarding/steps/2-add-products-and-stock/_component/product-form.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@heroui/button'
2 | import { FormProvider, type SubmitHandler, useForm } from 'react-hook-form'
3 | import type { UUID } from 'node:crypto'
4 |
5 | import { generateOrderedUuid } from '@/functions/generate-ordered-uuid'
6 | import { toast } from '@/functions/toast'
7 |
8 | import db from '@/models/db'
9 | import type { Product } from '@/models/table-types/product'
10 | import type { ProductMovement } from '@/models/table-types/product-movement'
11 | import useAuth from '@/hooks/use-auth'
12 |
13 | import TextInput from '@/components/input-controllers/text-input'
14 |
15 | interface FormValues {
16 | name: string
17 | qty_unit: string
18 | default_price: number
19 | stock_qty: number
20 | stock_cost: number
21 | code?: string
22 | category?: string
23 | description?: string
24 | }
25 |
26 | export function ProductForm({
27 | onProductAdded,
28 | }: {
29 | onProductAdded: () => void
30 | }) {
31 | const methods = useForm()
32 | const { handleSubmit, formState, reset } = methods
33 | const { user } = useAuth()
34 |
35 | const onSubmit: SubmitHandler = async data => {
36 | if (!user) {
37 | toast('Pengguna tidak ditemukan, silahkan login ulang')
38 | return
39 | }
40 |
41 | const now = new Date().toISOString()
42 | const productUuid = generateOrderedUuid() as UUID
43 |
44 | const newProduct: Product = {
45 | uuid: productUuid,
46 | name: data.name,
47 | qty_unit: data.qty_unit,
48 | default_price: Number(data.default_price),
49 | stock: {
50 | qty: Number(data.stock_qty),
51 | cost: Number(data.stock_cost),
52 | },
53 | code: data.code,
54 | category: data.category,
55 | description: data.description,
56 | created_at: now,
57 | updated_at: now,
58 | }
59 |
60 | const newProductMovement: ProductMovement = {
61 | uuid: generateOrderedUuid() as UUID,
62 | at: now,
63 | by_user_state: user,
64 | type: 'purchase',
65 | items: [
66 | {
67 | product_state: newProduct,
68 | qty: Number(data.stock_qty),
69 | price: Number(data.stock_cost),
70 | },
71 | ],
72 | additional_costs: [],
73 | files: [],
74 | created_at: now,
75 | updated_at: now,
76 | additional_info: {
77 | received_at: now,
78 | },
79 | note: '[STOK AWAL]',
80 | }
81 |
82 | try {
83 | await db.transaction('rw', db.products, db.productMovements, async () => {
84 | await db.products.add(newProduct)
85 | await db.productMovements.add(newProductMovement)
86 | })
87 |
88 | toast('Produk berhasil ditambahkan')
89 | reset()
90 | onProductAdded()
91 | } catch (error) {
92 | toast('Gagal menambahkan produk')
93 | console.error(error)
94 | }
95 | }
96 |
97 | return (
98 |
99 |
103 |
110 |
117 |
128 |
135 |
146 |
152 |
158 |
164 |
165 |
170 | Tambah Produk
171 |
172 |
173 |
174 | )
175 | }
176 |
--------------------------------------------------------------------------------