Membangun aplikasi Point of Sale (PoS) adalah salah satu cara terbaik untuk belajar Laravel secara menyeluruh. Dalam tutorial ini, kita akan membangun sistem kasir lengkap menggunakan pendekatan vibe coding β memanfaatkan AI sebagai partner development untuk mempercepat proses tanpa mengorbankan pemahaman. Mulai dari planning database, CRUD products, interface kasir real-time, hingga deploy ke Railway untuk production. Semua akan kita kerjakan step-by-step dengan prompt examples yang bisa langsung kamu praktikkan.
Bagian 1: Introduction & Planning
Apa yang Akan Kita Build?
Di tutorial ini, kita akan membangun Point of Sale (PoS) Web Application β sistem kasir berbasis web yang bisa digunakan untuk toko retail, cafe, atau bisnis lainnya.
Ini bukan tutorial CRUD sederhana. Kita akan build sistem yang production-ready dengan fitur-fitur real:
FITUR YANG AKAN KITA BANGUN:
π¦ PRODUCT MANAGEMENT
βββ Categories dengan CRUD lengkap
βββ Products dengan image upload
βββ Stock tracking
βββ Search dan filter
π POS / CASHIER INTERFACE
βββ Interface kasir yang intuitive
βββ Real-time cart dengan Alpine.js
βββ Multiple payment methods (Cash, Card, QRIS)
βββ Discount support
βββ Receipt generation
βββ Automatic stock reduction
π REPORTING & DASHBOARD
βββ Daily dan monthly sales report
βββ Best selling products
βββ Revenue charts
βββ Payment method breakdown
βββ Low stock alerts
π AUTHENTICATION & ROLES
βββ Admin: Full access ke semua fitur
βββ Cashier: Hanya POS dan view transactions
βββ Protected routes berdasarkan role
Preview Hasil Akhir
Setelah menyelesaikan tutorial ini, kamu akan punya:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β POS SYSTEM - DASHBOARD β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ β
β β Rp 2.5M β β 45 β β 120 β β 5 β β
β β Today's β β Trans β β Products β βLow Stock β β
β β Revenue β β Today β β Active β β Alert β β
β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ β
β β
β βββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββ β
β β Weekly Sales Chart β β Payment Methods Today β β
β β π Line Chart β β π₯§ Pie Chart β β
β βββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββ β
β β
β Recent Transactions: β
β INV-20250128-0001 | Rp 150.000 | Cash | Budi (Cashier) β
β INV-20250128-0002 | Rp 275.000 | QRIS | Budi (Cashier) β
β INV-20250128-0003 | Rp 89.000 | Card | Siti (Cashier) β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Dan yang paling penting: deployed dan accessible secara online di Railway.
Kenapa PoS Bagus untuk Belajar?
Point of Sale system adalah projek yang sempurna untuk portfolio karena:
- Real-world application β Banyak bisnis butuh sistem kasir
- Feature-rich β Cover banyak konsep: CRUD, auth, sessions, AJAX, charts
- Complexity yang tepat β Tidak terlalu simple, tidak overwhelming
- Demonstrable β Bisa di-demo ke potential clients atau employers
- Extendable β Bisa ditambah fitur: inventory, multi-outlet, loyalty program
Tech Stack Overview
TECHNOLOGY STACK:
βββββββββββββββββ
BACKEND:
βββ Laravel 11 (latest stable)
βββ PHP 8.2+
βββ MySQL 8.0
βββ Laravel Breeze (authentication)
βββ Eloquent ORM
FRONTEND:
βββ Blade Templates
βββ Tailwind CSS (styling)
βββ Alpine.js (reactivity)
βββ Chart.js (dashboard charts)
DEPLOYMENT:
βββ Railway (hosting platform)
βββ Railway MySQL (managed database)
βββ GitHub (version control)
βββ Automatic deployments
VIBE CODING TOOLS:
βββ Cursor (primary code editor)
βββ Claude.ai (planning & complex tasks)
βββ GitHub Copilot (optional, inline completion)
Kenapa stack ini?
- Laravel 11: Framework PHP paling populer, excellent untuk web apps
- Tailwind CSS: Rapid styling, consistent design, no custom CSS needed
- Alpine.js: Lightweight reactivity untuk POS cart, no build step
- Railway: Modern deployment, free tier generous, Laravel-friendly
Prerequisites
Sebelum mulai, pastikan kamu sudah punya:
REQUIREMENTS:
β
PHP 8.2+ terinstall
β Check: php -v
β
Composer terinstall
β Check: composer -v
β
Node.js 18+ dan NPM
β Check: node -v && npm -v
β
MySQL atau bisa pakai SQLite untuk development
β Atau langsung pakai Railway MySQL
β
Code editor (Cursor recommended)
β Download: cursor.com
β
Git terinstall
β Check: git --version
β
GitHub account
β Untuk deployment ke Railway
β
Basic Laravel knowledge
β Familiar dengan MVC, routing, Eloquent
Jika belum familiar dengan Laravel basics, tidak masalah β kita akan explain setiap langkah. Tapi basic understanding akan membantu kamu follow lebih smooth.
Vibe Coding Approach
Tutorial ini menggunakan vibe coding approach β kita akan memanfaatkan AI sebagai coding partner. Tapi bukan berarti kita blindly copy-paste.
Ini workflow yang akan kita pakai:
VIBE CODING WORKFLOW:
βββββββββββββββββββββ
1. PLAN FIRST
βββ Discuss architecture dengan AI
βββ Design database schema
βββ Tentukan development phases
βββ BEFORE any coding
2. PROMPT STRATEGICALLY
βββ Specific, detailed prompts
βββ Include context dan constraints
βββ One task per prompt
βββ Review output before accept
3. UNDERSTAND THE CODE
βββ Baca dan pahami generated code
βββ Minta explanation kalau unclear
βββ Modify sesuai kebutuhan
βββ Don't blindly copy-paste
4. ITERATE
βββ Test setiap fitur
βββ Fix issues yang muncul
βββ Improve incrementally
βββ Commit regularly
5. HUMAN-IN-THE-LOOP
βββ Kamu yang decide architecture
βββ Kamu yang review code quality
βββ Kamu yang test functionality
βββ AI assists, kamu lead
Planning dengan Claude
Sebelum menulis satu baris code pun, kita mulai dengan planning. Ini adalah langkah yang sering di-skip oleh beginners, padahal ini yang membedakan projek yang sukses dan yang berantakan.
Buka Claude.ai dan gunakan prompt berikut untuk planning:
Prompt: Initial Planning
Saya mau build Point of Sale (PoS) web application untuk retail store.
TECH STACK:
- Laravel 11
- MySQL
- Tailwind CSS
- Alpine.js untuk reactivity
- Chart.js untuk dashboard
FEATURES YANG DIBUTUHKAN:
1. Authentication
- Login/Register
- 2 roles: Admin dan Cashier
- Admin: full access
- Cashier: hanya POS dan view transactions
2. Product Management (Admin only)
- Categories CRUD
- Products CRUD dengan image upload
- Stock tracking
3. POS Interface
- Product grid dengan search dan category filter
- Add to cart functionality
- Cart dengan quantity adjustment
- Discount (percentage)
- Tax calculation (11%)
- Multiple payment methods: Cash, Card, QRIS
- Generate receipt
4. Transactions
- Transaction history dengan filters
- Transaction detail view
- Print receipt
5. Reports & Dashboard
- Today's sales summary
- Weekly/Monthly revenue chart
- Top selling products
- Payment method breakdown
Bantu saya dengan:
1. Database schema design (semua tables dan relationships)
2. List lengkap migrations yang dibutuhkan
3. Models dan relationships antar model
4. Recommended folder structure
5. Development phases (urutan development yang optimal)
Output dalam format yang terstruktur dan mudah di-follow.
Response yang Kita Harapkan
Claude akan memberikan detailed plan. Ini yang akan kita gunakan sebagai blueprint:
Database Schema Design
Berdasarkan planning, ini database schema yang akan kita bangun:
DATABASE SCHEMA:
ββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β USERS TABLE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β id β bigint, primary key, auto increment β
β name β varchar(255) β
β email β varchar(255), unique β
β email_verified β timestamp, nullable β
β password β varchar(255) β
β role β enum('admin', 'cashier'), default 'cashier' β
β remember_token β varchar(100), nullable β
β timestamps β created_at, updated_at β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
β hasMany
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TRANSACTIONS TABLE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β id β bigint, primary key β
β invoice_number β varchar(50), unique (INV-YYYYMMDD-XXXX) β
β user_id β bigint, foreign key β users β
β subtotal β decimal(12,2) β
β discount_percentβ decimal(5,2), default 0 β
β discount_amount β decimal(12,2), default 0 β
β tax_percent β decimal(5,2), default 11 β
β tax_amount β decimal(12,2) β
β grand_total β decimal(12,2) β
β payment_method β enum('cash', 'card', 'qris') β
β paid_amount β decimal(12,2) β
β change_amount β decimal(12,2) β
β notes β text, nullable β
β timestamps β created_at, updated_at β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
β hasMany
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TRANSACTION_ITEMS TABLE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β id β bigint, primary key β
β transaction_id β bigint, foreign key β transactions β
β product_id β bigint, foreign key β products β
β product_name β varchar(255) [snapshot] β
β product_price β decimal(12,2) [snapshot] β
β quantity β integer β
β subtotal β decimal(12,2) β
β timestamps β created_at, updated_at β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β²
β belongsTo
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PRODUCTS TABLE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β id β bigint, primary key β
β category_id β bigint, foreign key β categories β
β name β varchar(255) β
β sku β varchar(50), unique β
β description β text, nullable β
β price β decimal(12,2) β
β stock β integer, default 0 β
β image β varchar(255), nullable β
β is_active β boolean, default true β
β timestamps β created_at, updated_at β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β²
β belongsTo
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CATEGORIES TABLE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β id β bigint, primary key β
β name β varchar(255) β
β slug β varchar(255), unique β
β description β text, nullable β
β timestamps β created_at, updated_at β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Entity Relationship Diagram (ERD)
ββββββββββββββ ββββββββββββββββ βββββββββββββββββββββ
β categories β β products β β transaction_items β
ββββββββββββββ€ ββββββββββββββββ€ βββββββββββββββββββββ€
β id (PK) βββββ β id (PK) βββββ β id (PK) β
β name β β β category_id βββββ β transaction_id βββββ
β slug β βββββ name β β product_id βββββββββ
β descriptionβ β sku β β product_name β β β
ββββββββββββββ β price βββββββββ product_price β β β
β stock β β quantity β β β
β image β β subtotal β β β
β is_active β βββββββββββββββββββββ β β
ββββββββββββββββ β β
β β
ββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββββ β β
β users β β transactions β β β
ββββββββββββββ€ ββββββββββββββββββββββββββββββββββββββββββββ€ β β
β id (PK) βββββ β id (PK) ββββ β
β name β β β invoice_number β β
β email β βββββ user_id (FK) β β
β password β β subtotal, discount, tax, grand_total β β
β role β β payment_method, paid_amount, change β β
ββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββββ β
β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
Product reference (for data integrity)
Catatan Penting tentang Snapshots:
Perhatikan product_name dan product_price di transaction_items β ini adalah snapshots. Kenapa?
- Kalau product diedit atau dihapus setelah transaksi, history tetap akurat
- Harga saat transaksi tercatat, bukan harga current
- Ini best practice untuk financial/transaction data
Migrations yang Dibutuhkan
Berdasarkan schema, ini list migrations yang akan kita buat:
MIGRATIONS LIST:
ββββββββββββββββ
Existing (dari Laravel):
βββ 0001_01_01_000000_create_users_table.php
βββ 0001_01_01_000001_create_cache_table.php
βββ 0001_01_01_000002_create_jobs_table.php
Yang akan kita buat:
βββ 2025_01_28_000001_add_role_to_users_table.php
βββ 2025_01_28_000002_create_categories_table.php
βββ 2025_01_28_000003_create_products_table.php
βββ 2025_01_28_000004_create_transactions_table.php
βββ 2025_01_28_000005_create_transaction_items_table.php
Models dan Relationships
MODELS & RELATIONSHIPS:
βββββββββββββββββββββββ
User
βββ Attributes: name, email, password, role
βββ Constants: ROLE_ADMIN, ROLE_CASHIER
βββ Relationships:
β βββ hasMany(Transaction::class)
βββ Methods: isAdmin(), isCashier()
Category
βββ Attributes: name, slug, description
βββ Relationships:
β βββ hasMany(Product::class)
βββ Scopes: search()
Product
βββ Attributes: category_id, name, sku, description, price, stock, image, is_active
βββ Relationships:
β βββ belongsTo(Category::class)
β βββ hasMany(TransactionItem::class)
βββ Accessors: formatted_price, image_url
βββ Scopes: active(), lowStock(), search()
βββ Methods: decreaseStock(), increaseStock()
Transaction
βββ Attributes: invoice_number, user_id, subtotal, discount_*, tax_*, grand_total, payment_method, paid_amount, change_amount, notes
βββ Relationships:
β βββ belongsTo(User::class)
β βββ hasMany(TransactionItem::class)
βββ Static Methods: generateInvoiceNumber()
TransactionItem
βββ Attributes: transaction_id, product_id, product_name, product_price, quantity, subtotal
βββ Relationships:
βββ belongsTo(Transaction::class)
βββ belongsTo(Product::class)
Folder Structure
RECOMMENDED FOLDER STRUCTURE:
βββββββββββββββββββββββββββββ
app/
βββ Http/
β βββ Controllers/
β β βββ Admin/
β β β βββ CategoryController.php
β β β βββ ProductController.php
β β β βββ ReportController.php
β β β βββ UserController.php
β β βββ Cashier/
β β β βββ PosController.php
β β βββ TransactionController.php
β β βββ DashboardController.php
β βββ Middleware/
β β βββ CheckRole.php
β βββ Requests/
β βββ StoreCategoryRequest.php
β βββ StoreProductRequest.php
β βββ ProcessPaymentRequest.php
βββ Models/
β βββ Category.php
β βββ Product.php
β βββ Transaction.php
β βββ TransactionItem.php
βββ View/
βββ Components/
βββ AppLayout.php
βββ ...
resources/views/
βββ layouts/
β βββ app.blade.php
βββ components/
β βββ nav-link.blade.php
β βββ toast.blade.php
β βββ modal.blade.php
βββ admin/
β βββ categories/
β β βββ index.blade.php
β β βββ create.blade.php
β β βββ edit.blade.php
β βββ products/
β β βββ index.blade.php
β β βββ create.blade.php
β β βββ edit.blade.php
β βββ reports/
β βββ daily.blade.php
β βββ monthly.blade.php
βββ cashier/
β βββ pos.blade.php
βββ transactions/
β βββ index.blade.php
β βββ show.blade.php
β βββ receipt.blade.php
βββ dashboard.blade.php
routes/
βββ web.php (semua routes di sini)
Kenapa struktur ini?
- Admin/ dan Cashier/ folders β separation by role
- Requests/ untuk form validation β clean controllers
- Components/ untuk reusable UI β DRY principle
- Flat routes di web.php β Laravel 11 style, simple dan clear
Development Roadmap
Ini urutan development yang akan kita ikuti:
DEVELOPMENT PHASES:
βββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 1: FOUNDATION (Bagian 2) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
β β‘ Laravel installation β
β β‘ Breeze setup untuk auth β
β β‘ Add role column ke users β
β β‘ Create CheckRole middleware β
β β‘ Setup base layout dengan sidebar β
β β‘ Create admin user seeder β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 2: CATEGORIES (Bagian 3) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
β β‘ Categories migration & model β
β β‘ CategoryController dengan CRUD β
β β‘ Category views (index, create, edit) β
β β‘ Routes setup β
β β‘ Test CRUD functionality β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 3: PRODUCTS (Bagian 4) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
β β‘ Products migration & model β
β β‘ ProductController dengan image upload β
β β‘ Product views dengan filters β
β β‘ Storage link untuk images β
β β‘ Test CRUD dengan images β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 4: POS INTERFACE (Bagian 5) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
β β‘ POS layout design β
β β‘ Cart session management β
β β‘ AJAX endpoints (add, update, remove) β
β β‘ Alpine.js cart reactivity β
β β‘ Real-time total calculations β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 5: TRANSACTIONS (Bagian 6) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
β β‘ Transaction & TransactionItem models β
β β‘ Payment processing logic β
β β‘ Stock reduction β
β β‘ Invoice number generation β
β β‘ Receipt view β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 6: HISTORY & REPORTS (Bagian 7) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
β β‘ Transaction history views β
β β‘ Filters (date, payment method, etc) β
β β‘ Reports controller β
β β‘ Dashboard dengan Chart.js β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 7: POLISH (Bagian 8) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
β β‘ UI/UX improvements β
β β‘ Loading states β
β β‘ Error handling β
β β‘ Seeders untuk testing β
β β‘ Final testing β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 8: DEPLOYMENT (Bagian 9) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£
β β‘ Prepare untuk production β
β β‘ GitHub repository β
β β‘ Railway setup β
β β‘ Environment configuration β
β β‘ Deploy dan verify β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Quick Summary Bagian 1
Sebelum lanjut ke coding, pastikan kamu sudah:
CHECKLIST BAGIAN 1:
βββββββββββββββββββ
β
Understand apa yang akan kita build
βββ PoS system dengan auth, products, POS, transactions, reports
β
Tech stack clear
βββ Laravel 11, Tailwind, Alpine.js, Chart.js, Railway
β
Database schema designed
βββ 5 tables: users, categories, products, transactions, transaction_items
β
Relationships understood
βββ User β Transactions β TransactionItems β Products β Categories
β
Development roadmap clear
βββ 8 phases dari setup sampai deployment
β
Vibe coding approach understood
βββ Plan first, prompt strategically, understand code, iterate
Apa Selanjutnya?
Di Bagian 2, kita akan:
- Create Laravel project baru
- Install dan setup Laravel Breeze
- Implement user roles (Admin/Cashier)
- Create base layout dengan navigation
- Setup routing structure
Kita akan mulai coding dengan actual prompts yang bisa langsung kamu praktikkan di Cursor.
Let's build! π
Bagian 2: Project Setup & Authentication
Di bagian ini, kita akan setup foundation project β dari instalasi Laravel sampai authentication dengan role-based access. Ini adalah fondasi yang akan kita bangun di atas untuk semua fitur selanjutnya.
Step 1: Create Laravel Project
Buka terminal dan jalankan commands berikut:
# Create new Laravel project
composer create-project laravel/laravel pos-system
# Masuk ke folder project
cd pos-system
# Verify installation
php artisan --version
# Output: Laravel Framework 11.x.x
Verify struktur folder:
pos-system/
βββ app/
βββ bootstrap/
βββ config/
βββ database/
βββ public/
βββ resources/
βββ routes/
βββ storage/
βββ tests/
βββ vendor/
βββ .env
βββ artisan
βββ composer.json
βββ package.json
Step 2: Install Laravel Breeze
Laravel Breeze memberikan authentication scaffolding yang simple dan clean. Kita akan pakai Blade stack (bukan Livewire atau Inertia) untuk keep things straightforward.
# Install Breeze
composer require laravel/breeze --dev
# Install Breeze dengan Blade stack
php artisan breeze:install blade
# Install NPM dependencies
npm install
# Build assets
npm run build
# Atau untuk development dengan hot reload
npm run dev
Apa yang Breeze install?
BREEZE SCAFFOLDING:
βββββββββββββββββββ
Controllers:
βββ Auth/AuthenticatedSessionController.php
βββ Auth/ConfirmablePasswordController.php
βββ Auth/EmailVerificationController.php
βββ Auth/NewPasswordController.php
βββ Auth/PasswordController.php
βββ Auth/PasswordResetLinkController.php
βββ Auth/RegisteredUserController.php
βββ ProfileController.php
Views:
βββ auth/
β βββ confirm-password.blade.php
β βββ forgot-password.blade.php
β βββ login.blade.php
β βββ register.blade.php
β βββ reset-password.blade.php
βββ profile/
β βββ edit.blade.php
β βββ partials/
βββ layouts/
β βββ app.blade.php
β βββ guest.blade.php
β βββ navigation.blade.php
βββ dashboard.blade.php
Routes:
βββ auth.php (included in web.php)
Step 3: Database Configuration
Edit file .env untuk database connection:
# .env
APP_NAME="POS System"
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8000
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=pos_system
DB_USERNAME=root
DB_PASSWORD=
# Atau kalau mau pakai SQLite untuk simplicity:
# DB_CONNECTION=sqlite
# (hapus DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD)
Untuk MySQL, create database dulu:
-- Di MySQL client
CREATE DATABASE pos_system;
Untuk SQLite (lebih simple untuk development):
# Create SQLite database file
touch database/database.sqlite
Dan update .env:
DB_CONNECTION=sqlite
# Hapus atau comment out DB_HOST, DB_PORT, dll
Run migrations:
php artisan migrate
Output yang expected:
INFO Running migrations.
2024_01_01_000000_create_users_table .............. 15ms DONE
2024_01_01_000001_create_cache_table .............. 8ms DONE
2024_01_01_000002_create_jobs_table ............... 12ms DONE
Step 4: Add Role to Users
Sekarang kita akan menambahkan role system. Buka Cursor dan gunakan vibe coding untuk generate migration dan update model.
Prompt: Add Role Column
Laravel 11 project dengan Breeze sudah terinstall.
Saya butuh menambahkan role system ke users:
- Admin: full access ke semua fitur
- Cashier: hanya bisa akses POS dan lihat transactions
Buatkan:
1. Migration untuk add 'role' column ke users table
- Type: enum dengan values 'admin' dan 'cashier'
- Default: 'cashier'
2. Update User model dengan:
- Role constants (ROLE_ADMIN, ROLE_CASHIER)
- Helper methods: isAdmin(), isCashier()
- Cast role sebagai string
Keep it simple, tanpa package tambahan seperti Spatie Permission.
Migration: Add Role to Users
php artisan make:migration add_role_to_users_table
Edit file migration yang baru dibuat:
<?php
// database/migrations/2025_01_28_000001_add_role_to_users_table.php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->enum('role', ['admin', 'cashier'])
->default('cashier')
->after('email');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};
Run migration:
php artisan migrate
Update User Model
<?php
// app/Models/User.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
class User extends Authenticatable
{
use HasFactory, Notifiable;
// Role constants
public const ROLE_ADMIN = 'admin';
public const ROLE_CASHIER = 'cashier';
protected $fillable = [
'name',
'email',
'password',
'role',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
// Helper methods
public function isAdmin(): bool
{
return $this->role === self::ROLE_ADMIN;
}
public function isCashier(): bool
{
return $this->role === self::ROLE_CASHIER;
}
// Relationship untuk transactions (akan dipakai nanti)
public function transactions()
{
return $this->hasMany(Transaction::class);
}
}
Step 5: Create Role Middleware
Kita butuh middleware untuk protect routes berdasarkan role.
php artisan make:middleware CheckRole
Edit middleware:
<?php
// app/Http/Middleware/CheckRole.php
namespace App\\Http\\Middleware;
use Closure;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;
class CheckRole
{
/**
* Handle an incoming request.
*
* @param \\Closure(\\Illuminate\\Http\\Request): (\\Symfony\\Component\\HttpFoundation\\Response) $next
* @param string $role
*/
public function handle(Request $request, Closure $next, string $role): Response
{
// Check if user is authenticated
if (!$request->user()) {
return redirect()->route('login');
}
// Check if user has required role
if ($request->user()->role !== $role) {
// Option 1: Abort dengan 403
abort(403, 'Unauthorized. You do not have permission to access this page.');
// Option 2: Redirect dengan message (uncomment jika prefer ini)
// return redirect()->route('dashboard')
// ->with('error', 'You do not have permission to access that page.');
}
return $next($request);
}
}
Register middleware di bootstrap/app.php:
<?php
// bootstrap/app.php
use Illuminate\\Foundation\\Application;
use Illuminate\\Foundation\\Configuration\\Exceptions;
use Illuminate\\Foundation\\Configuration\\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// Register alias untuk middleware
$middleware->alias([
'role' => \\App\\Http\\Middleware\\CheckRole::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
Cara pakai middleware:
// Single role
Route::get('/admin/dashboard', ...)->middleware('role:admin');
// Di route group
Route::middleware(['auth', 'role:admin'])->group(function () {
// Admin only routes
});
Step 6: Create Admin Seeder
Buat seeder untuk default admin user:
php artisan make:seeder AdminUserSeeder
<?php
// database/seeders/AdminUserSeeder.php
namespace Database\\Seeders;
use App\\Models\\User;
use Illuminate\\Database\\Seeder;
use Illuminate\\Support\\Facades\\Hash;
class AdminUserSeeder extends Seeder
{
public function run(): void
{
// Create admin user
User::create([
'name' => 'Administrator',
'email' => '[email protected]',
'password' => Hash::make('password'),
'role' => User::ROLE_ADMIN,
'email_verified_at' => now(),
]);
// Create sample cashier user
User::create([
'name' => 'Cashier 1',
'email' => '[email protected]',
'password' => Hash::make('password'),
'role' => User::ROLE_CASHIER,
'email_verified_at' => now(),
]);
$this->command->info('Admin and Cashier users created successfully!');
$this->command->info('Admin: [email protected] / password');
$this->command->info('Cashier: [email protected] / password');
}
}
Update DatabaseSeeder.php:
<?php
// database/seeders/DatabaseSeeder.php
namespace Database\\Seeders;
use Illuminate\\Database\\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
AdminUserSeeder::class,
]);
}
}
Run seeder:
php artisan db:seed
Step 7: Setup Base Layout
Sekarang kita akan customize layout untuk PoS system. Gunakan prompt berikut di Cursor:
Prompt: Base Layout
Buatkan base layout untuk Laravel PoS system.
Requirements:
- Sidebar navigation di kiri (collapsible di mobile)
- Top navbar dengan: store name, user dropdown (profile, logout)
- Main content area di kanan
- Tailwind CSS styling
- Alpine.js untuk toggle sidebar di mobile
- Clean, professional look
Navigation items:
- Dashboard (semua role)
- Categories (admin only)
- Products (admin only)
- POS / Cashier (semua role)
- Transactions (semua role)
- Reports (admin only)
- Users (admin only)
Show/hide menu items based on user role menggunakan @if directives.
Highlight active menu item.
File: resources/views/layouts/app.blade.php
Layout Implementation
{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'POS System') }} - @yield('title', 'Dashboard')</title>
<!-- Fonts -->
<link rel="preconnect" href="<https://fonts.bunny.net>">
<link href="<https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap>" rel="stylesheet" />
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
<!-- Alpine.js -->
<script defer src="<https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js>"></script>
@stack('styles')
</head>
<body class="font-sans antialiased bg-gray-100">
<div x-data="{ sidebarOpen: false }" class="min-h-screen flex">
{{-- Mobile sidebar overlay --}}
<div
x-show="sidebarOpen"
x-transition:enter="transition-opacity ease-linear duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-linear duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-gray-600 bg-opacity-75 z-20 lg:hidden"
@click="sidebarOpen = false"
></div>
{{-- Sidebar --}}
<aside
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
class="fixed inset-y-0 left-0 z-30 w-64 bg-gray-900 transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0"
>
{{-- Logo --}}
<div class="flex items-center justify-center h-16 bg-gray-800">
<span class="text-white text-xl font-bold">π¦ POS System</span>
</div>
{{-- Navigation --}}
<nav class="mt-6 px-3">
<div class="space-y-1">
{{-- Dashboard - All roles --}}
<a href="{{ route('dashboard') }}"
class="flex items-center px-4 py-3 text-gray-300 rounded-lg hover:bg-gray-800 hover:text-white transition-colors {{ request()->routeIs('dashboard') ? 'bg-gray-800 text-white' : '' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
Dashboard
</a>
{{-- POS - All roles --}}
<a href="{{ route('pos.index') }}"
class="flex items-center px-4 py-3 text-gray-300 rounded-lg hover:bg-gray-800 hover:text-white transition-colors {{ request()->routeIs('pos.*') ? 'bg-gray-800 text-white' : '' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
POS / Cashier
</a>
{{-- Transactions - All roles --}}
<a href="{{ route('transactions.index') }}"
class="flex items-center px-4 py-3 text-gray-300 rounded-lg hover:bg-gray-800 hover:text-white transition-colors {{ request()->routeIs('transactions.*') ? 'bg-gray-800 text-white' : '' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>
Transactions
</a>
{{-- Admin Only Section --}}
@if(auth()->user()->isAdmin())
<div class="pt-4 mt-4 border-t border-gray-700">
<p class="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Admin Menu
</p>
</div>
{{-- Categories --}}
<a href="{{ route('admin.categories.index') }}"
class="flex items-center px-4 py-3 text-gray-300 rounded-lg hover:bg-gray-800 hover:text-white transition-colors {{ request()->routeIs('admin.categories.*') ? 'bg-gray-800 text-white' : '' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
</svg>
Categories
</a>
{{-- Products --}}
<a href="{{ route('admin.products.index') }}"
class="flex items-center px-4 py-3 text-gray-300 rounded-lg hover:bg-gray-800 hover:text-white transition-colors {{ request()->routeIs('admin.products.*') ? 'bg-gray-800 text-white' : '' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
Products
</a>
{{-- Reports --}}
<a href="{{ route('admin.reports.index') }}"
class="flex items-center px-4 py-3 text-gray-300 rounded-lg hover:bg-gray-800 hover:text-white transition-colors {{ request()->routeIs('admin.reports.*') ? 'bg-gray-800 text-white' : '' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
Reports
</a>
{{-- Users --}}
<a href="{{ route('admin.users.index') }}"
class="flex items-center px-4 py-3 text-gray-300 rounded-lg hover:bg-gray-800 hover:text-white transition-colors {{ request()->routeIs('admin.users.*') ? 'bg-gray-800 text-white' : '' }}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
Users
</a>
@endif
</div>
</nav>
{{-- User info at bottom --}}
<div class="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-700">
<div class="flex items-center">
<div class="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center">
<span class="text-white font-medium">{{ substr(auth()->user()->name, 0, 1) }}</span>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-white">{{ auth()->user()->name }}</p>
<p class="text-xs text-gray-400 capitalize">{{ auth()->user()->role }}</p>
</div>
</div>
</div>
</aside>
{{-- Main Content --}}
<div class="flex-1 flex flex-col min-w-0">
{{-- Top Navbar --}}
<header class="bg-white shadow-sm border-b border-gray-200">
<div class="flex items-center justify-between h-16 px-4 sm:px-6 lg:px-8">
{{-- Mobile menu button --}}
<button
@click="sidebarOpen = true"
class="lg:hidden text-gray-500 hover:text-gray-700 focus:outline-none"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
{{-- Page title --}}
<h1 class="text-lg font-semibold text-gray-800">
@yield('title', 'Dashboard')
</h1>
{{-- Right side --}}
<div class="flex items-center gap-4">
{{-- Current time --}}
<span class="hidden sm:block text-sm text-gray-500" x-data x-text="new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })"></span>
{{-- User dropdown --}}
<div x-data="{ open: false }" class="relative">
<button @click="open = !open" class="flex items-center gap-2 text-sm text-gray-700 hover:text-gray-900">
<span class="hidden sm:block">{{ auth()->user()->name }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div
x-show="open"
@click.away="open = false"
x-transition
class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50"
>
<a href="{{ route('profile.edit') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Profile
</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Logout
</button>
</form>
</div>
</div>
</div>
</div>
</header>
{{-- Flash Messages --}}
@if(session('success'))
<div class="mx-4 sm:mx-6 lg:mx-8 mt-4">
<div class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg flex items-center justify-between" x-data="{ show: true }" x-show="show">
<span>{{ session('success') }}</span>
<button @click="show = false" class="text-green-600 hover:text-green-800">×</button>
</div>
</div>
@endif
@if(session('error'))
<div class="mx-4 sm:mx-6 lg:mx-8 mt-4">
<div class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg flex items-center justify-between" x-data="{ show: true }" x-show="show">
<span>{{ session('error') }}</span>
<button @click="show = false" class="text-red-600 hover:text-red-800">×</button>
</div>
</div>
@endif
{{-- Main Content Area --}}
<main class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
@yield('content')
</main>
</div>
</div>
@stack('scripts')
</body>
</html>
Step 8: Setup Routes Structure
Sekarang setup routes di routes/web.php:
<?php
// routes/web.php
use App\\Http\\Controllers\\ProfileController;
use App\\Http\\Controllers\\DashboardController;
use App\\Http\\Controllers\\TransactionController;
use App\\Http\\Controllers\\Cashier\\PosController;
use App\\Http\\Controllers\\Admin\\CategoryController;
use App\\Http\\Controllers\\Admin\\ProductController;
use App\\Http\\Controllers\\Admin\\ReportController;
use App\\Http\\Controllers\\Admin\\UserController;
use Illuminate\\Support\\Facades\\Route;
// Public routes
Route::get('/', function () {
return redirect()->route('login');
});
// Authenticated routes
Route::middleware(['auth', 'verified'])->group(function () {
// Dashboard - All roles
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
// POS - All roles
Route::prefix('pos')->name('pos.')->group(function () {
Route::get('/', [PosController::class, 'index'])->name('index');
Route::post('/cart/add', [PosController::class, 'addToCart'])->name('cart.add');
Route::patch('/cart/update', [PosController::class, 'updateCart'])->name('cart.update');
Route::delete('/cart/remove', [PosController::class, 'removeFromCart'])->name('cart.remove');
Route::delete('/cart/clear', [PosController::class, 'clearCart'])->name('cart.clear');
Route::post('/checkout', [PosController::class, 'checkout'])->name('checkout');
});
// Transactions - All roles (filtered by role in controller)
Route::resource('transactions', TransactionController::class)->only(['index', 'show']);
Route::get('/transactions/{transaction}/receipt', [TransactionController::class, 'receipt'])->name('transactions.receipt');
// Profile
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
// Admin only routes
Route::middleware(['role:admin'])->prefix('admin')->name('admin.')->group(function () {
// Categories
Route::resource('categories', CategoryController::class);
// Products
Route::resource('products', ProductController::class);
// Reports
Route::get('/reports', [ReportController::class, 'index'])->name('reports.index');
Route::get('/reports/daily', [ReportController::class, 'daily'])->name('reports.daily');
Route::get('/reports/monthly', [ReportController::class, 'monthly'])->name('reports.monthly');
// Users
Route::resource('users', UserController::class);
});
});
require __DIR__.'/auth.php';
Step 9: Create Placeholder Controllers
Untuk sementara, buat placeholder controllers agar routes tidak error:
# Create controllers
php artisan make:controller DashboardController
php artisan make:controller TransactionController
php artisan make:controller Cashier/PosController
php artisan make:controller Admin/CategoryController --resource
php artisan make:controller Admin/ProductController --resource
php artisan make:controller Admin/ReportController
php artisan make:controller Admin/UserController --resource
DashboardController (placeholder):
<?php
// app/Http/Controllers/DashboardController.php
namespace App\\Http\\Controllers;
use Illuminate\\Http\\Request;
class DashboardController extends Controller
{
public function index()
{
return view('dashboard');
}
}
Update dashboard view untuk sementara:
{{-- resources/views/dashboard.blade.php --}}
@extends('layouts.app')
@section('title', 'Dashboard')
@section('content')
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{{-- Stat Cards --}}
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-blue-100 text-blue-600">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Today's Revenue</p>
<p class="text-2xl font-bold text-gray-900">Rp 0</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-green-100 text-green-600">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Transactions Today</p>
<p class="text-2xl font-bold text-gray-900">0</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-yellow-100 text-yellow-600">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Total Products</p>
<p class="text-2xl font-bold text-gray-900">0</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-red-100 text-red-600">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Low Stock Alert</p>
<p class="text-2xl font-bold text-gray-900">0</p>
</div>
</div>
</div>
</div>
{{-- Placeholder for charts --}}
<div class="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Weekly Sales</h3>
<div class="h-64 flex items-center justify-center text-gray-400">
Chart will be here
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Top Products</h3>
<div class="h-64 flex items-center justify-center text-gray-400">
Chart will be here
</div>
</div>
</div>
@endsection
Step 10: Test the Setup
Sekarang let's test everything:
# Start development server
php artisan serve
# Di terminal lain, start Vite untuk asset compilation
npm run dev
Buka browser dan test:
TEST CHECKLIST:
βββββββββββββββ
1. Open <http://localhost:8000>
β Should redirect to login page
2. Login dengan admin
β Email: [email protected]
β Password: password
β Should see Dashboard dengan semua menu
3. Check sidebar navigation
β Admin should see: Dashboard, POS, Transactions, Categories, Products, Reports, Users
4. Logout dan login dengan cashier
β Email: [email protected]
β Password: password
β Should see Dashboard dengan limited menu
5. Check sidebar navigation untuk cashier
β Cashier should see: Dashboard, POS, Transactions
β Should NOT see: Categories, Products, Reports, Users
6. Try access admin route sebagai cashier
β Go to /admin/categories
β Should get 403 Forbidden
Troubleshooting
Problem: "Route [pos.index] not defined"
Ini karena controller belum dibuat. Create placeholder:
// app/Http/Controllers/Cashier/PosController.php
namespace App\\Http\\Controllers\\Cashier;
use App\\Http\\Controllers\\Controller;
class PosController extends Controller
{
public function index()
{
return view('cashier.pos');
}
// Placeholder methods
public function addToCart() { }
public function updateCart() { }
public function removeFromCart() { }
public function clearCart() { }
public function checkout() { }
}
Dan buat placeholder view:
mkdir -p resources/views/cashier
{{-- resources/views/cashier/pos.blade.php --}}
@extends('layouts.app')
@section('title', 'POS')
@section('content')
<p>POS interface will be here</p>
@endsection
Lakukan hal yang sama untuk routes lain yang belum ada view-nya.
Problem: Styles not loading
npm run build
# atau
npm run dev
Problem: 403 but should have access
Check role di database:
php artisan tinker
>>> User::where('email', '[email protected]')->first()->role
# Should return "admin"
Summary Bagian 2
COMPLETED IN THIS SECTION:
ββββββββββββββββββββββββββ
β
Laravel project created
β
Breeze authentication installed
β
Role column added to users
β
CheckRole middleware created dan registered
β
Admin seeder created
β
Base layout dengan sidebar navigation
β
Routes structure setup
β
Placeholder controllers created
β
Role-based menu visibility
β
Login/logout tested untuk both roles
Apa Selanjutnya?
Di Bagian 3, kita akan build Categories CRUD:
- Migration dan Model untuk Categories
- CategoryController dengan full CRUD
- Blade views: index, create, edit
- Search dan pagination
- Proper validation
Kita akan mulai membangun fitur actual dengan vibe coding approach β complete dengan prompts yang bisa langsung kamu praktikkan.
Let's continue! π
Bagian 3: Categories CRUD
Sekarang kita mulai membangun fitur pertama yang actual β Categories management. Ini adalah fondasi untuk Products, karena setiap product akan belong to satu category. Kita akan build complete CRUD dengan search, pagination, dan proper validation menggunakan vibe coding approach.
Step 1: Create Migration
php artisan make:migration create_categories_table
Edit migration file:
<?php
// database/migrations/2025_01_28_000002_create_categories_table.php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->timestamps();
// Index untuk search performance
$table->index('name');
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};
Run migration:
php artisan migrate
Step 2: Create Model dengan Vibe Coding
Buka Cursor dan gunakan prompt berikut:
Prompt: Category Model
Buatkan Category Model untuk Laravel 11:
Table: categories
Fields: id, name, slug, description, timestamps
Requirements:
1. Fillable untuk mass assignment
2. Relationship: hasMany ke Product (akan dibuat nanti)
3. Boot method untuk auto-generate slug dari name saat create/update
4. Scope search() untuk filter by name
5. Method getRouteKeyName() return 'slug' untuk route model binding
Use Str::slug() untuk generate slug.
Handle duplicate slug dengan append angka jika sudah exist.
Category Model Implementation
<?php
// app/Models/Category.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Illuminate\\Support\\Str;
class Category extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
];
/**
* Boot method untuk auto-generate slug
*/
protected static function boot()
{
parent::boot();
static::creating(function ($category) {
$category->slug = self::generateUniqueSlug($category->name);
});
static::updating(function ($category) {
// Only regenerate slug if name changed
if ($category->isDirty('name')) {
$category->slug = self::generateUniqueSlug($category->name, $category->id);
}
});
}
/**
* Generate unique slug
*/
protected static function generateUniqueSlug(string $name, ?int $excludeId = null): string
{
$slug = Str::slug($name);
$originalSlug = $slug;
$counter = 1;
// Check if slug exists
while (self::where('slug', $slug)
->when($excludeId, fn($query) => $query->where('id', '!=', $excludeId))
->exists()) {
$slug = $originalSlug . '-' . $counter;
$counter++;
}
return $slug;
}
/**
* Use slug for route model binding
*/
public function getRouteKeyName(): string
{
return 'slug';
}
/**
* Relationship: Category has many Products
*/
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
/**
* Scope: Search by name
*/
public function scopeSearch($query, ?string $search)
{
return $query->when($search, function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
});
}
/**
* Get products count (untuk display)
*/
public function getProductsCountAttribute(): int
{
return $this->products()->count();
}
}
Step 3: Create Controller dengan Vibe Coding
Prompt: Category Controller
Buatkan CategoryController untuk Laravel 11 dengan full CRUD:
Methods needed:
- index(): List categories dengan search dan pagination (10 per page)
- create(): Show create form
- store(): Save new category dengan validation
- edit(): Show edit form
- update(): Update category dengan validation
- destroy(): Delete category (prevent jika ada products)
Validation rules:
- name: required, string, max 255, unique (except on update)
- description: nullable, string, max 1000
Requirements:
- Use Route Model Binding dengan slug
- Flash messages untuk success/error
- Redirect dengan proper route names
- Include products count di index
- Prevent delete kalau category punya products
- Clean dan readable code
Route names: admin.categories.*
CategoryController Implementation
<?php
// app/Http/Controllers/Admin/CategoryController.php
namespace App\\Http\\Controllers\\Admin;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Category;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\RedirectResponse;
use Illuminate\\View\\View;
use Illuminate\\Validation\\Rule;
class CategoryController extends Controller
{
/**
* Display a listing of categories.
*/
public function index(Request $request): View
{
$categories = Category::query()
->search($request->search)
->withCount('products')
->latest()
->paginate(10)
->withQueryString();
return view('admin.categories.index', compact('categories'));
}
/**
* Show the form for creating a new category.
*/
public function create(): View
{
return view('admin.categories.create');
}
/**
* Store a newly created category.
*/
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255', 'unique:categories,name'],
'description' => ['nullable', 'string', 'max:1000'],
], [
'name.required' => 'Nama category wajib diisi.',
'name.unique' => 'Nama category sudah digunakan.',
'name.max' => 'Nama category maksimal 255 karakter.',
'description.max' => 'Deskripsi maksimal 1000 karakter.',
]);
Category::create($validated);
return redirect()
->route('admin.categories.index')
->with('success', 'Category berhasil ditambahkan.');
}
/**
* Show the form for editing the specified category.
*/
public function edit(Category $category): View
{
return view('admin.categories.edit', compact('category'));
}
/**
* Update the specified category.
*/
public function update(Request $request, Category $category): RedirectResponse
{
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
Rule::unique('categories', 'name')->ignore($category->id),
],
'description' => ['nullable', 'string', 'max:1000'],
], [
'name.required' => 'Nama category wajib diisi.',
'name.unique' => 'Nama category sudah digunakan.',
'name.max' => 'Nama category maksimal 255 karakter.',
'description.max' => 'Deskripsi maksimal 1000 karakter.',
]);
$category->update($validated);
return redirect()
->route('admin.categories.index')
->with('success', 'Category berhasil diupdate.');
}
/**
* Remove the specified category.
*/
public function destroy(Category $category): RedirectResponse
{
// Check if category has products
if ($category->products()->exists()) {
return redirect()
->route('admin.categories.index')
->with('error', 'Category tidak bisa dihapus karena masih memiliki products.');
}
$category->delete();
return redirect()
->route('admin.categories.index')
->with('success', 'Category berhasil dihapus.');
}
}
Step 4: Create Views
Buat folder structure untuk views:
mkdir -p resources/views/admin/categories
Prompt: Category Views
Buatkan Blade views untuk Categories CRUD:
1. index.blade.php
- Search form
- Table dengan columns: No, Name, Slug, Products Count, Actions
- Pagination links
- Create button
- Edit dan Delete actions di setiap row
- Konfirmasi sebelum delete
- Empty state kalau tidak ada data
2. create.blade.php
- Form dengan Name dan Description fields
- Validation error display
- Cancel dan Save buttons
3. edit.blade.php
- Same as create tapi pre-filled dengan data existing
- Show slug (readonly, auto-generated)
Styling: Tailwind CSS
Layout: extends 'layouts.app'
Use Alpine.js untuk delete confirmation modal.
Index View
{{-- resources/views/admin/categories/index.blade.php --}}
@extends('layouts.app')
@section('title', 'Categories')
@section('content')
{{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h2 class="text-2xl font-bold text-gray-800">Categories</h2>
<p class="text-gray-600 mt-1">Manage product categories</p>
</div>
<a href="{{ route('admin.categories.create') }}"
class="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add Category
</a>
</div>
{{-- Search --}}
<div class="bg-white rounded-lg shadow mb-6">
<div class="p-4">
<form action="{{ route('admin.categories.index') }}" method="GET">
<div class="flex gap-4">
<div class="flex-1">
<input
type="text"
name="search"
value="{{ request('search') }}"
placeholder="Search categories..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
</div>
<button type="submit" class="px-4 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-900 transition-colors">
Search
</button>
@if(request('search'))
<a href="{{ route('admin.categories.index') }}" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
Clear
</a>
@endif
</div>
</form>
</div>
</div>
{{-- Table --}}
<div class="bg-white rounded-lg shadow overflow-hidden">
@if($categories->count() > 0)
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-16">
No
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Slug
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-32">
Products
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider w-40">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach($categories as $index => $category)
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $categories->firstItem() + $index }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ $category->name }}</div>
@if($category->description)
<div class="text-sm text-gray-500 truncate max-w-xs">{{ $category->description }}</div>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded">
{{ $category->slug }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $category->products_count > 0 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600' }}">
{{ $category->products_count }} products
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ route('admin.categories.edit', $category) }}"
class="text-blue-600 hover:text-blue-900">
Edit
</a>
{{-- Delete with confirmation --}}
<div x-data="{ showConfirm: false }" class="relative">
<button @click="showConfirm = true" class="text-red-600 hover:text-red-900">
Delete
</button>
{{-- Confirmation Modal --}}
<div x-show="showConfirm"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center"
style="display: none;">
<div class="fixed inset-0 bg-black opacity-50" @click="showConfirm = false"></div>
<div class="bg-white rounded-lg shadow-xl p-6 max-w-sm mx-4 relative z-10">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Delete Category</h3>
<p class="text-gray-600 mb-4">
Are you sure you want to delete "<strong>{{ $category->name }}</strong>"?
This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button @click="showConfirm = false"
class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300">
Cancel
</button>
<form action="{{ route('admin.categories.destroy', $category) }}" method="POST">
@csrf
@method('DELETE')
<button type="submit"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
Delete
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
{{-- Pagination --}}
@if($categories->hasPages())
<div class="px-6 py-4 border-t border-gray-200">
{{ $categories->links() }}
</div>
@endif
@else
{{-- Empty State --}}
<div class="px-6 py-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No categories found</h3>
<p class="mt-2 text-gray-500">
@if(request('search'))
No categories match your search. Try different keywords.
@else
Get started by creating your first category.
@endif
</p>
@if(!request('search'))
<div class="mt-6">
<a href="{{ route('admin.categories.create') }}"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add Category
</a>
</div>
@endif
</div>
@endif
</div>
@endsection
Create View
{{-- resources/views/admin/categories/create.blade.php --}}
@extends('layouts.app')
@section('title', 'Create Category')
@section('content')
{{-- Header --}}
<div class="mb-6">
<div class="flex items-center gap-2 text-sm text-gray-600 mb-2">
<a href="{{ route('admin.categories.index') }}" class="hover:text-blue-600">Categories</a>
<span>/</span>
<span>Create</span>
</div>
<h2 class="text-2xl font-bold text-gray-800">Create New Category</h2>
</div>
{{-- Form --}}
<div class="bg-white rounded-lg shadow">
<form action="{{ route('admin.categories.store') }}" method="POST">
@csrf
<div class="p-6 space-y-6">
{{-- Name --}}
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
Name <span class="text-red-500">*</span>
</label>
<input
type="text"
name="name"
id="name"
value="{{ old('name') }}"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('name') border-red-500 @enderror"
placeholder="Enter category name"
required
autofocus
>
@error('name')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
<p class="mt-1 text-sm text-gray-500">Slug akan di-generate otomatis dari nama.</p>
</div>
{{-- Description --}}
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
name="description"
id="description"
rows="4"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('description') border-red-500 @enderror"
placeholder="Enter category description (optional)"
>{{ old('description') }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
</div>
{{-- Actions --}}
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200 flex items-center justify-end gap-3 rounded-b-lg">
<a href="{{ route('admin.categories.index') }}"
class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors">
Cancel
</a>
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
Save Category
</button>
</div>
</form>
</div>
@endsection
Edit View
{{-- resources/views/admin/categories/edit.blade.php --}}
@extends('layouts.app')
@section('title', 'Edit Category')
@section('content')
{{-- Header --}}
<div class="mb-6">
<div class="flex items-center gap-2 text-sm text-gray-600 mb-2">
<a href="{{ route('admin.categories.index') }}" class="hover:text-blue-600">Categories</a>
<span>/</span>
<span>Edit</span>
</div>
<h2 class="text-2xl font-bold text-gray-800">Edit Category</h2>
</div>
{{-- Form --}}
<div class="bg-white rounded-lg shadow">
<form action="{{ route('admin.categories.update', $category) }}" method="POST">
@csrf
@method('PUT')
<div class="p-6 space-y-6">
{{-- Name --}}
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
Name <span class="text-red-500">*</span>
</label>
<input
type="text"
name="name"
id="name"
value="{{ old('name', $category->name) }}"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('name') border-red-500 @enderror"
placeholder="Enter category name"
required
autofocus
>
@error('name')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
{{-- Slug (readonly) --}}
<div>
<label for="slug" class="block text-sm font-medium text-gray-700 mb-2">
Slug
</label>
<input
type="text"
id="slug"
value="{{ $category->slug }}"
class="w-full px-4 py-2 border rounded-lg bg-gray-100 text-gray-600"
readonly
>
<p class="mt-1 text-sm text-gray-500">Slug akan di-update otomatis jika nama berubah.</p>
</div>
{{-- Description --}}
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
name="description"
id="description"
rows="4"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('description') border-red-500 @enderror"
placeholder="Enter category description (optional)"
>{{ old('description', $category->description) }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
{{-- Info --}}
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Category Info</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Products:</span>
<span class="ml-2 font-medium">{{ $category->products()->count() }}</span>
</div>
<div>
<span class="text-gray-500">Created:</span>
<span class="ml-2 font-medium">{{ $category->created_at->format('d M Y') }}</span>
</div>
</div>
</div>
</div>
{{-- Actions --}}
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200 flex items-center justify-end gap-3 rounded-b-lg">
<a href="{{ route('admin.categories.index') }}"
class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors">
Cancel
</a>
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
Update Category
</button>
</div>
</form>
</div>
@endsection
Step 5: Create Category Seeder
Untuk testing, buat seeder dengan sample categories:
php artisan make:seeder CategorySeeder
<?php
// database/seeders/CategorySeeder.php
namespace Database\\Seeders;
use App\\Models\\Category;
use Illuminate\\Database\\Seeder;
class CategorySeeder extends Seeder
{
public function run(): void
{
$categories = [
[
'name' => 'Makanan',
'description' => 'Berbagai jenis makanan siap saji dan kemasan',
],
[
'name' => 'Minuman',
'description' => 'Minuman dingin, hangat, dan kemasan',
],
[
'name' => 'Snack',
'description' => 'Makanan ringan dan cemilan',
],
[
'name' => 'Rokok',
'description' => 'Berbagai merek rokok',
],
[
'name' => 'Toiletries',
'description' => 'Perlengkapan mandi dan kebersihan diri',
],
[
'name' => 'Obat-obatan',
'description' => 'Obat-obatan bebas dan suplemen',
],
[
'name' => 'ATK',
'description' => 'Alat tulis dan perlengkapan kantor',
],
[
'name' => 'Elektronik',
'description' => 'Aksesoris elektronik dan gadget',
],
];
foreach ($categories as $category) {
Category::create($category);
}
$this->command->info('Categories seeded successfully! (' . count($categories) . ' categories)');
}
}
Update DatabaseSeeder.php:
<?php
// database/seeders/DatabaseSeeder.php
namespace Database\\Seeders;
use Illuminate\\Database\\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
AdminUserSeeder::class,
CategorySeeder::class,
]);
}
}
Run seeder:
# Run specific seeder
php artisan db:seed --class=CategorySeeder
# Atau fresh migrate dengan semua seeders
php artisan migrate:fresh --seed
Step 6: Test Categories CRUD
Jalankan development server:
php artisan serve
npm run dev
Buka browser dan test:
TEST CHECKLIST:
βββββββββββββββ
1. LOGIN AS ADMIN
β Email: [email protected]
β Password: password
2. NAVIGATE TO CATEGORIES
β Click "Categories" di sidebar
β Should see list of seeded categories
3. TEST SEARCH
β Search "makan"
β Should show "Makanan" dan "Snack" (jika description match)
β Clear search, semua muncul lagi
4. TEST CREATE
β Click "Add Category"
β Fill: Name = "Test Category"
β Description = "This is a test"
β Submit
β Should redirect to index dengan success message
β New category should appear
5. TEST VALIDATION
β Create dengan name kosong β Error message
β Create dengan name yang sudah ada β "Nama category sudah digunakan"
6. TEST EDIT
β Click "Edit" di salah satu category
β Change name
β Submit
β Should update dan slug ikut berubah
7. TEST DELETE
β Click "Delete" di category tanpa products
β Confirm di modal
β Should delete successfully
8. TEST DELETE PROTECTION
β Ini akan di-test nanti setelah ada products
β Category dengan products tidak bisa dihapus
9. TEST PAGINATION
β Kalau lebih dari 10 categories, pagination muncul
β Click page 2, URL should have ?page=2
10. TEST AS CASHIER
β Logout, login sebagai cashier
β Try access /admin/categories
β Should get 403 Forbidden
Step 7: Minor Fixes & Improvements
Setelah testing, mungkin ada beberapa improvement yang perlu:
Fix Pagination Styling
Jika pagination tidak styled dengan baik, publish pagination views:
php artisan vendor:publish --tag=laravel-pagination
Atau gunakan simple pagination styling di tailwind.config.js:
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./resources/**/*.blade.php",
"./resources/**/*.js",
"./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php",
],
// ...
};
Add Loading State pada Form Submit
Update create dan edit views dengan Alpine.js loading state:
{{-- Di form tag --}}
<form
action="{{ route('admin.categories.store') }}"
method="POST"
x-data="{ submitting: false }"
@submit="submitting = true"
>
{{-- ... form content ... --}}
{{-- Di submit button --}}
<button
type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
:disabled="submitting"
>
<span x-show="!submitting">Save Category</span>
<span x-show="submitting" class="flex items-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Saving...
</span>
</button>
</form>
Summary Bagian 3
COMPLETED IN THIS SECTION:
ββββββββββββββββββββββββββ
β
Categories migration created
βββ Table: id, name, slug, description, timestamps
β
Category model dengan:
βββ Auto-generate unique slug
βββ Route model binding (slug)
βββ HasMany relationship ke products
βββ Search scope
β
CategoryController dengan full CRUD:
βββ index (search, pagination)
βββ create & store (validation)
βββ edit & update (validation)
βββ destroy (protection jika ada products)
β
Blade views:
βββ index (table, search, pagination, delete modal)
βββ create (form dengan validation errors)
βββ edit (pre-filled form)
β
CategorySeeder dengan 8 sample categories
β
All tested dan working
File Structure After This Section
app/
βββ Http/
β βββ Controllers/
β βββ Admin/
β βββ CategoryController.php β NEW
βββ Models/
β βββ Category.php β NEW
database/
βββ migrations/
β βββ 2025_01_28_000002_create_categories_table.php β NEW
βββ seeders/
βββ CategorySeeder.php β NEW
βββ DatabaseSeeder.php β UPDATED
resources/views/
βββ admin/
βββ categories/
βββ index.blade.php β NEW
βββ create.blade.php β NEW
βββ edit.blade.php β NEW
Apa Selanjutnya?
Di Bagian 4, kita akan build Products CRUD dengan:
- Product migration dengan relationship ke Category
- Image upload handling
- Stock tracking
- Filter by category dan search
- Grid dan table view toggle
Products adalah core dari PoS system, jadi ini akan lebih comprehensive dengan image handling dan more complex UI.
Let's continue building! π
Bagian 4: Products CRUD dengan Image Upload
Products adalah core dari sistem PoS. Di bagian ini, kita akan build complete product management dengan image upload, stock tracking, category filtering, dan UI yang lebih rich dibanding categories. Ini adalah bagian yang paling comprehensive sejauh ini.
Step 1: Create Migration
php artisan make:migration create_products_table
<?php
// database/migrations/2025_01_28_000003_create_products_table.php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->foreignId('category_id')->constrained()->onDelete('restrict');
$table->string('name');
$table->string('sku', 50)->unique();
$table->text('description')->nullable();
$table->decimal('price', 12, 2);
$table->integer('stock')->default(0);
$table->string('image')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
// Indexes untuk performance
$table->index('name');
$table->index('sku');
$table->index('is_active');
$table->index(['category_id', 'is_active']);
});
}
public function down(): void
{
Schema::dropIfExists('products');
}
};
Run migration:
php artisan migrate
Step 2: Create Product Model
Prompt: Product Model
Buatkan Product Model untuk Laravel 11:
Table fields: id, category_id, name, sku, description, price, stock, image, is_active, timestamps
Requirements:
1. Fillable untuk mass assignment
2. Casts: price as decimal, is_active as boolean
3. Relationships:
- belongsTo Category
- hasMany TransactionItem (untuk nanti)
4. Accessors:
- formatted_price: format ke Rupiah (Rp 10.000)
- image_url: return full URL atau default image
5. Scopes:
- active(): where is_active = true
- search($term): search by name atau sku
- lowStock($threshold = 10): stock <= threshold
- byCategory($categoryId): filter by category
6. Methods:
- decreaseStock($qty): kurangi stock
- increaseStock($qty): tambah stock
- hasEnoughStock($qty): check stock >= qty
Product Model Implementation
<?php
// app/Models/Product.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Illuminate\\Support\\Facades\\Storage;
class Product extends Model
{
use HasFactory;
protected $fillable = [
'category_id',
'name',
'sku',
'description',
'price',
'stock',
'image',
'is_active',
];
protected $casts = [
'price' => 'decimal:2',
'stock' => 'integer',
'is_active' => 'boolean',
];
// ==================== RELATIONSHIPS ====================
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function transactionItems(): HasMany
{
return $this->hasMany(TransactionItem::class);
}
// ==================== ACCESSORS ====================
/**
* Get formatted price in Rupiah
*/
public function getFormattedPriceAttribute(): string
{
return 'Rp ' . number_format($this->price, 0, ',', '.');
}
/**
* Get image URL or default placeholder
*/
public function getImageUrlAttribute(): string
{
if ($this->image && Storage::disk('public')->exists($this->image)) {
return Storage::url($this->image);
}
// Return placeholder image
return '<https://via.placeholder.com/200x200?text=No+Image>';
}
// ==================== SCOPES ====================
/**
* Scope: Only active products
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope: Search by name or SKU
*/
public function scopeSearch($query, ?string $term)
{
return $query->when($term, function ($q) use ($term) {
$q->where(function ($query) use ($term) {
$query->where('name', 'like', "%{$term}%")
->orWhere('sku', 'like', "%{$term}%");
});
});
}
/**
* Scope: Low stock products
*/
public function scopeLowStock($query, int $threshold = 10)
{
return $query->where('stock', '<=', $threshold);
}
/**
* Scope: Filter by category
*/
public function scopeByCategory($query, $categoryId)
{
return $query->when($categoryId, function ($q) use ($categoryId) {
$q->where('category_id', $categoryId);
});
}
/**
* Scope: Available for sale (active AND has stock)
*/
public function scopeAvailable($query)
{
return $query->active()->where('stock', '>', 0);
}
// ==================== METHODS ====================
/**
* Decrease stock
*/
public function decreaseStock(int $quantity): bool
{
if ($this->stock < $quantity) {
return false;
}
$this->decrement('stock', $quantity);
return true;
}
/**
* Increase stock
*/
public function increaseStock(int $quantity): void
{
$this->increment('stock', $quantity);
}
/**
* Check if product has enough stock
*/
public function hasEnoughStock(int $quantity): bool
{
return $this->stock >= $quantity;
}
/**
* Check if product is low on stock
*/
public function isLowStock(int $threshold = 10): bool
{
return $this->stock <= $threshold;
}
/**
* Check if product is out of stock
*/
public function isOutOfStock(): bool
{
return $this->stock <= 0;
}
}
Step 3: Setup Storage for Images
Laravel butuh storage link untuk serve uploaded files:
php artisan storage:link
Ini akan create symbolic link dari public/storage ke storage/app/public.
Buat folder untuk product images:
mkdir -p storage/app/public/products
Step 4: Create Product Controller
Prompt: Product Controller
Buatkan ProductController untuk Laravel 11:
Methods:
- index(): List products dengan search, category filter, pagination 12/page
- create(): Show form dengan categories dropdown
- store(): Save dengan image upload ke storage/app/public/products
- edit(): Show form pre-filled
- update(): Update dengan optional new image (delete old)
- destroy(): Delete product dan image-nya
Validation:
- category_id: required, exists
- name: required, max 255
- sku: required, max 50, unique (except update)
- description: nullable
- price: required, numeric, min 0
- stock: required, integer, min 0
- image: nullable, image, mimes:jpg,jpeg,png,webp, max:2048
- is_active: boolean
Features:
- Flash messages
- Proper redirects
- Image handling (upload, replace, delete)
- Route model binding
ProductController Implementation
<?php
// app/Http/Controllers/Admin/ProductController.php
namespace App\\Http\\Controllers\\Admin;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Category;
use App\\Models\\Product;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\RedirectResponse;
use Illuminate\\Support\\Facades\\Storage;
use Illuminate\\Validation\\Rule;
use Illuminate\\View\\View;
class ProductController extends Controller
{
/**
* Display a listing of products.
*/
public function index(Request $request): View
{
$products = Product::query()
->with('category')
->search($request->search)
->byCategory($request->category)
->when($request->stock === 'low', fn($q) => $q->lowStock())
->when($request->stock === 'out', fn($q) => $q->where('stock', 0))
->when($request->status === 'active', fn($q) => $q->active())
->when($request->status === 'inactive', fn($q) => $q->where('is_active', false))
->latest()
->paginate(12)
->withQueryString();
$categories = Category::orderBy('name')->get();
return view('admin.products.index', compact('products', 'categories'));
}
/**
* Show the form for creating a new product.
*/
public function create(): View
{
$categories = Category::orderBy('name')->get();
return view('admin.products.create', compact('categories'));
}
/**
* Store a newly created product.
*/
public function store(Request $request): RedirectResponse
{
$validated = $this->validateProduct($request);
// Handle image upload
if ($request->hasFile('image')) {
$validated['image'] = $request->file('image')->store('products', 'public');
}
Product::create($validated);
return redirect()
->route('admin.products.index')
->with('success', 'Product berhasil ditambahkan.');
}
/**
* Show the form for editing the specified product.
*/
public function edit(Product $product): View
{
$categories = Category::orderBy('name')->get();
return view('admin.products.edit', compact('product', 'categories'));
}
/**
* Update the specified product.
*/
public function update(Request $request, Product $product): RedirectResponse
{
$validated = $this->validateProduct($request, $product->id);
// Handle image upload
if ($request->hasFile('image')) {
// Delete old image
if ($product->image) {
Storage::disk('public')->delete($product->image);
}
$validated['image'] = $request->file('image')->store('products', 'public');
}
// Handle image removal
if ($request->boolean('remove_image') && $product->image) {
Storage::disk('public')->delete($product->image);
$validated['image'] = null;
}
$product->update($validated);
return redirect()
->route('admin.products.index')
->with('success', 'Product berhasil diupdate.');
}
/**
* Remove the specified product.
*/
public function destroy(Product $product): RedirectResponse
{
// Delete image if exists
if ($product->image) {
Storage::disk('public')->delete($product->image);
}
$product->delete();
return redirect()
->route('admin.products.index')
->with('success', 'Product berhasil dihapus.');
}
/**
* Validate product data
*/
private function validateProduct(Request $request, ?int $productId = null): array
{
return $request->validate([
'category_id' => ['required', 'exists:categories,id'],
'name' => ['required', 'string', 'max:255'],
'sku' => [
'required',
'string',
'max:50',
Rule::unique('products', 'sku')->ignore($productId),
],
'description' => ['nullable', 'string', 'max:2000'],
'price' => ['required', 'numeric', 'min:0', 'max:999999999'],
'stock' => ['required', 'integer', 'min:0', 'max:999999'],
'image' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
'is_active' => ['boolean'],
], [
'category_id.required' => 'Category wajib dipilih.',
'category_id.exists' => 'Category tidak valid.',
'name.required' => 'Nama product wajib diisi.',
'sku.required' => 'SKU wajib diisi.',
'sku.unique' => 'SKU sudah digunakan.',
'price.required' => 'Harga wajib diisi.',
'price.numeric' => 'Harga harus berupa angka.',
'price.min' => 'Harga tidak boleh negatif.',
'stock.required' => 'Stock wajib diisi.',
'stock.integer' => 'Stock harus berupa bilangan bulat.',
'stock.min' => 'Stock tidak boleh negatif.',
'image.image' => 'File harus berupa gambar.',
'image.mimes' => 'Format gambar harus jpg, jpeg, png, atau webp.',
'image.max' => 'Ukuran gambar maksimal 2MB.',
]);
}
}
Step 5: Create Product Views
Index View (dengan Grid Layout)
{{-- resources/views/admin/products/index.blade.php --}}
@extends('layouts.app')
@section('title', 'Products')
@section('content')
{{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h2 class="text-2xl font-bold text-gray-800">Products</h2>
<p class="text-gray-600 mt-1">Manage your product inventory</p>
</div>
<a href="{{ route('admin.products.create') }}"
class="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add Product
</a>
</div>
{{-- Filters --}}
<div class="bg-white rounded-lg shadow mb-6">
<div class="p-4">
<form action="{{ route('admin.products.index') }}" method="GET">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
{{-- Search --}}
<div>
<input
type="text"
name="search"
value="{{ request('search') }}"
placeholder="Search name or SKU..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
</div>
{{-- Category Filter --}}
<div>
<select name="category" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">All Categories</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ request('category') == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
</div>
{{-- Stock Filter --}}
<div>
<select name="stock" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">All Stock</option>
<option value="low" {{ request('stock') === 'low' ? 'selected' : '' }}>Low Stock (β€10)</option>
<option value="out" {{ request('stock') === 'out' ? 'selected' : '' }}>Out of Stock</option>
</select>
</div>
{{-- Buttons --}}
<div class="flex gap-2">
<button type="submit" class="flex-1 px-4 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-900 transition-colors">
Filter
</button>
@if(request()->hasAny(['search', 'category', 'stock', 'status']))
<a href="{{ route('admin.products.index') }}" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
Clear
</a>
@endif
</div>
</div>
</form>
</div>
</div>
{{-- Stats Bar --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Total Products</p>
<p class="text-2xl font-bold text-gray-800">{{ \\App\\Models\\Product::count() }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Active</p>
<p class="text-2xl font-bold text-green-600">{{ \\App\\Models\\Product::active()->count() }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Low Stock</p>
<p class="text-2xl font-bold text-yellow-600">{{ \\App\\Models\\Product::lowStock()->count() }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Out of Stock</p>
<p class="text-2xl font-bold text-red-600">{{ \\App\\Models\\Product::where('stock', 0)->count() }}</p>
</div>
</div>
{{-- Products Grid --}}
@if($products->count() > 0)
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
@foreach($products as $product)
<div class="bg-white rounded-lg shadow overflow-hidden hover:shadow-lg transition-shadow">
{{-- Image --}}
<div class="relative aspect-square bg-gray-100">
<img
src="{{ $product->image_url }}"
alt="{{ $product->name }}"
class="w-full h-full object-cover"
>
{{-- Status Badge --}}
@if(!$product->is_active)
<span class="absolute top-2 left-2 px-2 py-1 bg-gray-800 text-white text-xs font-medium rounded">
Inactive
</span>
@endif
{{-- Stock Badge --}}
@if($product->stock <= 0)
<span class="absolute top-2 right-2 px-2 py-1 bg-red-600 text-white text-xs font-medium rounded">
Out of Stock
</span>
@elseif($product->isLowStock())
<span class="absolute top-2 right-2 px-2 py-1 bg-yellow-500 text-white text-xs font-medium rounded">
Low Stock
</span>
@endif
</div>
{{-- Content --}}
<div class="p-4">
<div class="flex items-start justify-between mb-2">
<div>
<h3 class="font-semibold text-gray-800 line-clamp-1">{{ $product->name }}</h3>
<p class="text-sm text-gray-500">{{ $product->sku }}</p>
</div>
</div>
<div class="flex items-center justify-between mb-3">
<span class="text-lg font-bold text-blue-600">{{ $product->formatted_price }}</span>
<span class="text-sm text-gray-500">Stock: {{ $product->stock }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">
{{ $product->category->name }}
</span>
<div class="flex items-center gap-2">
<a href="{{ route('admin.products.edit', $product) }}"
class="text-blue-600 hover:text-blue-800">
Edit
</a>
{{-- Delete --}}
<div x-data="{ showConfirm: false }">
<button @click="showConfirm = true" class="text-red-600 hover:text-red-800">
Delete
</button>
{{-- Delete Modal --}}
<div x-show="showConfirm"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center"
style="display: none;">
<div class="fixed inset-0 bg-black opacity-50" @click="showConfirm = false"></div>
<div class="bg-white rounded-lg shadow-xl p-6 max-w-sm mx-4 relative z-10">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Delete Product</h3>
<p class="text-gray-600 mb-4">
Delete "<strong>{{ $product->name }}</strong>"? This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button @click="showConfirm = false" class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300">
Cancel
</button>
<form action="{{ route('admin.products.destroy', $product) }}" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
Delete
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@endforeach
</div>
{{-- Pagination --}}
@if($products->hasPages())
<div class="mt-6">
{{ $products->links() }}
</div>
@endif
@else
{{-- Empty State --}}
<div class="bg-white rounded-lg shadow px-6 py-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No products found</h3>
<p class="mt-2 text-gray-500">
@if(request()->hasAny(['search', 'category', 'stock']))
No products match your filters. Try adjusting your search.
@else
Get started by adding your first product.
@endif
</p>
@if(!request()->hasAny(['search', 'category', 'stock']))
<div class="mt-6">
<a href="{{ route('admin.products.create') }}" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add Product
</a>
</div>
@endif
</div>
@endif
@endsection
Create View
{{-- resources/views/admin/products/create.blade.php --}}
@extends('layouts.app')
@section('title', 'Create Product')
@section('content')
{{-- Header --}}
<div class="mb-6">
<div class="flex items-center gap-2 text-sm text-gray-600 mb-2">
<a href="{{ route('admin.products.index') }}" class="hover:text-blue-600">Products</a>
<span>/</span>
<span>Create</span>
</div>
<h2 class="text-2xl font-bold text-gray-800">Create New Product</h2>
</div>
{{-- Form --}}
<form action="{{ route('admin.products.store') }}" method="POST" enctype="multipart/form-data" x-data="productForm()">
@csrf
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Main Content --}}
<div class="lg:col-span-2 space-y-6">
{{-- Basic Info --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Basic Information</h3>
<div class="space-y-4">
{{-- Name --}}
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
Product Name <span class="text-red-500">*</span>
</label>
<input
type="text"
name="name"
id="name"
value="{{ old('name') }}"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('name') border-red-500 @enderror"
placeholder="Enter product name"
required
>
@error('name')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
{{-- SKU --}}
<div>
<label for="sku" class="block text-sm font-medium text-gray-700 mb-2">
SKU <span class="text-red-500">*</span>
</label>
<input
type="text"
name="sku"
id="sku"
value="{{ old('sku') }}"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('sku') border-red-500 @enderror"
placeholder="e.g., PRD-001"
required
>
@error('sku')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
{{-- Description --}}
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
name="description"
id="description"
rows="4"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('description') border-red-500 @enderror"
placeholder="Enter product description (optional)"
>{{ old('description') }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
</div>
</div>
{{-- Pricing & Stock --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Pricing & Stock</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{-- Price --}}
<div>
<label for="price" class="block text-sm font-medium text-gray-700 mb-2">
Price (Rp) <span class="text-red-500">*</span>
</label>
<div class="relative">
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">Rp</span>
<input
type="number"
name="price"
id="price"
value="{{ old('price') }}"
class="w-full pl-12 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('price') border-red-500 @enderror"
placeholder="0"
min="0"
step="100"
required
>
</div>
@error('price')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
{{-- Stock --}}
<div>
<label for="stock" class="block text-sm font-medium text-gray-700 mb-2">
Stock <span class="text-red-500">*</span>
</label>
<input
type="number"
name="stock"
id="stock"
value="{{ old('stock', 0) }}"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('stock') border-red-500 @enderror"
placeholder="0"
min="0"
required
>
@error('stock')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
</div>
</div>
</div>
{{-- Sidebar --}}
<div class="space-y-6">
{{-- Category & Status --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Organization</h3>
<div class="space-y-4">
{{-- Category --}}
<div>
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-2">
Category <span class="text-red-500">*</span>
</label>
<select
name="category_id"
id="category_id"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('category_id') border-red-500 @enderror"
required
>
<option value="">Select category</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
{{-- Status --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
<label class="flex items-center">
<input
type="checkbox"
name="is_active"
value="1"
{{ old('is_active', true) ? 'checked' : '' }}
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
>
<span class="ml-2 text-sm text-gray-700">Active (visible in POS)</span>
</label>
</div>
</div>
</div>
{{-- Image --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Product Image</h3>
<div>
{{-- Preview --}}
<div class="mb-4">
<div
class="aspect-square bg-gray-100 rounded-lg overflow-hidden flex items-center justify-center"
x-show="!imagePreview"
>
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<img
x-show="imagePreview"
:src="imagePreview"
class="aspect-square w-full object-cover rounded-lg"
style="display: none;"
>
</div>
{{-- Upload Input --}}
<input
type="file"
name="image"
id="image"
accept="image/jpeg,image/png,image/webp"
@change="previewImage($event)"
class="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
>
@error('image')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
<p class="mt-2 text-xs text-gray-500">JPG, PNG or WebP. Max 2MB.</p>
</div>
</div>
{{-- Actions --}}
<div class="bg-white rounded-lg shadow p-6">
<div class="space-y-3">
<button type="submit" class="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
Save Product
</button>
<a href="{{ route('admin.products.index') }}" class="block w-full px-4 py-2 text-center text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors">
Cancel
</a>
</div>
</div>
</div>
</div>
</form>
<script>
function productForm() {
return {
imagePreview: null,
previewImage(event) {
const file = event.target.files[0];
if (file) {
this.imagePreview = URL.createObjectURL(file);
}
}
}
}
</script>
@endsection
Edit View
{{-- resources/views/admin/products/edit.blade.php --}}
@extends('layouts.app')
@section('title', 'Edit Product')
@section('content')
{{-- Header --}}
<div class="mb-6">
<div class="flex items-center gap-2 text-sm text-gray-600 mb-2">
<a href="{{ route('admin.products.index') }}" class="hover:text-blue-600">Products</a>
<span>/</span>
<span>Edit</span>
</div>
<h2 class="text-2xl font-bold text-gray-800">Edit Product</h2>
</div>
{{-- Form --}}
<form action="{{ route('admin.products.update', $product) }}" method="POST" enctype="multipart/form-data" x-data="productForm()">
@csrf
@method('PUT')
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Main Content --}}
<div class="lg:col-span-2 space-y-6">
{{-- Basic Info --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Basic Information</h3>
<div class="space-y-4">
{{-- Name --}}
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
Product Name <span class="text-red-500">*</span>
</label>
<input
type="text"
name="name"
id="name"
value="{{ old('name', $product->name) }}"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('name') border-red-500 @enderror"
required
>
@error('name')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
{{-- SKU --}}
<div>
<label for="sku" class="block text-sm font-medium text-gray-700 mb-2">
SKU <span class="text-red-500">*</span>
</label>
<input
type="text"
name="sku"
id="sku"
value="{{ old('sku', $product->sku) }}"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('sku') border-red-500 @enderror"
required
>
@error('sku')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
{{-- Description --}}
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
name="description"
id="description"
rows="4"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>{{ old('description', $product->description) }}</textarea>
</div>
</div>
</div>
{{-- Pricing & Stock --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Pricing & Stock</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{-- Price --}}
<div>
<label for="price" class="block text-sm font-medium text-gray-700 mb-2">
Price (Rp) <span class="text-red-500">*</span>
</label>
<div class="relative">
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">Rp</span>
<input
type="number"
name="price"
id="price"
value="{{ old('price', $product->price) }}"
class="w-full pl-12 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
min="0"
step="100"
required
>
</div>
@error('price')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
{{-- Stock --}}
<div>
<label for="stock" class="block text-sm font-medium text-gray-700 mb-2">
Stock <span class="text-red-500">*</span>
</label>
<input
type="number"
name="stock"
id="stock"
value="{{ old('stock', $product->stock) }}"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
min="0"
required
>
@error('stock')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
</div>
</div>
</div>
{{-- Sidebar --}}
<div class="space-y-6">
{{-- Category & Status --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Organization</h3>
<div class="space-y-4">
{{-- Category --}}
<div>
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-2">
Category <span class="text-red-500">*</span>
</label>
<select
name="category_id"
id="category_id"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ old('category_id', $product->category_id) == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
</div>
{{-- Status --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
<label class="flex items-center">
<input
type="checkbox"
name="is_active"
value="1"
{{ old('is_active', $product->is_active) ? 'checked' : '' }}
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
>
<span class="ml-2 text-sm text-gray-700">Active (visible in POS)</span>
</label>
</div>
</div>
</div>
{{-- Image --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Product Image</h3>
<div>
{{-- Current/Preview --}}
<div class="mb-4">
<img
:src="imagePreview || '{{ $product->image_url }}'"
class="aspect-square w-full object-cover rounded-lg"
>
</div>
{{-- Upload Input --}}
<input
type="file"
name="image"
id="image"
accept="image/jpeg,image/png,image/webp"
@change="previewImage($event)"
class="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
>
@if($product->image)
<label class="flex items-center mt-3">
<input
type="checkbox"
name="remove_image"
value="1"
class="w-4 h-4 text-red-600 border-gray-300 rounded focus:ring-red-500"
>
<span class="ml-2 text-sm text-red-600">Remove current image</span>
</label>
@endif
<p class="mt-2 text-xs text-gray-500">JPG, PNG or WebP. Max 2MB.</p>
</div>
</div>
{{-- Product Info --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Product Info</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">Created</span>
<span>{{ $product->created_at->format('d M Y H:i') }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Updated</span>
<span>{{ $product->updated_at->format('d M Y H:i') }}</span>
</div>
</div>
</div>
{{-- Actions --}}
<div class="bg-white rounded-lg shadow p-6">
<div class="space-y-3">
<button type="submit" class="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
Update Product
</button>
<a href="{{ route('admin.products.index') }}" class="block w-full px-4 py-2 text-center text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors">
Cancel
</a>
</div>
</div>
</div>
</div>
</form>
<script>
function productForm() {
return {
imagePreview: null,
previewImage(event) {
const file = event.target.files[0];
if (file) {
this.imagePreview = URL.createObjectURL(file);
}
}
}
}
</script>
@endsection
Step 6: Create Product Seeder
php artisan make:seeder ProductSeeder
<?php
// database/seeders/ProductSeeder.php
namespace Database\\Seeders;
use App\\Models\\Category;
use App\\Models\\Product;
use Illuminate\\Database\\Seeder;
class ProductSeeder extends Seeder
{
public function run(): void
{
$products = [
// Makanan
['category' => 'Makanan', 'name' => 'Nasi Goreng Spesial', 'sku' => 'MKN-001', 'price' => 25000, 'stock' => 50],
['category' => 'Makanan', 'name' => 'Mie Goreng', 'sku' => 'MKN-002', 'price' => 20000, 'stock' => 50],
['category' => 'Makanan', 'name' => 'Ayam Geprek', 'sku' => 'MKN-003', 'price' => 18000, 'stock' => 30],
['category' => 'Makanan', 'name' => 'Indomie Goreng', 'sku' => 'MKN-004', 'price' => 5000, 'stock' => 100],
// Minuman
['category' => 'Minuman', 'name' => 'Es Teh Manis', 'sku' => 'MNM-001', 'price' => 5000, 'stock' => 100],
['category' => 'Minuman', 'name' => 'Es Jeruk', 'sku' => 'MNM-002', 'price' => 7000, 'stock' => 80],
['category' => 'Minuman', 'name' => 'Kopi Hitam', 'sku' => 'MNM-003', 'price' => 8000, 'stock' => 60],
['category' => 'Minuman', 'name' => 'Aqua 600ml', 'sku' => 'MNM-004', 'price' => 4000, 'stock' => 200],
['category' => 'Minuman', 'name' => 'Coca Cola', 'sku' => 'MNM-005', 'price' => 8000, 'stock' => 50],
// Snack
['category' => 'Snack', 'name' => 'Chitato Original', 'sku' => 'SNK-001', 'price' => 12000, 'stock' => 40],
['category' => 'Snack', 'name' => 'Taro Net', 'sku' => 'SNK-002', 'price' => 3000, 'stock' => 100],
['category' => 'Snack', 'name' => 'Oreo', 'sku' => 'SNK-003', 'price' => 10000, 'stock' => 60],
['category' => 'Snack', 'name' => 'Coklat Silverqueen', 'sku' => 'SNK-004', 'price' => 15000, 'stock' => 30],
// Rokok
['category' => 'Rokok', 'name' => 'Gudang Garam Surya', 'sku' => 'RKK-001', 'price' => 28000, 'stock' => 100],
['category' => 'Rokok', 'name' => 'Sampoerna Mild', 'sku' => 'RKK-002', 'price' => 30000, 'stock' => 100],
['category' => 'Rokok', 'name' => 'Djarum Super', 'sku' => 'RKK-003', 'price' => 25000, 'stock' => 80],
// Toiletries
['category' => 'Toiletries', 'name' => 'Sabun Lifebuoy', 'sku' => 'TLT-001', 'price' => 5000, 'stock' => 50],
['category' => 'Toiletries', 'name' => 'Shampoo Pantene Sachet', 'sku' => 'TLT-002', 'price' => 1500, 'stock' => 200],
['category' => 'Toiletries', 'name' => 'Pasta Gigi Pepsodent', 'sku' => 'TLT-003', 'price' => 12000, 'stock' => 40],
// Low stock examples
['category' => 'Snack', 'name' => 'Pocky Strawberry', 'sku' => 'SNK-005', 'price' => 14000, 'stock' => 5],
['category' => 'Minuman', 'name' => 'Yakult', 'sku' => 'MNM-006', 'price' => 10000, 'stock' => 3],
// Out of stock example
['category' => 'Makanan', 'name' => 'Bakso Spesial', 'sku' => 'MKN-005', 'price' => 20000, 'stock' => 0],
];
foreach ($products as $productData) {
$category = Category::where('name', $productData['category'])->first();
if ($category) {
Product::create([
'category_id' => $category->id,
'name' => $productData['name'],
'sku' => $productData['sku'],
'price' => $productData['price'],
'stock' => $productData['stock'],
'is_active' => true,
]);
}
}
$this->command->info('Products seeded successfully! (' . count($products) . ' products)');
}
}
Update DatabaseSeeder.php:
<?php
namespace Database\\Seeders;
use Illuminate\\Database\\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
AdminUserSeeder::class,
CategorySeeder::class,
ProductSeeder::class,
]);
}
}
Run seeder:
php artisan db:seed --class=ProductSeeder
# Atau fresh dengan semua seeders
php artisan migrate:fresh --seed
Step 7: Test Products CRUD
TEST CHECKLIST:
βββββββββββββββ
1. LOGIN AS ADMIN
β Navigate ke Products
2. CHECK INDEX PAGE
β Should see product grid dengan images
β Stats bar: Total, Active, Low Stock, Out of Stock
β Low stock badges (yellow)
β Out of stock badges (red)
3. TEST FILTERS
β Search "goreng" β filter results
β Category dropdown β filter by category
β Stock filter "Low Stock" β show products dengan stock β€ 10
β Clear filters
4. TEST CREATE
β Click "Add Product"
β Fill all fields
β Upload image (test preview)
β Submit
β Should redirect dengan success message
β Image should display
5. TEST VALIDATION
β Submit tanpa required fields β error messages
β Duplicate SKU β error message
6. TEST EDIT
β Edit a product
β Change image β preview updates
β Submit β image replaced
β Check "Remove image" β image removed
7. TEST DELETE
β Delete a product
β Confirm di modal
β Product dan image deleted
8. CHECK IMAGE UPLOAD
β Create product dengan image
β Check storage/app/public/products folder
β Image file should exist
β Display correctly di browser
Summary Bagian 4
COMPLETED IN THIS SECTION:
ββββββββββββββββββββββββββ
β
Products migration dengan:
βββ Foreign key ke categories
βββ All required fields
βββ Proper indexes
β
Product model dengan:
βββ Relationships (belongsTo Category)
βββ Accessors (formatted_price, image_url)
βββ Scopes (active, search, lowStock, byCategory)
βββ Methods (decreaseStock, increaseStock, hasEnoughStock)
β
ProductController dengan:
βββ Full CRUD operations
βββ Image upload handling
βββ Image replacement & deletion
βββ Comprehensive validation
βββ Indonesian error messages
β
Product views:
βββ index (grid layout, filters, stats, badges)
βββ create (2-column layout, image preview)
βββ edit (pre-filled, remove image option)
β
Storage setup untuk images
β
ProductSeeder dengan 22 sample products
Files Created/Modified
app/
βββ Models/
β βββ Product.php β NEW
βββ Http/Controllers/Admin/
βββ ProductController.php β UPDATED (was placeholder)
database/
βββ migrations/
β βββ 2025_01_28_000003_create_products_table.php β NEW
βββ seeders/
βββ ProductSeeder.php β NEW
βββ DatabaseSeeder.php β UPDATED
resources/views/admin/products/
βββ index.blade.php β NEW
βββ create.blade.php β NEW
βββ edit.blade.php β NEW
storage/app/public/
βββ products/ β FOLDER for uploaded images
Apa Selanjutnya?
Di Bagian 5, kita akan build POS Interface β the heart of the system:
- POS layout dengan product grid dan cart
- Session-based cart management
- AJAX endpoints untuk cart operations
- Alpine.js untuk real-time reactivity
- Discount dan tax calculations
Ini adalah bagian yang paling exciting karena akan membuat sistem kita benar-benar functional sebagai Point of Sale!
Let's build the cashier interface! π
Bagian 5: POS Interface
Ini adalah jantung dari sistem PoS β interface kasir yang akan digunakan untuk melakukan transaksi sehari-hari. Kita akan build interface dengan product grid di kiri, cart di kanan, dan semua interaksi menggunakan AJAX untuk pengalaman yang smooth tanpa page reload.
POS Interface Design
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β POS / Cashier Selasa, 28 Jan 2025 β
ββββββββββββββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ€
β PRODUCTS (2/3 width) β CART (1/3 width) β
β β β
β [Search products...] [All Categories βΌ] β π Current Order β
β β β
β βββββββββ βββββββββ βββββββββ βββββββββ β Nasi Goreng x2 50k β
β β [IMG] β β [IMG] β β [IMG] β β [IMG] β β [-] [2] [+] [π] β
β β Nasi β β Mie β β Ayam β β Es Tehβ β β
β βGoreng β βGoreng β βGeprek β β Manis β β Es Teh Manis x3 15k β
β β 25.000β β 20.000β β 18.000β β 5.000 β β [-] [3] [+] [π] β
β β stk:50β β stk:50β β stk:30β βstk:100β β β
β βββββββββ βββββββββ βββββββββ βββββββββ β ββββββββββββββββββββββ β
β β Subtotal: Rp 65.000 β
β βββββββββ βββββββββ βββββββββ βββββββββ β Discount: [__]% -6.500 β
β β [IMG] β β [IMG] β β [IMG] β β [IMG] β β Tax (11%): Rp 6.435 β
β β ... β β ... β β ... β β ... β β ββββββββββββββββββββββ β
β βββββββββ βββββββββ βββββββββ βββββββββ β TOTAL: Rp 64.935 β
β β β
β β [Clear Cart] β
β β [π³ PROCESS PAYMENT] β
ββββββββββββββββββββββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββ
Step 1: Update POS Controller
<?php
// app/Http/Controllers/Cashier/PosController.php
namespace App\\Http\\Controllers\\Cashier;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Category;
use App\\Models\\Product;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\View\\View;
class PosController extends Controller
{
/**
* Display POS interface
*/
public function index(Request $request): View
{
$products = Product::query()
->with('category')
->active()
->where('stock', '>', 0)
->when($request->search, function ($query, $search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('sku', 'like', "%{$search}%");
});
})
->when($request->category, function ($query, $categoryId) {
$query->where('category_id', $categoryId);
})
->orderBy('name')
->get();
$categories = Category::whereHas('products', function ($query) {
$query->active()->where('stock', '>', 0);
})->orderBy('name')->get();
$cart = session('pos_cart', []);
return view('cashier.pos', compact('products', 'categories', 'cart'));
}
/**
* Add product to cart
*/
public function addToCart(Request $request): JsonResponse
{
$request->validate([
'product_id' => 'required|exists:products,id',
]);
$product = Product::findOrFail($request->product_id);
// Check if product is available
if (!$product->is_active || $product->stock <= 0) {
return response()->json([
'success' => false,
'message' => 'Product tidak tersedia.',
], 400);
}
$cart = session('pos_cart', []);
$productId = $product->id;
if (isset($cart[$productId])) {
// Check stock
$newQty = $cart[$productId]['quantity'] + 1;
if ($newQty > $product->stock) {
return response()->json([
'success' => false,
'message' => 'Stock tidak mencukupi. Tersedia: ' . $product->stock,
], 400);
}
$cart[$productId]['quantity'] = $newQty;
$cart[$productId]['subtotal'] = $newQty * $product->price;
} else {
$cart[$productId] = [
'id' => $product->id,
'name' => $product->name,
'price' => $product->price,
'quantity' => 1,
'subtotal' => $product->price,
'stock' => $product->stock,
];
}
session(['pos_cart' => $cart]);
return response()->json([
'success' => true,
'message' => $product->name . ' ditambahkan ke cart.',
'cart' => $cart,
'totals' => $this->calculateTotals($cart),
]);
}
/**
* Update cart item quantity
*/
public function updateCart(Request $request): JsonResponse
{
$request->validate([
'product_id' => 'required|exists:products,id',
'quantity' => 'required|integer|min:0',
]);
$product = Product::findOrFail($request->product_id);
$cart = session('pos_cart', []);
$productId = $product->id;
if (!isset($cart[$productId])) {
return response()->json([
'success' => false,
'message' => 'Product tidak ada di cart.',
], 400);
}
if ($request->quantity <= 0) {
unset($cart[$productId]);
} else {
if ($request->quantity > $product->stock) {
return response()->json([
'success' => false,
'message' => 'Stock tidak mencukupi. Tersedia: ' . $product->stock,
], 400);
}
$cart[$productId]['quantity'] = $request->quantity;
$cart[$productId]['subtotal'] = $request->quantity * $product->price;
}
session(['pos_cart' => $cart]);
return response()->json([
'success' => true,
'cart' => $cart,
'totals' => $this->calculateTotals($cart),
]);
}
/**
* Remove item from cart
*/
public function removeFromCart(Request $request): JsonResponse
{
$request->validate([
'product_id' => 'required',
]);
$cart = session('pos_cart', []);
$productId = $request->product_id;
if (isset($cart[$productId])) {
unset($cart[$productId]);
session(['pos_cart' => $cart]);
}
return response()->json([
'success' => true,
'message' => 'Item dihapus dari cart.',
'cart' => $cart,
'totals' => $this->calculateTotals($cart),
]);
}
/**
* Clear entire cart
*/
public function clearCart(): JsonResponse
{
session()->forget('pos_cart');
return response()->json([
'success' => true,
'message' => 'Cart dikosongkan.',
'cart' => [],
'totals' => $this->calculateTotals([]),
]);
}
/**
* Calculate cart totals
*/
private function calculateTotals(array $cart, float $discountPercent = 0): array
{
$subtotal = collect($cart)->sum('subtotal');
$discountAmount = $subtotal * ($discountPercent / 100);
$afterDiscount = $subtotal - $discountAmount;
$taxPercent = 11;
$taxAmount = $afterDiscount * ($taxPercent / 100);
$grandTotal = $afterDiscount + $taxAmount;
return [
'subtotal' => round($subtotal),
'discount_percent' => $discountPercent,
'discount_amount' => round($discountAmount),
'tax_percent' => $taxPercent,
'tax_amount' => round($taxAmount),
'grand_total' => round($grandTotal),
'items_count' => collect($cart)->sum('quantity'),
];
}
}
Step 2: Create POS View
{{-- resources/views/cashier/pos.blade.php --}}
@extends('layouts.app')
@section('title', 'POS / Cashier')
@section('content')
<div x-data="posApp()" x-init="init()" class="h-[calc(100vh-10rem)]">
<div class="flex gap-6 h-full">
{{-- LEFT: Products Section --}}
<div class="flex-1 flex flex-col min-w-0">
{{-- Search & Filter --}}
<div class="bg-white rounded-lg shadow p-4 mb-4">
<div class="flex gap-4">
<div class="flex-1">
<input
type="text"
x-model="searchQuery"
@input.debounce.300ms="filterProducts()"
placeholder="Cari produk..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
</div>
<select
x-model="selectedCategory"
@change="filterProducts()"
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Semua Kategori</option>
@foreach($categories as $category)
<option value="{{ $category->id }}">{{ $category->name }}</option>
@endforeach
</select>
</div>
</div>
{{-- Products Grid --}}
<div class="flex-1 overflow-y-auto bg-white rounded-lg shadow p-4">
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
@foreach($products as $product)
<div
class="product-card cursor-pointer bg-gray-50 rounded-lg p-3 hover:bg-blue-50 hover:ring-2 hover:ring-blue-500 transition-all"
data-category="{{ $product->category_id }}"
data-name="{{ strtolower($product->name) }}"
data-sku="{{ strtolower($product->sku) }}"
@click="addToCart({{ $product->id }})"
>
<div class="aspect-square bg-gray-200 rounded-lg mb-2 overflow-hidden">
<img
src="{{ $product->image_url }}"
alt="{{ $product->name }}"
class="w-full h-full object-cover"
loading="lazy"
>
</div>
<h4 class="font-medium text-gray-800 text-sm line-clamp-2 mb-1">{{ $product->name }}</h4>
<p class="text-blue-600 font-bold text-sm">{{ $product->formatted_price }}</p>
<p class="text-xs text-gray-500">Stock: {{ $product->stock }}</p>
</div>
@endforeach
</div>
{{-- Empty State --}}
@if($products->isEmpty())
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<p class="mt-4 text-gray-500">Tidak ada produk tersedia</p>
</div>
@endif
</div>
</div>
{{-- RIGHT: Cart Section --}}
<div class="w-96 flex flex-col bg-white rounded-lg shadow">
{{-- Cart Header --}}
<div class="p-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-800">π Order Saat Ini</h3>
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-sm font-medium rounded-full" x-text="totals.items_count + ' item'"></span>
</div>
</div>
{{-- Cart Items --}}
<div class="flex-1 overflow-y-auto p-4">
<template x-if="Object.keys(cart).length === 0">
<div class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
<p class="mt-2 text-gray-500">Cart kosong</p>
<p class="text-sm text-gray-400">Klik produk untuk menambahkan</p>
</div>
</template>
<template x-for="item in Object.values(cart)" :key="item.id">
<div class="flex items-center gap-3 py-3 border-b border-gray-100">
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-800 text-sm truncate" x-text="item.name"></h4>
<p class="text-sm text-gray-500" x-text="formatRupiah(item.price) + ' Γ ' + item.quantity"></p>
</div>
{{-- Quantity Controls --}}
<div class="flex items-center gap-1">
<button
@click="updateQuantity(item.id, item.quantity - 1)"
class="w-7 h-7 flex items-center justify-center bg-gray-200 rounded hover:bg-gray-300 text-sm font-bold"
>β</button>
<span class="w-8 text-center text-sm font-medium" x-text="item.quantity"></span>
<button
@click="updateQuantity(item.id, item.quantity + 1)"
class="w-7 h-7 flex items-center justify-center bg-gray-200 rounded hover:bg-gray-300 text-sm font-bold"
>+</button>
</div>
{{-- Subtotal & Remove --}}
<div class="text-right">
<p class="font-bold text-gray-800 text-sm" x-text="formatRupiah(item.subtotal)"></p>
<button
@click="removeItem(item.id)"
class="text-xs text-red-500 hover:text-red-700"
>Hapus</button>
</div>
</div>
</template>
</div>
{{-- Cart Footer: Totals --}}
<div class="border-t border-gray-200 p-4 space-y-3">
{{-- Subtotal --}}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Subtotal</span>
<span class="font-medium" x-text="formatRupiah(totals.subtotal)"></span>
</div>
{{-- Discount --}}
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">Diskon</span>
<div class="flex items-center gap-2">
<input
type="number"
x-model.number="discountPercent"
@input="calculateTotals()"
min="0"
max="100"
class="w-16 px-2 py-1 text-right border border-gray-300 rounded text-sm"
>
<span class="text-gray-500">%</span>
<span class="text-red-500 font-medium w-24 text-right" x-text="'-' + formatRupiah(totals.discount_amount)"></span>
</div>
</div>
{{-- Tax --}}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Pajak (11%)</span>
<span class="font-medium" x-text="formatRupiah(totals.tax_amount)"></span>
</div>
{{-- Grand Total --}}
<div class="flex justify-between text-lg font-bold border-t border-gray-200 pt-3">
<span>TOTAL</span>
<span class="text-blue-600" x-text="formatRupiah(totals.grand_total)"></span>
</div>
{{-- Action Buttons --}}
<div class="space-y-2 pt-2">
<button
@click="clearCart()"
:disabled="Object.keys(cart).length === 0"
class="w-full px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Kosongkan Cart
</button>
<button
@click="openPaymentModal()"
:disabled="Object.keys(cart).length === 0"
class="w-full px-4 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
π³ PROSES PEMBAYARAN
</button>
</div>
</div>
</div>
</div>
{{-- Payment Modal --}}
<div
x-show="showPaymentModal"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="display: none;"
>
<div class="fixed inset-0 bg-black/50" @click="showPaymentModal = false"></div>
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative z-10">
{{-- Modal Header --}}
<div class="p-6 border-b border-gray-200">
<h3 class="text-xl font-bold text-gray-800">Pembayaran</h3>
<p class="text-sm text-gray-500 mt-1">Total: <span class="font-bold text-blue-600" x-text="formatRupiah(totals.grand_total)"></span></p>
</div>
{{-- Modal Body --}}
<div class="p-6 space-y-4">
{{-- Payment Method --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Metode Pembayaran</label>
<div class="grid grid-cols-3 gap-3">
<button
@click="paymentMethod = 'cash'"
:class="paymentMethod === 'cash' ? 'ring-2 ring-blue-500 bg-blue-50' : 'bg-gray-100'"
class="p-3 rounded-lg text-center hover:bg-gray-200 transition-colors"
>
<span class="text-2xl">π΅</span>
<p class="text-sm font-medium mt-1">Cash</p>
</button>
<button
@click="paymentMethod = 'card'; paidAmount = totals.grand_total"
:class="paymentMethod === 'card' ? 'ring-2 ring-blue-500 bg-blue-50' : 'bg-gray-100'"
class="p-3 rounded-lg text-center hover:bg-gray-200 transition-colors"
>
<span class="text-2xl">π³</span>
<p class="text-sm font-medium mt-1">Card</p>
</button>
<button
@click="paymentMethod = 'qris'; paidAmount = totals.grand_total"
:class="paymentMethod === 'qris' ? 'ring-2 ring-blue-500 bg-blue-50' : 'bg-gray-100'"
class="p-3 rounded-lg text-center hover:bg-gray-200 transition-colors"
>
<span class="text-2xl">π±</span>
<p class="text-sm font-medium mt-1">QRIS</p>
</button>
</div>
</div>
{{-- Paid Amount (only for cash) --}}
<div x-show="paymentMethod === 'cash'">
<label class="block text-sm font-medium text-gray-700 mb-2">Jumlah Dibayar</label>
<div class="relative">
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">Rp</span>
<input
type="number"
x-model.number="paidAmount"
class="w-full pl-12 pr-4 py-3 text-lg border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="0"
>
</div>
{{-- Quick Amount Buttons --}}
<div class="flex flex-wrap gap-2 mt-2">
<button @click="paidAmount = totals.grand_total" class="px-3 py-1 bg-gray-200 rounded text-sm hover:bg-gray-300">Uang Pas</button>
<button @click="paidAmount = Math.ceil(totals.grand_total / 10000) * 10000" class="px-3 py-1 bg-gray-200 rounded text-sm hover:bg-gray-300" x-text="formatRupiah(Math.ceil(totals.grand_total / 10000) * 10000)"></button>
<button @click="paidAmount = Math.ceil(totals.grand_total / 50000) * 50000" class="px-3 py-1 bg-gray-200 rounded text-sm hover:bg-gray-300" x-text="formatRupiah(Math.ceil(totals.grand_total / 50000) * 50000)"></button>
<button @click="paidAmount = Math.ceil(totals.grand_total / 100000) * 100000" class="px-3 py-1 bg-gray-200 rounded text-sm hover:bg-gray-300" x-text="formatRupiah(Math.ceil(totals.grand_total / 100000) * 100000)"></button>
</div>
</div>
{{-- Change --}}
<div x-show="paymentMethod === 'cash' && paidAmount >= totals.grand_total" class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex justify-between items-center">
<span class="text-green-800 font-medium">Kembalian</span>
<span class="text-2xl font-bold text-green-600" x-text="formatRupiah(paidAmount - totals.grand_total)"></span>
</div>
</div>
{{-- Insufficient --}}
<div x-show="paymentMethod === 'cash' && paidAmount > 0 && paidAmount < totals.grand_total" class="bg-red-50 border border-red-200 rounded-lg p-4">
<p class="text-red-600 text-sm">Jumlah pembayaran kurang <span class="font-bold" x-text="formatRupiah(totals.grand_total - paidAmount)"></span></p>
</div>
{{-- Notes --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Catatan (opsional)</label>
<input
type="text"
x-model="paymentNotes"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Catatan tambahan..."
>
</div>
</div>
{{-- Modal Footer --}}
<div class="p-6 border-t border-gray-200 flex gap-3">
<button
@click="showPaymentModal = false"
class="flex-1 px-4 py-3 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors"
>
Batal
</button>
<button
@click="processPayment()"
:disabled="processing || (paymentMethod === 'cash' && paidAmount < totals.grand_total)"
class="flex-1 px-4 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!processing">Konfirmasi</span>
<span x-show="processing" class="flex items-center justify-center">
<svg class="animate-spin h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Processing...
</span>
</button>
</div>
</div>
</div>
{{-- Toast Notification --}}
<div
x-show="toast.show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-2"
:class="toast.type === 'success' ? 'bg-green-500' : 'bg-red-500'"
class="fixed bottom-4 right-4 px-6 py-3 text-white rounded-lg shadow-lg z-50"
style="display: none;"
>
<span x-text="toast.message"></span>
</div>
</div>
@push('scripts')
<script>
function posApp() {
return {
// State
cart: @json($cart),
totals: {
subtotal: 0,
discount_percent: 0,
discount_amount: 0,
tax_percent: 11,
tax_amount: 0,
grand_total: 0,
items_count: 0,
},
discountPercent: 0,
searchQuery: '',
selectedCategory: '',
// Payment Modal
showPaymentModal: false,
paymentMethod: 'cash',
paidAmount: 0,
paymentNotes: '',
processing: false,
// Toast
toast: { show: false, message: '', type: 'success' },
// Initialize
init() {
this.calculateTotals();
},
// Format currency
formatRupiah(amount) {
return 'Rp ' + new Intl.NumberFormat('id-ID').format(amount);
},
// Show toast
showToast(message, type = 'success') {
this.toast = { show: true, message, type };
setTimeout(() => this.toast.show = false, 3000);
},
// Calculate totals
calculateTotals() {
const subtotal = Object.values(this.cart).reduce((sum, item) => sum + item.subtotal, 0);
const discountAmount = subtotal * (this.discountPercent / 100);
const afterDiscount = subtotal - discountAmount;
const taxAmount = afterDiscount * 0.11;
const grandTotal = afterDiscount + taxAmount;
const itemsCount = Object.values(this.cart).reduce((sum, item) => sum + item.quantity, 0);
this.totals = {
subtotal: Math.round(subtotal),
discount_percent: this.discountPercent,
discount_amount: Math.round(discountAmount),
tax_percent: 11,
tax_amount: Math.round(taxAmount),
grand_total: Math.round(grandTotal),
items_count: itemsCount,
};
},
// Filter products (client-side)
filterProducts() {
const search = this.searchQuery.toLowerCase();
const category = this.selectedCategory;
document.querySelectorAll('.product-card').forEach(card => {
const name = card.dataset.name;
const sku = card.dataset.sku;
const cat = card.dataset.category;
const matchSearch = !search || name.includes(search) || sku.includes(search);
const matchCategory = !category || cat === category;
card.style.display = matchSearch && matchCategory ? 'block' : 'none';
});
},
// Add to cart
async addToCart(productId) {
try {
const response = await fetch('{{ route("pos.cart.add") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
},
body: JSON.stringify({ product_id: productId }),
});
const data = await response.json();
if (data.success) {
this.cart = data.cart;
this.totals = data.totals;
this.showToast(data.message, 'success');
} else {
this.showToast(data.message, 'error');
}
} catch (error) {
this.showToast('Terjadi kesalahan', 'error');
}
},
// Update quantity
async updateQuantity(productId, quantity) {
try {
const response = await fetch('{{ route("pos.cart.update") }}', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
},
body: JSON.stringify({ product_id: productId, quantity }),
});
const data = await response.json();
if (data.success) {
this.cart = data.cart;
this.calculateTotals();
} else {
this.showToast(data.message, 'error');
}
} catch (error) {
this.showToast('Terjadi kesalahan', 'error');
}
},
// Remove item
async removeItem(productId) {
try {
const response = await fetch('{{ route("pos.cart.remove") }}', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
},
body: JSON.stringify({ product_id: productId }),
});
const data = await response.json();
if (data.success) {
this.cart = data.cart;
this.calculateTotals();
this.showToast(data.message, 'success');
}
} catch (error) {
this.showToast('Terjadi kesalahan', 'error');
}
},
// Clear cart
async clearCart() {
if (!confirm('Kosongkan semua item di cart?')) return;
try {
const response = await fetch('{{ route("pos.cart.clear") }}', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
},
});
const data = await response.json();
if (data.success) {
this.cart = {};
this.discountPercent = 0;
this.calculateTotals();
this.showToast(data.message, 'success');
}
} catch (error) {
this.showToast('Terjadi kesalahan', 'error');
}
},
// Open payment modal
openPaymentModal() {
this.showPaymentModal = true;
this.paymentMethod = 'cash';
this.paidAmount = 0;
this.paymentNotes = '';
},
// Process payment
async processPayment() {
if (this.processing) return;
this.processing = true;
try {
const response = await fetch('{{ route("pos.checkout") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
},
body: JSON.stringify({
payment_method: this.paymentMethod,
paid_amount: this.paidAmount,
discount_percent: this.discountPercent,
notes: this.paymentNotes,
}),
});
const data = await response.json();
if (data.success) {
this.showPaymentModal = false;
this.cart = {};
this.discountPercent = 0;
this.calculateTotals();
this.showToast('Transaksi berhasil! Invoice: ' + data.transaction.invoice_number, 'success');
// Open receipt in new tab
if (data.receipt_url) {
window.open(data.receipt_url, '_blank');
}
} else {
this.showToast(data.message, 'error');
}
} catch (error) {
this.showToast('Terjadi kesalahan saat proses pembayaran', 'error');
} finally {
this.processing = false;
}
},
};
}
</script>
@endpush
@endsection
Step 3: Test POS Interface
php artisan serve
npm run dev
TEST CHECKLIST:
βββββββββββββββ
1. NAVIGATE TO POS
β Login sebagai admin atau cashier
β Click "POS / Cashier" di sidebar
β Should see products grid dan empty cart
2. TEST ADD TO CART
β Click any product
β Should appear di cart
β Toast notification muncul
β Totals update
3. TEST QUANTITY
β Click + button β quantity increases
β Click - button β quantity decreases
β Quantity 0 β item removed
4. TEST SEARCH
β Type "goreng" β filter products
β Clear β show all
5. TEST CATEGORY FILTER
β Select "Minuman" β only drinks shown
β Select "Semua" β show all
6. TEST DISCOUNT
β Add items to cart
β Enter 10 di discount field
β Total should recalculate
7. TEST CLEAR CART
β Add items
β Click "Kosongkan Cart"
β Confirm β cart empty
8. TEST PAYMENT MODAL
β Add items, click "PROSES PEMBAYARAN"
β Modal opens
β Select payment method
β For cash: enter paid amount
β Quick buttons work
β Change calculated correctly
9. TEST STOCK VALIDATION
β Add item until reaching stock limit
β Should show error message
Summary Bagian 5
COMPLETED IN THIS SECTION:
ββββββββββββββββββββββββββ
β
POS Controller dengan:
βββ index() - Display POS interface
βββ addToCart() - AJAX add item
βββ updateCart() - AJAX update quantity
βββ removeFromCart() - AJAX remove item
βββ clearCart() - AJAX clear all
βββ calculateTotals() - Helper method
β
POS View dengan:
βββ Split layout (products | cart)
βββ Product grid dengan search & filter
βββ Real-time cart dengan Alpine.js
βββ Quantity controls (+/-)
βββ Discount input
βββ Tax calculation (11%)
βββ Payment modal
βββ Multiple payment methods
βββ Change calculation
βββ Toast notifications
βββ Responsive design
β
Session-based cart management
β
Stock validation on add
Apa Selanjutnya?
Di Bagian 6, kita akan implement Transaction Processing:
- Transaction & TransactionItem models
- Checkout endpoint yang process payment
- Stock reduction setelah transaksi
- Invoice number generation
- Receipt view
Setelah bagian 6, sistem PoS akan fully functional untuk melakukan transaksi!
Almost there! π
Bagian 6: Transaction Processing
Sekarang kita akan menghubungkan POS interface dengan database β menyimpan transaksi, mengurangi stock, dan generate receipt. Setelah bagian ini selesai, sistem PoS akan fully functional!
Step 1: Create Transaction Migrations
php artisan make:migration create_transactions_table
php artisan make:migration create_transaction_items_table
Transactions Table:
<?php
// database/migrations/2025_01_28_000004_create_transactions_table.php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('transactions', function (Blueprint $table) {
$table->id();
$table->string('invoice_number', 50)->unique();
$table->foreignId('user_id')->constrained()->onDelete('restrict');
$table->decimal('subtotal', 12, 2);
$table->decimal('discount_percent', 5, 2)->default(0);
$table->decimal('discount_amount', 12, 2)->default(0);
$table->decimal('tax_percent', 5, 2)->default(11);
$table->decimal('tax_amount', 12, 2);
$table->decimal('grand_total', 12, 2);
$table->enum('payment_method', ['cash', 'card', 'qris']);
$table->decimal('paid_amount', 12, 2);
$table->decimal('change_amount', 12, 2)->default(0);
$table->text('notes')->nullable();
$table->timestamps();
$table->index('invoice_number');
$table->index('created_at');
$table->index(['user_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('transactions');
}
};
Transaction Items Table:
<?php
// database/migrations/2025_01_28_000005_create_transaction_items_table.php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('transaction_items', function (Blueprint $table) {
$table->id();
$table->foreignId('transaction_id')->constrained()->onDelete('cascade');
$table->foreignId('product_id')->constrained()->onDelete('restrict');
$table->string('product_name');
$table->decimal('product_price', 12, 2);
$table->integer('quantity');
$table->decimal('subtotal', 12, 2);
$table->timestamps();
$table->index('transaction_id');
});
}
public function down(): void
{
Schema::dropIfExists('transaction_items');
}
};
Run migrations:
php artisan migrate
Step 2: Create Models
Transaction Model:
<?php
// app/Models/Transaction.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Transaction extends Model
{
use HasFactory;
protected $fillable = [
'invoice_number',
'user_id',
'subtotal',
'discount_percent',
'discount_amount',
'tax_percent',
'tax_amount',
'grand_total',
'payment_method',
'paid_amount',
'change_amount',
'notes',
];
protected $casts = [
'subtotal' => 'decimal:2',
'discount_percent' => 'decimal:2',
'discount_amount' => 'decimal:2',
'tax_percent' => 'decimal:2',
'tax_amount' => 'decimal:2',
'grand_total' => 'decimal:2',
'paid_amount' => 'decimal:2',
'change_amount' => 'decimal:2',
];
// Relationships
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(TransactionItem::class);
}
// Generate unique invoice number: INV-YYYYMMDD-XXXX
public static function generateInvoiceNumber(): string
{
$date = now()->format('Ymd');
$prefix = "INV-{$date}-";
$lastTransaction = self::where('invoice_number', 'like', "{$prefix}%")
->orderBy('invoice_number', 'desc')
->first();
if ($lastTransaction) {
$lastNumber = (int) substr($lastTransaction->invoice_number, -4);
$newNumber = $lastNumber + 1;
} else {
$newNumber = 1;
}
return $prefix . str_pad($newNumber, 4, '0', STR_PAD_LEFT);
}
// Accessors
public function getFormattedGrandTotalAttribute(): string
{
return 'Rp ' . number_format($this->grand_total, 0, ',', '.');
}
public function getFormattedDateAttribute(): string
{
return $this->created_at->format('d M Y H:i');
}
public function getPaymentMethodLabelAttribute(): string
{
return match($this->payment_method) {
'cash' => 'Cash',
'card' => 'Kartu Debit/Kredit',
'qris' => 'QRIS',
default => $this->payment_method,
};
}
}
TransactionItem Model:
<?php
// app/Models/TransactionItem.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class TransactionItem extends Model
{
use HasFactory;
protected $fillable = [
'transaction_id',
'product_id',
'product_name',
'product_price',
'quantity',
'subtotal',
];
protected $casts = [
'product_price' => 'decimal:2',
'subtotal' => 'decimal:2',
];
public function transaction(): BelongsTo
{
return $this->belongsTo(Transaction::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function getFormattedPriceAttribute(): string
{
return 'Rp ' . number_format($this->product_price, 0, ',', '.');
}
public function getFormattedSubtotalAttribute(): string
{
return 'Rp ' . number_format($this->subtotal, 0, ',', '.');
}
}
Step 3: Add Checkout Method to POS Controller
Tambahkan method checkout() di PosController:
<?php
// Di app/Http/Controllers/Cashier/PosController.php
// Tambahkan method ini dan update use statements
use App\\Models\\Transaction;
use App\\Models\\TransactionItem;
use App\\Models\\Product;
use Illuminate\\Support\\Facades\\DB;
/**
* Process checkout / payment
*/
public function checkout(Request $request): JsonResponse
{
$request->validate([
'payment_method' => 'required|in:cash,card,qris',
'paid_amount' => 'required|numeric|min:0',
'discount_percent' => 'nullable|numeric|min:0|max:100',
'notes' => 'nullable|string|max:500',
]);
$cart = session('pos_cart', []);
if (empty($cart)) {
return response()->json([
'success' => false,
'message' => 'Cart kosong.',
], 400);
}
// Calculate totals
$subtotal = collect($cart)->sum('subtotal');
$discountPercent = $request->discount_percent ?? 0;
$discountAmount = $subtotal * ($discountPercent / 100);
$afterDiscount = $subtotal - $discountAmount;
$taxPercent = 11;
$taxAmount = $afterDiscount * ($taxPercent / 100);
$grandTotal = round($afterDiscount + $taxAmount);
// Validate payment amount
if ($request->payment_method === 'cash' && $request->paid_amount < $grandTotal) {
return response()->json([
'success' => false,
'message' => 'Jumlah pembayaran kurang.',
], 400);
}
// For card/qris, paid amount equals grand total
$paidAmount = $request->payment_method === 'cash'
? $request->paid_amount
: $grandTotal;
try {
DB::beginTransaction();
// Create transaction
$transaction = Transaction::create([
'invoice_number' => Transaction::generateInvoiceNumber(),
'user_id' => auth()->id(),
'subtotal' => $subtotal,
'discount_percent' => $discountPercent,
'discount_amount' => round($discountAmount),
'tax_percent' => $taxPercent,
'tax_amount' => round($taxAmount),
'grand_total' => $grandTotal,
'payment_method' => $request->payment_method,
'paid_amount' => $paidAmount,
'change_amount' => max(0, $paidAmount - $grandTotal),
'notes' => $request->notes,
]);
// Create transaction items & decrease stock
foreach ($cart as $item) {
$product = Product::find($item['id']);
if (!$product || !$product->hasEnoughStock($item['quantity'])) {
throw new \\Exception("Stock {$item['name']} tidak mencukupi.");
}
TransactionItem::create([
'transaction_id' => $transaction->id,
'product_id' => $product->id,
'product_name' => $product->name,
'product_price' => $product->price,
'quantity' => $item['quantity'],
'subtotal' => $item['subtotal'],
]);
$product->decreaseStock($item['quantity']);
}
// Clear cart
session()->forget('pos_cart');
DB::commit();
return response()->json([
'success' => true,
'message' => 'Transaksi berhasil!',
'transaction' => $transaction->load('items'),
'receipt_url' => route('transactions.receipt', $transaction),
]);
} catch (\\Exception $e) {
DB::rollBack();
return response()->json([
'success' => false,
'message' => 'Transaksi gagal: ' . $e->getMessage(),
], 500);
}
}
Step 4: Create Transaction Controller
<?php
// app/Http/Controllers/TransactionController.php
namespace App\\Http\\Controllers;
use App\\Models\\Transaction;
use Illuminate\\Http\\Request;
use Illuminate\\View\\View;
class TransactionController extends Controller
{
public function index(Request $request): View
{
$query = Transaction::with(['user', 'items'])
->when(!auth()->user()->isAdmin(), function ($q) {
$q->where('user_id', auth()->id());
})
->when($request->search, function ($q, $search) {
$q->where('invoice_number', 'like', "%{$search}%");
})
->when($request->date, function ($q, $date) {
$q->whereDate('created_at', $date);
})
->when($request->payment_method, function ($q, $method) {
$q->where('payment_method', $method);
})
->latest()
->paginate(15)
->withQueryString();
return view('transactions.index', ['transactions' => $query]);
}
public function show(Transaction $transaction): View
{
// Check access
if (!auth()->user()->isAdmin() && $transaction->user_id !== auth()->id()) {
abort(403);
}
$transaction->load(['user', 'items']);
return view('transactions.show', compact('transaction'));
}
public function receipt(Transaction $transaction): View
{
$transaction->load(['user', 'items']);
return view('transactions.receipt', compact('transaction'));
}
}
Step 5: Create Transaction Views
Index View:
{{-- resources/views/transactions/index.blade.php --}}
@extends('layouts.app')
@section('title', 'Transactions')
@section('content')
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-800">Transaction History</h2>
</div>
{{-- Filters --}}
<div class="bg-white rounded-lg shadow p-4 mb-6">
<form action="{{ route('transactions.index') }}" method="GET" class="flex flex-wrap gap-4">
<input type="text" name="search" value="{{ request('search') }}" placeholder="Search invoice..." class="px-4 py-2 border rounded-lg">
<input type="date" name="date" value="{{ request('date') }}" class="px-4 py-2 border rounded-lg">
<select name="payment_method" class="px-4 py-2 border rounded-lg">
<option value="">All Methods</option>
<option value="cash" {{ request('payment_method') === 'cash' ? 'selected' : '' }}>Cash</option>
<option value="card" {{ request('payment_method') === 'card' ? 'selected' : '' }}>Card</option>
<option value="qris" {{ request('payment_method') === 'qris' ? 'selected' : '' }}>QRIS</option>
</select>
<button type="submit" class="px-4 py-2 bg-gray-800 text-white rounded-lg">Filter</button>
@if(request()->hasAny(['search', 'date', 'payment_method']))
<a href="{{ route('transactions.index') }}" class="px-4 py-2 bg-gray-200 rounded-lg">Clear</a>
@endif
</form>
</div>
{{-- Table --}}
<div class="bg-white rounded-lg shadow overflow-hidden">
@if($transactions->count() > 0)
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Invoice</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Items</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Total</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Payment</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Cashier</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($transactions as $transaction)
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 font-medium text-blue-600">{{ $transaction->invoice_number }}</td>
<td class="px-6 py-4 text-sm text-gray-500">{{ $transaction->created_at->format('d/m/Y H:i') }}</td>
<td class="px-6 py-4 text-sm">{{ $transaction->items->sum('quantity') }} items</td>
<td class="px-6 py-4 font-medium">{{ $transaction->formatted_grand_total }}</td>
<td class="px-6 py-4">
<span class="px-2 py-1 text-xs rounded-full {{ $transaction->payment_method === 'cash' ? 'bg-green-100 text-green-800' : ($transaction->payment_method === 'card' ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800') }}">
{{ ucfirst($transaction->payment_method) }}
</span>
</td>
<td class="px-6 py-4 text-sm">{{ $transaction->user->name }}</td>
<td class="px-6 py-4 text-right space-x-2">
<a href="{{ route('transactions.show', $transaction) }}" class="text-blue-600 hover:underline">Detail</a>
<a href="{{ route('transactions.receipt', $transaction) }}" target="_blank" class="text-gray-600 hover:underline">Receipt</a>
</td>
</tr>
@endforeach
</tbody>
</table>
@if($transactions->hasPages())
<div class="px-6 py-4 border-t">{{ $transactions->links() }}</div>
@endif
@else
<div class="px-6 py-12 text-center text-gray-500">No transactions found.</div>
@endif
</div>
@endsection
Receipt View (Print-friendly):
{{-- resources/views/transactions/receipt.blade.php --}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Receipt - {{ $transaction->invoice_number }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Courier New', monospace; font-size: 12px; width: 280px; margin: 0 auto; padding: 10px; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.font-bold { font-weight: bold; }
.mb-2 { margin-bottom: 8px; }
.mb-4 { margin-bottom: 16px; }
.border-dashed { border-top: 1px dashed #000; padding-top: 8px; margin-top: 8px; }
.flex { display: flex; justify-content: space-between; }
.item { margin-bottom: 4px; }
@media print { body { width: 80mm; } .no-print { display: none; } }
</style>
</head>
<body>
{{-- Header --}}
<div class="text-center mb-4">
<div class="font-bold" style="font-size: 16px;">TOKO SEJAHTERA</div>
<div>Jl. Contoh No. 123</div>
<div>Telp: 021-1234567</div>
</div>
<div class="border-dashed mb-2">
<div class="flex"><span>No:</span><span>{{ $transaction->invoice_number }}</span></div>
<div class="flex"><span>Tanggal:</span><span>{{ $transaction->created_at->format('d/m/Y H:i') }}</span></div>
<div class="flex"><span>Kasir:</span><span>{{ $transaction->user->name }}</span></div>
</div>
{{-- Items --}}
<div class="border-dashed mb-2">
@foreach($transaction->items as $item)
<div class="item">
<div>{{ $item->product_name }}</div>
<div class="flex">
<span>{{ $item->quantity }} x {{ number_format($item->product_price, 0, ',', '.') }}</span>
<span>{{ number_format($item->subtotal, 0, ',', '.') }}</span>
</div>
</div>
@endforeach
</div>
{{-- Totals --}}
<div class="border-dashed">
<div class="flex"><span>Subtotal</span><span>{{ number_format($transaction->subtotal, 0, ',', '.') }}</span></div>
@if($transaction->discount_amount > 0)
<div class="flex"><span>Diskon ({{ $transaction->discount_percent }}%)</span><span>-{{ number_format($transaction->discount_amount, 0, ',', '.') }}</span></div>
@endif
<div class="flex"><span>Pajak ({{ $transaction->tax_percent }}%)</span><span>{{ number_format($transaction->tax_amount, 0, ',', '.') }}</span></div>
<div class="flex font-bold" style="font-size: 14px; margin-top: 8px;">
<span>TOTAL</span><span>Rp {{ number_format($transaction->grand_total, 0, ',', '.') }}</span>
</div>
<div class="flex mt-2"><span>Bayar ({{ ucfirst($transaction->payment_method) }})</span><span>{{ number_format($transaction->paid_amount, 0, ',', '.') }}</span></div>
@if($transaction->change_amount > 0)
<div class="flex"><span>Kembali</span><span>{{ number_format($transaction->change_amount, 0, ',', '.') }}</span></div>
@endif
</div>
{{-- Footer --}}
<div class="text-center border-dashed" style="margin-top: 16px;">
<div class="mb-2">Terima Kasih</div>
<div style="font-size: 10px;">Barang yang sudah dibeli tidak dapat dikembalikan</div>
</div>
{{-- Print Button --}}
<div class="text-center no-print" style="margin-top: 20px;">
<button onclick="window.print()" style="padding: 10px 20px; cursor: pointer;">π¨οΈ Print Receipt</button>
</div>
</body>
</html>
Step 6: Test Complete Flow
TEST CHECKLIST:
βββββββββββββββ
1. COMPLETE TRANSACTION
β Login, go to POS
β Add products to cart
β Set discount (optional)
β Click "Proses Pembayaran"
β Select Cash, enter amount
β Click "Konfirmasi"
β Should show success toast
β Receipt opens in new tab
2. VERIFY DATABASE
β Check transactions table (new record)
β Check transaction_items table (items recorded)
β Check products table (stock decreased)
3. VERIFY RECEIPT
β Invoice number correct
β Items listed correctly
β Totals calculated correctly
β Print button works
4. CHECK TRANSACTION HISTORY
β Go to Transactions menu
β New transaction appears
β Filters work
β Detail view shows all info
5. TEST STOCK VALIDATION
β Try to buy more than available stock
β Should show error message
Summary Bagian 6
COMPLETED:
ββββββββββ
β
Transaction & TransactionItem migrations
β
Transaction model dengan invoice generator
β
TransactionItem model dengan snapshots
β
Checkout method dengan DB transaction
β
Stock reduction after purchase
β
Transaction history (index, show)
β
Print-friendly receipt
β
Complete purchase flow working
Apa Selanjutnya?
Di Bagian 7, kita akan build Reports & Dashboard:
- Daily/monthly sales reports
- Dashboard dengan Chart.js
- Top selling products
- Revenue statistics
4 bagian lagi! π
Bagian 7: Reports & Dashboard
Sekarang kita akan membuat dashboard yang informatif dengan statistics dan charts. Admin bisa melihat performa penjualan, produk terlaris, dan trend revenue.
Step 1: Update Dashboard Controller
<?php
// app/Http/Controllers/DashboardController.php
namespace App\\Http\\Controllers;
use App\\Models\\Product;
use App\\Models\\Transaction;
use App\\Models\\TransactionItem;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\DB;
class DashboardController extends Controller
{
public function index()
{
$isAdmin = auth()->user()->isAdmin();
$userId = auth()->id();
// Today's stats
$todayStats = $this->getTodayStats($isAdmin, $userId);
// Weekly sales (last 7 days)
$weeklySales = $this->getWeeklySales($isAdmin, $userId);
// Payment method breakdown (today)
$paymentBreakdown = $this->getPaymentBreakdown($isAdmin, $userId);
// Top products (this month)
$topProducts = $this->getTopProducts();
// Recent transactions
$recentTransactions = Transaction::with('user')
->when(!$isAdmin, fn($q) => $q->where('user_id', $userId))
->latest()
->take(5)
->get();
// Low stock products (admin only)
$lowStockProducts = $isAdmin ? Product::lowStock(10)->take(5)->get() : collect();
return view('dashboard', compact(
'todayStats',
'weeklySales',
'paymentBreakdown',
'topProducts',
'recentTransactions',
'lowStockProducts'
));
}
private function getTodayStats(bool $isAdmin, int $userId): array
{
$query = Transaction::whereDate('created_at', today());
if (!$isAdmin) {
$query->where('user_id', $userId);
}
return [
'revenue' => $query->sum('grand_total'),
'transactions' => $query->count(),
'items_sold' => TransactionItem::whereHas('transaction', function ($q) use ($isAdmin, $userId) {
$q->whereDate('created_at', today());
if (!$isAdmin) $q->where('user_id', $userId);
})->sum('quantity'),
];
}
private function getWeeklySales(bool $isAdmin, int $userId): array
{
$sales = Transaction::select(
DB::raw('DATE(created_at) as date'),
DB::raw('SUM(grand_total) as total'),
DB::raw('COUNT(*) as count')
)
->where('created_at', '>=', now()->subDays(6)->startOfDay())
->when(!$isAdmin, fn($q) => $q->where('user_id', $userId))
->groupBy('date')
->orderBy('date')
->get()
->keyBy('date');
// Fill missing dates with 0
$result = [];
for ($i = 6; $i >= 0; $i--) {
$date = now()->subDays($i)->format('Y-m-d');
$result[] = [
'date' => now()->subDays($i)->format('D'),
'total' => $sales[$date]->total ?? 0,
'count' => $sales[$date]->count ?? 0,
];
}
return $result;
}
private function getPaymentBreakdown(bool $isAdmin, int $userId): array
{
return Transaction::select('payment_method', DB::raw('COUNT(*) as count'), DB::raw('SUM(grand_total) as total'))
->whereDate('created_at', today())
->when(!$isAdmin, fn($q) => $q->where('user_id', $userId))
->groupBy('payment_method')
->get()
->toArray();
}
private function getTopProducts(): array
{
return TransactionItem::select(
'product_name',
DB::raw('SUM(quantity) as total_qty'),
DB::raw('SUM(subtotal) as total_revenue')
)
->whereHas('transaction', fn($q) => $q->whereMonth('created_at', now()->month))
->groupBy('product_name')
->orderByDesc('total_qty')
->take(5)
->get()
->toArray();
}
}
Step 2: Update Dashboard View
{{-- resources/views/dashboard.blade.php --}}
@extends('layouts.app')
@section('title', 'Dashboard')
@section('content')
{{-- Stats Cards --}}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-blue-100 text-blue-600">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Revenue Hari Ini</p>
<p class="text-2xl font-bold text-gray-900">Rp {{ number_format($todayStats['revenue'], 0, ',', '.') }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-green-100 text-green-600">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Transaksi Hari Ini</p>
<p class="text-2xl font-bold text-gray-900">{{ $todayStats['transactions'] }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-yellow-100 text-yellow-600">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Item Terjual</p>
<p class="text-2xl font-bold text-gray-900">{{ $todayStats['items_sold'] }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-purple-100 text-purple-600">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"/>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Total Products</p>
<p class="text-2xl font-bold text-gray-900">{{ \\App\\Models\\Product::count() }}</p>
</div>
</div>
</div>
</div>
{{-- Charts Row --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{{-- Weekly Sales Chart --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Penjualan 7 Hari Terakhir</h3>
<canvas id="weeklySalesChart" height="200"></canvas>
</div>
{{-- Payment Methods Chart --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Metode Pembayaran Hari Ini</h3>
@if(count($paymentBreakdown) > 0)
<canvas id="paymentChart" height="200"></canvas>
@else
<div class="h-48 flex items-center justify-center text-gray-400">
Belum ada transaksi hari ini
</div>
@endif
</div>
</div>
{{-- Bottom Row --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Top Products --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Produk Terlaris (Bulan Ini)</h3>
@if(count($topProducts) > 0)
<div class="space-y-3">
@foreach($topProducts as $index => $product)
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-blue-100 text-blue-600 text-xs flex items-center justify-center font-bold">{{ $index + 1 }}</span>
<span class="text-sm font-medium truncate max-w-[150px]">{{ $product['product_name'] }}</span>
</div>
<span class="text-sm text-gray-500">{{ $product['total_qty'] }} terjual</span>
</div>
@endforeach
</div>
@else
<p class="text-gray-400 text-center py-8">Belum ada data</p>
@endif
</div>
{{-- Recent Transactions --}}
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Transaksi Terakhir</h3>
@if($recentTransactions->count() > 0)
<div class="space-y-3">
@foreach($recentTransactions as $trx)
<div class="flex items-center justify-between text-sm">
<div>
<p class="font-medium text-blue-600">{{ $trx->invoice_number }}</p>
<p class="text-xs text-gray-400">{{ $trx->created_at->diffForHumans() }}</p>
</div>
<span class="font-medium">{{ $trx->formatted_grand_total }}</span>
</div>
@endforeach
</div>
<a href="{{ route('transactions.index') }}" class="block text-center text-sm text-blue-600 mt-4 hover:underline">Lihat semua β</a>
@else
<p class="text-gray-400 text-center py-8">Belum ada transaksi</p>
@endif
</div>
{{-- Low Stock Alert (Admin Only) --}}
@if(auth()->user()->isAdmin())
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">β οΈ Stok Menipis</h3>
@if($lowStockProducts->count() > 0)
<div class="space-y-3">
@foreach($lowStockProducts as $product)
<div class="flex items-center justify-between">
<span class="text-sm font-medium truncate max-w-[150px]">{{ $product->name }}</span>
<span class="px-2 py-1 text-xs rounded-full {{ $product->stock == 0 ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700' }}">
{{ $product->stock }} left
</span>
</div>
@endforeach
</div>
<a href="{{ route('admin.products.index', ['stock' => 'low']) }}" class="block text-center text-sm text-blue-600 mt-4 hover:underline">Lihat semua β</a>
@else
<p class="text-green-600 text-center py-8">β Semua stok aman</p>
@endif
</div>
@endif
</div>
@endsection
@push('scripts')
<script src="<https://cdn.jsdelivr.net/npm/chart.js>"></script>
<script>
// Weekly Sales Chart
new Chart(document.getElementById('weeklySalesChart'), {
type: 'line',
data: {
labels: {!! json_encode(collect($weeklySales)->pluck('date')) !!},
datasets: [{
label: 'Revenue',
data: {!! json_encode(collect($weeklySales)->pluck('total')) !!},
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3,
fill: true,
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true,
ticks: {
callback: value => 'Rp ' + new Intl.NumberFormat('id').format(value)
}
}
}
}
});
// Payment Methods Chart
@if(count($paymentBreakdown) > 0)
new Chart(document.getElementById('paymentChart'), {
type: 'doughnut',
data: {
labels: {!! json_encode(collect($paymentBreakdown)->pluck('payment_method')->map(fn($m) => ucfirst($m))) !!},
datasets: [{
data: {!! json_encode(collect($paymentBreakdown)->pluck('total')) !!},
backgroundColor: ['#10B981', '#3B82F6', '#8B5CF6'],
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' }
}
}
});
@endif
</script>
@endpush
Step 3: Create Reports Controller (Admin)
<?php
// app/Http/Controllers/Admin/ReportController.php
namespace App\\Http\\Controllers\\Admin;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Transaction;
use App\\Models\\TransactionItem;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\DB;
class ReportController extends Controller
{
public function index()
{
return view('admin.reports.index');
}
public function daily(Request $request)
{
$date = $request->date ? \\Carbon\\Carbon::parse($request->date) : today();
$transactions = Transaction::with(['user', 'items'])
->whereDate('created_at', $date)
->latest()
->get();
$summary = [
'total_revenue' => $transactions->sum('grand_total'),
'total_transactions' => $transactions->count(),
'total_items' => $transactions->sum(fn($t) => $t->items->sum('quantity')),
'avg_transaction' => $transactions->count() > 0 ? $transactions->avg('grand_total') : 0,
'by_payment' => $transactions->groupBy('payment_method')->map(fn($g) => [
'count' => $g->count(),
'total' => $g->sum('grand_total'),
]),
];
return view('admin.reports.daily', compact('date', 'transactions', 'summary'));
}
}
Daily Report View:
{{-- resources/views/admin/reports/daily.blade.php --}}
@extends('layouts.app')
@section('title', 'Daily Report')
@section('content')
<div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl font-bold">Laporan Harian</h2>
<form class="flex gap-2">
<input type="date" name="date" value="{{ $date->format('Y-m-d') }}" class="px-4 py-2 border rounded-lg">
<button class="px-4 py-2 bg-blue-600 text-white rounded-lg">Tampilkan</button>
</form>
</div>
{{-- Summary Cards --}}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Total Revenue</p>
<p class="text-xl font-bold">Rp {{ number_format($summary['total_revenue'], 0, ',', '.') }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Transaksi</p>
<p class="text-xl font-bold">{{ $summary['total_transactions'] }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Items Sold</p>
<p class="text-xl font-bold">{{ $summary['total_items'] }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Rata-rata/Transaksi</p>
<p class="text-xl font-bold">Rp {{ number_format($summary['avg_transaction'], 0, ',', '.') }}</p>
</div>
</div>
{{-- Payment Breakdown --}}
<div class="bg-white rounded-lg shadow p-4 mb-6">
<h3 class="font-semibold mb-3">Berdasarkan Metode Pembayaran</h3>
<div class="flex gap-6">
@foreach($summary['by_payment'] as $method => $data)
<div>
<span class="text-sm text-gray-500">{{ ucfirst($method) }}:</span>
<span class="font-medium">{{ $data['count'] }} transaksi</span>
<span class="text-gray-400">(Rp {{ number_format($data['total'], 0, ',', '.') }})</span>
</div>
@endforeach
</div>
</div>
{{-- Transactions Table --}}
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Invoice</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Time</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Items</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Total</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Payment</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Cashier</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@forelse($transactions as $trx)
<tr>
<td class="px-4 py-3 text-sm text-blue-600">{{ $trx->invoice_number }}</td>
<td class="px-4 py-3 text-sm">{{ $trx->created_at->format('H:i') }}</td>
<td class="px-4 py-3 text-sm">{{ $trx->items->sum('quantity') }}</td>
<td class="px-4 py-3 text-sm font-medium">{{ $trx->formatted_grand_total }}</td>
<td class="px-4 py-3 text-sm">{{ ucfirst($trx->payment_method) }}</td>
<td class="px-4 py-3 text-sm">{{ $trx->user->name }}</td>
</tr>
@empty
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">Tidak ada transaksi</td></tr>
@endforelse
</tbody>
</table>
</div>
@endsection
Summary Bagian 7
COMPLETED:
ββββββββββ
β
Dashboard dengan:
βββ Today's stats (revenue, transactions, items)
βββ Weekly sales line chart
βββ Payment method doughnut chart
βββ Top 5 products (this month)
βββ Recent transactions
βββ Low stock alerts (admin)
β
Reports:
βββ Daily report dengan date picker
βββ Summary cards
βββ Payment breakdown
βββ Transactions table
β
Chart.js integration
Apa Selanjutnya?
Bagian 8: Polish & Testing Bagian 9: Deploy ke Railway
Bagian 10: Closing & Recommendations
3 bagian lagi! π
Bagian 8: Polish & Testing
Sebelum deploy ke production, kita perlu memastikan aplikasi sudah polished dan tested. Di bagian ini kita akan menambahkan finishing touches dan melakukan comprehensive testing.
Step 1: Create Complete Seeders
Update semua seeders untuk testing yang comprehensive:
<?php
// database/seeders/DatabaseSeeder.php
namespace Database\\Seeders;
use Illuminate\\Database\\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
AdminUserSeeder::class,
CategorySeeder::class,
ProductSeeder::class,
TransactionSeeder::class, // NEW
]);
}
}
Transaction Seeder (untuk testing reports):
<?php
// database/seeders/TransactionSeeder.php
namespace Database\\Seeders;
use App\\Models\\Product;
use App\\Models\\Transaction;
use App\\Models\\TransactionItem;
use App\\Models\\User;
use Illuminate\\Database\\Seeder;
class TransactionSeeder extends Seeder
{
public function run(): void
{
$cashiers = User::where('role', 'cashier')->orWhere('role', 'admin')->get();
$products = Product::where('stock', '>', 5)->get();
$paymentMethods = ['cash', 'card', 'qris'];
// Generate 30 transactions over the past 7 days
for ($i = 0; $i < 30; $i++) {
$date = now()->subDays(rand(0, 6))->subHours(rand(0, 12));
$cashier = $cashiers->random();
// Random 1-5 items per transaction
$itemCount = rand(1, 5);
$selectedProducts = $products->random($itemCount);
$subtotal = 0;
$items = [];
foreach ($selectedProducts as $product) {
$qty = rand(1, 3);
$itemSubtotal = $product->price * $qty;
$subtotal += $itemSubtotal;
$items[] = [
'product_id' => $product->id,
'product_name' => $product->name,
'product_price' => $product->price,
'quantity' => $qty,
'subtotal' => $itemSubtotal,
];
}
$discountPercent = rand(0, 1) ? rand(5, 15) : 0;
$discountAmount = $subtotal * ($discountPercent / 100);
$afterDiscount = $subtotal - $discountAmount;
$taxAmount = $afterDiscount * 0.11;
$grandTotal = round($afterDiscount + $taxAmount);
$paymentMethod = $paymentMethods[array_rand($paymentMethods)];
$paidAmount = $paymentMethod === 'cash'
? ceil($grandTotal / 10000) * 10000
: $grandTotal;
$transaction = Transaction::create([
'invoice_number' => 'INV-' . $date->format('Ymd') . '-' . str_pad($i + 1, 4, '0', STR_PAD_LEFT),
'user_id' => $cashier->id,
'subtotal' => $subtotal,
'discount_percent' => $discountPercent,
'discount_amount' => round($discountAmount),
'tax_percent' => 11,
'tax_amount' => round($taxAmount),
'grand_total' => $grandTotal,
'payment_method' => $paymentMethod,
'paid_amount' => $paidAmount,
'change_amount' => max(0, $paidAmount - $grandTotal),
'created_at' => $date,
'updated_at' => $date,
]);
foreach ($items as $item) {
TransactionItem::create(array_merge($item, [
'transaction_id' => $transaction->id,
'created_at' => $date,
'updated_at' => $date,
]));
}
}
$this->command->info('30 sample transactions created!');
}
}
Run fresh migration dengan semua seeders:
php artisan migrate:fresh --seed
Step 2: Add Loading States & UX Improvements
Global Loading Component (tambahkan di layout):
{{-- resources/views/components/loading-overlay.blade.php --}}
<div
x-data="{ loading: false }"
x-on:loading.window="loading = true"
x-on:loaded.window="loading = false"
>
<div x-show="loading" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" style="display: none;">
<div class="bg-white rounded-lg p-6 flex items-center gap-3">
<svg class="animate-spin h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>Processing...</span>
</div>
</div>
</div>
Step 3: Error Handling
Custom Error Pages:
php artisan vendor:publish --tag=laravel-errors
Edit resources/views/errors/403.blade.php:
@extends('layouts.guest')
@section('content')
<div class="min-h-screen flex items-center justify-center">
<div class="text-center">
<h1 class="text-6xl font-bold text-gray-300">403</h1>
<p class="text-xl text-gray-600 mt-4">Access Denied</p>
<p class="text-gray-500 mt-2">You don't have permission to access this page.</p>
<a href="{{ route('dashboard') }}" class="inline-block mt-6 px-6 py-3 bg-blue-600 text-white rounded-lg">
Back to Dashboard
</a>
</div>
</div>
@endsection
Step 4: Final Testing Checklist
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
COMPREHENSIVE TESTING CHECKLIST
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
AUTHENTICATION
ββββββββββββββ
β‘ Register new user β defaults to cashier role
β‘ Login with admin credentials
β‘ Login with cashier credentials
β‘ Logout functionality
β‘ Password validation
ROLE-BASED ACCESS
βββββββββββββββββ
β‘ Admin can access all menus
β‘ Cashier CANNOT access: Categories, Products, Reports, Users
β‘ Cashier CAN access: Dashboard, POS, Transactions
β‘ Direct URL access returns 403 for unauthorized
CATEGORIES (Admin)
ββββββββββββββββββ
β‘ List categories with pagination
β‘ Search categories
β‘ Create new category
β‘ Edit existing category
β‘ Delete category (no products)
β‘ Cannot delete category with products
PRODUCTS (Admin)
ββββββββββββββββ
β‘ List products with grid view
β‘ Filter by category
β‘ Filter by stock status
β‘ Search by name/SKU
β‘ Create product with image
β‘ Create product without image
β‘ Edit product, replace image
β‘ Edit product, remove image
β‘ Delete product (image also deleted)
β‘ Validation errors display correctly
POS INTERFACE
βββββββββββββ
β‘ Products load correctly
β‘ Search filters products
β‘ Category filter works
β‘ Click product adds to cart
β‘ Quantity +/- buttons work
β‘ Remove item from cart
β‘ Clear cart (with confirmation)
β‘ Discount calculation correct
β‘ Tax (11%) calculation correct
β‘ Total updates in real-time
β‘ Stock validation (can't exceed)
PAYMENT PROCESSING
ββββββββββββββββββ
β‘ Payment modal opens
β‘ Cash payment with change calculation
β‘ Quick amount buttons work
β‘ Card payment (auto exact amount)
β‘ QRIS payment (auto exact amount)
β‘ Insufficient payment shows error
β‘ Successful transaction:
β‘ Cart cleared
β‘ Stock decreased
β‘ Receipt opens
β‘ Transaction saved to DB
TRANSACTIONS
ββββββββββββ
β‘ List shows all transactions (admin)
β‘ List shows own transactions (cashier)
β‘ Search by invoice number
β‘ Filter by date
β‘ Filter by payment method
β‘ View transaction detail
β‘ Print receipt
DASHBOARD
βββββββββ
β‘ Stats cards show correct data
β‘ Weekly sales chart renders
β‘ Payment method chart renders
β‘ Top products list accurate
β‘ Recent transactions list
β‘ Low stock alerts (admin)
REPORTS (Admin)
βββββββββββββββ
β‘ Daily report loads
β‘ Date picker works
β‘ Summary calculations correct
β‘ Transaction list accurate
RESPONSIVE DESIGN
βββββββββββββββββ
β‘ Mobile sidebar collapses
β‘ Mobile menu toggle works
β‘ POS usable on tablet
β‘ Tables scroll horizontally on mobile
EDGE CASES
ββββββββββ
β‘ Empty states display correctly
β‘ No transactions = empty charts
β‘ Out of stock products not in POS
β‘ Inactive products not in POS
β‘ Large numbers format correctly
β‘ Long product names truncate
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Step 5: Performance Optimizations
Add Database Indexes (sudah ada di migrations):
- β
transactions.invoice_number - β
transactions.created_at - β
transactions.user_id - β
products.name - β
products.sku - β
products.is_active
Eager Loading (sudah implemented):
- β Products with Category
- β Transactions with User and Items
Config Caching (untuk production):
php artisan config:cache
php artisan route:cache
php artisan view:cache
Step 6: Environment Configuration
Pastikan .env.example lengkap:
APP_NAME="POS System"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=pos_system
DB_USERNAME=root
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
CACHE_DRIVER=database
QUEUE_CONNECTION=database
FILESYSTEM_DISK=public
Summary Bagian 8
COMPLETED:
ββββββββββ
β
TransactionSeeder untuk test data
β
Loading states
β
Custom error pages
β
Comprehensive testing checklist
β
Performance optimizations
β
Environment configuration
Apa Selanjutnya?
Bagian 9: Deploy ke Railway β Go live! Bagian 10: Closing & Rekomendasi Kelas
2 bagian lagi! Almost there! π
Bagian 9: Deploy ke Railway
Saatnya membawa aplikasi PoS kita ke production! Railway adalah platform deployment modern yang sangat cocok untuk Laravel β setup mudah, free tier generous, dan mendukung MySQL.
Step 1: Prepare Project untuk Production
Update .env.example:
APP_NAME="POS System"
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=
DB_CONNECTION=mysql
DB_HOST=
DB_PORT=3306
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=
SESSION_DRIVER=database
CACHE_DRIVER=database
QUEUE_CONNECTION=sync
FILESYSTEM_DISK=public
Create Procfile di root project:
web: php artisan serve --host=0.0.0.0 --port=$PORT
Create nixpacks.toml untuk build configuration:
[phases.setup]
nixPkgs = ["php82", "php82Extensions.pdo_mysql", "php82Extensions.mbstring", "php82Extensions.xml", "php82Extensions.curl", "php82Extensions.gd", "nodejs_18"]
[phases.install]
cmds = [
"composer install --no-dev --optimize-autoloader",
"npm ci && npm run build"
]
[phases.build]
cmds = [
"php artisan config:cache",
"php artisan route:cache",
"php artisan view:cache",
"php artisan storage:link || true"
]
[start]
cmd = "php artisan migrate --force && php artisan serve --host=0.0.0.0 --port=$PORT"
Step 2: Push ke GitHub
# Initialize git (jika belum)
git init
# Add .gitignore sudah ada dari Laravel
# Pastikan /vendor, /node_modules, .env tidak ter-commit
# Add semua files
git add .
# Commit
git commit -m "POS System - Ready for deployment"
# Create repo di GitHub, lalu:
git remote add origin <https://github.com/USERNAME/pos-system.git>
git branch -M main
git push -u origin main
Step 3: Setup Railway
RAILWAY SETUP STEP-BY-STEP:
βββββββββββββββββββββββββββ
1. BUAT AKUN RAILWAY
β Buka railway.app
β Sign up dengan GitHub
β Authorize Railway
2. CREATE NEW PROJECT
β Dashboard β "New Project"
β Pilih "Deploy from GitHub repo"
β Select repository "pos-system"
β Railway akan detect sebagai PHP/Laravel
3. ADD MYSQL DATABASE
β Dalam project, klik "+ New"
β Pilih "Database" β "MySQL"
β Railway akan provision MySQL instance
β Tunggu sampai status "Available"
4. CONFIGURE ENVIRONMENT VARIABLES
β Klik service Laravel (bukan MySQL)
β Tab "Variables"
β Add variables berikut:
Environment Variables untuk Railway:
APP_NAME=POS System
APP_ENV=production
APP_KEY=base64:GENERATE_DENGAN_php_artisan_key:generate_--show
APP_DEBUG=false
APP_URL=${{RAILWAY_PUBLIC_DOMAIN}}
DB_CONNECTION=mysql
DB_HOST=${{MySQL.MYSQLHOST}}
DB_PORT=${{MySQL.MYSQLPORT}}
DB_DATABASE=${{MySQL.MYSQLDATABASE}}
DB_USERNAME=${{MySQL.MYSQLUSER}}
DB_PASSWORD=${{MySQL.MYSQLPASSWORD}}
SESSION_DRIVER=database
CACHE_DRIVER=database
QUEUE_CONNECTION=sync
Generate APP_KEY:
# Di local terminal
php artisan key:generate --show
# Copy output: base64:xxxxxxxxxxxxx
Step 4: Deploy
DEPLOYMENT PROCESS:
βββββββββββββββββββ
1. Setelah variables di-set, Railway auto-deploy
2. Monitor di tab "Deployments"
3. Lihat logs untuk troubleshoot
4. Tunggu status "Success"
5. AKSES APLIKASI
β Tab "Settings" β "Domains"
β Klik "Generate Domain"
β Dapat URL: pos-system-xxx.up.railway.app
Step 5: Post-Deployment Setup
Run Seeder (via Railway CLI atau Dashboard):
# Install Railway CLI
npm install -g @railway/cli
# Login
railway login
# Link project
railway link
# Run commands
railway run php artisan db:seed
Atau buat route temporary untuk seeding:
// routes/web.php (HAPUS SETELAH SELESAI!)
Route::get('/setup-db', function () {
if (app()->environment('production')) {
Artisan::call('db:seed', ['--force' => true]);
return 'Database seeded!';
}
return 'Not in production';
});
Step 6: Verify Deployment
DEPLOYMENT CHECKLIST:
βββββββββββββββββββββ
β‘ Website accessible via Railway URL
β‘ Login page loads
β‘ Login dengan [email protected] / password
β‘ Dashboard menampilkan data
β‘ Navigate ke semua menu
β‘ POS interface functional
β‘ Create test transaction
β‘ Receipt dapat dibuka
β‘ Images upload berfungsi (jika pakai cloud storage)
Troubleshooting
Error: "500 Internal Server Error"
# Check logs di Railway dashboard
# Biasanya: APP_KEY belum di-set atau DB connection gagal
Error: "SQLSTATE Connection refused"
β Pastikan MySQL service sudah running
β Check variable DB_HOST pakai ${{MySQL.MYSQLHOST}}
β Bukan hardcoded value
Error: "The stream or file storage/logs/laravel.log could not be opened"
# Tambahkan di nixpacks.toml build:
"chmod -R 775 storage bootstrap/cache"
Assets tidak loading:
β Pastikan npm run build sudah jalan di build phase
β Check apakah APP_URL sudah benar
Custom Domain (Optional)
SETUP CUSTOM DOMAIN:
ββββββββββββββββββββ
1. Railway Dashboard β Settings β Domains
2. Klik "Add Custom Domain"
3. Masukkan domain: pos.yourdomain.com
4. Railway akan berikan CNAME record
5. Di DNS provider (Cloudflare, dll):
- Add CNAME record
- Name: pos
- Target: [value dari Railway]
6. Tunggu propagation (5-30 menit)
7. Railway auto-provision SSL certificate
Summary Bagian 9
COMPLETED:
ββββββββββ
β
Production configuration files
βββ Procfile
βββ nixpacks.toml
β
GitHub repository setup
β
Railway deployment
βββ Project creation
βββ MySQL database
βββ Environment variables
βββ Auto-deployment
β
Post-deployment setup
βββ Database seeding
βββ Verification checklist
β
Troubleshooting guide
π Aplikasi Sekarang LIVE!
Selamat! Sistem PoS kamu sekarang bisa diakses secara online di:
<https://pos-system-xxx.up.railway.app>
Apa Selanjutnya?
Bagian 10: Closing & Rekomendasi Kelas BuildWithAngga
Last one! π
Bagian 10: Closing & Next Steps
Selamat! π Kamu telah berhasil membangun sistem Point of Sale lengkap dengan Laravel menggunakan vibe coding approach, dan sudah deploy ke production di Railway.
Apa yang Sudah Kita Bangun
POS SYSTEM - COMPLETE FEATURES:
βββββββββββββββββββββββββββββββ
β
AUTHENTICATION & AUTHORIZATION
βββ Login/Register dengan Laravel Breeze
βββ Role-based access (Admin & Cashier)
βββ Protected routes dengan middleware
β
PRODUCT MANAGEMENT
βββ Categories CRUD
βββ Products CRUD dengan image upload
βββ Stock tracking
βββ Search & filters
β
POS INTERFACE
βββ Real-time cart dengan Alpine.js
βββ Product search & category filter
βββ Discount & tax calculation
βββ Multiple payment methods
β
TRANSACTION PROCESSING
βββ Complete checkout flow
βββ Automatic stock reduction
βββ Invoice number generation
βββ Print-friendly receipts
β
REPORTS & DASHBOARD
βββ Revenue statistics
βββ Sales charts dengan Chart.js
βββ Top selling products
βββ Low stock alerts
β
PRODUCTION DEPLOYMENT
βββ Railway hosting
βββ MySQL database
βββ Live & accessible online
Skills yang Kamu Pelajari
TECHNICAL SKILLS:
βββββββββββββββββ
β’ Laravel 11 (routing, controllers, models, migrations)
β’ Eloquent ORM (relationships, scopes, accessors)
β’ Blade templating dengan components
β’ Tailwind CSS untuk styling
β’ Alpine.js untuk reactivity
β’ Chart.js untuk data visualization
β’ Session management untuk cart
β’ Image upload & storage
β’ Database transactions
β’ Railway deployment
VIBE CODING SKILLS:
βββββββββββββββββββ
β’ Planning sebelum coding
β’ Writing effective prompts
β’ Iterative development dengan AI
β’ Code review & understanding
β’ Debugging dengan AI assistance
Possible Improvements
Sistem ini sudah functional, tapi masih banyak yang bisa ditambahkan:
FITUR TAMBAHAN YANG BISA DIKEMBANGKAN:
ββββββββββββββββββββββββββββββββββββββ
π± MOBILE & UX
β’ Progressive Web App (PWA)
β’ Barcode scanner integration
β’ Keyboard shortcuts untuk POS
β’ Dark mode
π¦ INVENTORY
β’ Stock opname
β’ Purchase orders
β’ Supplier management
β’ Stock alerts via email
π₯ CUSTOMERS
β’ Customer database
β’ Loyalty points
β’ Member discounts
β’ Purchase history
π° PAYMENTS
β’ Multiple payment gateway (Midtrans, Xendit)
β’ Split payment
β’ Credit/hutang management
β’ Refund handling
π ADVANCED REPORTS
β’ Export to Excel/PDF
β’ Profit margin reports
β’ Hourly sales analysis
β’ Employee performance
πͺ MULTI-OUTLET
β’ Multiple store support
β’ Centralized management
β’ Stock transfer antar outlet
β’ Consolidated reports
Project Ini Bisa Jadi Portfolio
CARA SHOWCASE PROJECT INI:
ββββββββββββββββββββββββββ
1. GITHUB
β’ Push ke public repository
β’ Tulis README yang bagus
β’ Include screenshots
β’ List tech stack & features
2. LIVE DEMO
β’ Keep Railway deployment running
β’ Buat demo account untuk recruiters
β’ Seed dengan realistic data
3. CASE STUDY
β’ Tulis blog tentang proses development
β’ Jelaskan challenges & solutions
β’ Share di LinkedIn
4. PORTFOLIO WEBSITE
β’ Include di personal portfolio
β’ Link ke GitHub & live demo
β’ Highlight vibe coding approach
Rekomendasi Kelas di BuildWithAngga
Untuk memperdalam skill kamu dan melanjutkan perjalanan sebagai developer, berikut kelas-kelas yang saya rekomendasikan di BuildWithAngga:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
π KELAS VIBE CODING
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Pelajari cara memaksimalkan AI sebagai coding partner:
π Vibe Coding Fundamentals
β’ Mindset dan workflow vibe coding
β’ Prompting techniques
β’ Tools comparison (Cursor, Copilot, Claude)
π Build Projects dengan AI
β’ Real-world projects dengan AI assistance
β’ Best practices dan patterns
β’ Code review dengan AI
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
π KELAS LARAVEL
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Perdalam framework Laravel:
π Laravel untuk Pemula
β’ Fundamental Laravel dari nol
β’ MVC architecture
β’ Eloquent ORM mastery
π Laravel Livewire
β’ Full-stack reactive components
β’ Real-time tanpa JavaScript
β’ Modern Laravel development
π Laravel REST API
β’ API development best practices
β’ Authentication dengan Sanctum
β’ API versioning & documentation
π Laravel E-commerce
β’ Build toko online lengkap
β’ Payment gateway integration
β’ Order management system
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
π KELAS DEPLOYMENT & SERVER
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Kuasai deployment dan server management:
π Deploy Laravel ke VPS
β’ Setup Ubuntu server
β’ Install LEMP stack
β’ SSL configuration
β’ Domain setup
π Nginx Mastery
β’ Nginx configuration
β’ Reverse proxy
β’ Load balancing
β’ Performance optimization
π Docker untuk Developer
β’ Container fundamentals
β’ Docker Compose
β’ Laravel dengan Docker
β’ CI/CD pipelines
π CI/CD & DevOps
β’ GitHub Actions
β’ Automated testing
β’ Automated deployment
β’ Monitoring & logging
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
π KELAS PENDUKUNG
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Skills tambahan yang valuable:
π JavaScript Modern
β’ ES6+ features
β’ Async/await
β’ DOM manipulation
π Tailwind CSS
β’ Utility-first CSS
β’ Responsive design
β’ Custom configurations
π Git & GitHub
β’ Version control
β’ Branching strategies
β’ Collaboration workflows
π MySQL & Database Design
β’ Query optimization
β’ Database modeling
β’ Performance tuning
Akses Semua Kelas
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β π MULAI BELAJAR SEKARANG DI BUILDWITHANGGA β
β β
β π <https://buildwithangga.com> β
β β
β β Kelas terstruktur dari pemula sampai mahir β
β β Project-based learning β
β β Mentor support β
β β Sertifikat completion β
β β Akses selamanya β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Pesan Penutup
Membangun aplikasi full-stack seperti sistem PoS ini dulu membutuhkan waktu berminggu-minggu. Dengan vibe coding, kamu bisa melakukannya dalam hitungan hari β bahkan jam.
Tapi ingat: AI adalah tools, bukan pengganti understanding.
Yang membuat kamu menjadi developer yang baik bukan seberapa cepat kamu generate code, tapi seberapa dalam kamu memahami code tersebut dan bisa memodifikasinya sesuai kebutuhan.
TIPS TERAKHIR:
ββββββββββββββ
1. JANGAN BERHENTI DI SINI
Project ini baru permulaan. Extend, improve, dan build more.
2. PRACTICE MAKES PERFECT
Semakin sering vibe coding, semakin tajam intuisi kamu.
3. SHARE YOUR WORK
Push ke GitHub, share di LinkedIn, bantu orang lain belajar.
4. KEEP LEARNING
Tech terus berkembang. Stay curious, stay learning.
5. BUILD REAL PROJECTS
Portfolio dengan real projects > 100 tutorial tanpa output.
Thank You!
Terima kasih sudah mengikuti tutorial ini dari awal sampai akhir. Semoga ilmu yang didapat bermanfaat dan bisa membuka peluang baru dalam karir kamu sebagai developer.
Kalau ada pertanyaan atau mau share progress, feel free untuk reach out!
Happy coding! π
Angga Risky Setiawan AI Product Engineer & Founder BuildWithAngga
Tutorial ini bermanfaat? Share ke teman-teman yang juga mau belajar Laravel dan vibe coding!